Compare commits

...

3 Commits

Author SHA1 Message Date
Cauê Faleiros
da903db32a feat: serve pdf.js from this origin instead of a CDN
All checks were successful
Build and deploy / Validate source (push) Successful in 5s
Build and deploy / Integration suite on a real stack (push) Successful in 1m10s
Build and deploy / Secret scan and release gate (push) Successful in 5s
Build and deploy / Publish images and notify Portainer (push) Successful in 1m43s
The Site pulled pdf.js 3.11.174 from cdnjs with no integrity attribute, and the
policy trusted the whole of cdnjs.cloudflare.com for both script-src and
worker-src. Anything that host served would have executed, and a customer
measuring a PDF sheet depended on it being reachable.

Vendor both files instead of pinning a hash: it removes the dependency rather
than constraining it, and lets the policy name only 'self'. Provenance and
SHA-256 digests are recorded in local/static/vendor/README.md, verified on
download against the SRI digests cdnjs publishes for that release.

cdnjs is now absent from script-src, worker-src and connect-src in both gateway
templates. Workers are 'self' plus blob:, which the Site needs for the worker it
constructs itself.

Verified in a browser against the running stack: pdf.js loads from /vendor/, the
blob worker starts, and a real seven-page PDF parses with no CSP violation. Both
browser suites and the full integration suite pass.

The version is deliberately unchanged. 3.11.174 is old, but its known eval path
is already closed by isEvalSupported:false, and upgrading is an API change that
needs its own testing rather than riding along with this.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-21 13:56:02 -03:00
Cauê Faleiros
9926d3ab55 chore: remove the unused second stack definition
deploy/stack.yaml arrived in the first commit and was never deployed. Portainer
runs the repository's docker-compose.yml. Keeping both meant two definitions
drifting apart, with the documentation naming the one nobody used, which is how
the credential question came up at all.

The hardening it offered is narrower than it looks: Docker secrets keep values
out of docker inspect and the Portainer console, but local/secrets.py loads them
into the process environment regardless, and anyone able to read docker inspect
can already read the secret files. With a single Portainer user, the benefit that
remains does not outweigh maintaining a divergent copy.

local/secrets.py stays: inert against the deployed file, and it lets a stack
switch to Docker secrets later without touching code. The preflight and its tests
degrade cleanly when no such stack is present.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-21 13:48:22 -03:00
Cauê Faleiros
e95a42dbed docs: name the stack Portainer actually deploys, and its credential exposure
PORTAINER.md called deploy/stack.yaml the production stack. The deployed file is
docker-compose.yml, which supplies eight credentials as plain environment
variables where stack.yaml uses Docker secrets. That puts the database password,
operator password and the R2 secret key in the container environment, readable
through docker inspect and the Portainer stack editor.

Recorded as ROADMAP 2.12 with the three options rather than changed here:
altering how production receives credentials is not a quiet change.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-21 13:42:12 -03:00
12 changed files with 141 additions and 325 deletions

View File

@@ -35,7 +35,6 @@ jobs:
# taken on the machine itself. Known occupants of that host:
# 8000, 9443 Portainer (the Edge tunnel and its UI)
# 18080/18081 the production dtf-cloud stack (docker-compose.yml defaults)
# 8080/8081 deploy/stack.yaml defaults
# 9000/9001 MinIO defaults elsewhere
# This block avoids all of them. Ephemeral ports are not an option: the
# published port is baked into PUBLIC_ORIGIN, ALLOWED_ORIGINS and the CSP

View File

@@ -4,7 +4,13 @@ DTF follows the same operating model as Graphs and ComporHUB: Gitea builds
prebuilt images, pushes them to the Gitea registry, and calls one Portainer
webhook. Portainer owns and redeploys one Docker Swarm stack named `dtf-cloud`.
The production stack is `deploy/stack.yaml`. It contains Site, Kanban, API,
The deployed stack is the repository's `docker-compose.yml`, which the
`dtf-cloud` Portainer stack points at. `deploy/stack.yaml` is a more hardened
definition that supplies every credential as a Docker secret rather than an
environment variable; it is not currently deployed. See `ROADMAP.md` 2.12 before
assuming either is authoritative.
`deploy/stack.yaml` contains Site, Kanban, API,
worker, PostgreSQL, ClamAV, and a one-time database initializer. Production uses
Cloudflare R2, so MinIO is not part of this stack.

View File

@@ -222,16 +222,57 @@ refactor: `'This runtime only supports APP_ENV=local'`,
- Replace marker matching with behavioural assertions (import the module, assert
the adapter classes in use).
### `[x]` 2.12 — Two divergent stack definitions; the docs named the wrong one
Found 2026-09-21 by asking which file Portainer deploys. `PORTAINER.md` called
`deploy/stack.yaml` "the production stack"; the deployed file is the repository's
`docker-compose.yml`. `deploy/stack.yaml` came from the first commit and was never
deployed — it supplied credentials as Docker secrets where the deployed file uses
plain environment variables.
**Decision (2026-09-21): keep `docker-compose.yml`, delete `deploy/stack.yaml`.**
The gain from Docker secrets here is narrower than it sounds. It keeps values out
of `docker inspect` and the Portainer UI, but `local/secrets.py` loads them into
the process environment anyway, and anyone who can read `docker inspect` is
already root or in the docker group and could read the secret files directly. The
operator is the only Portainer user, so the main benefit — limiting what a
lower-privileged console user can see — does not apply. Maintaining two
definitions that drift was the larger real cost.
`local/secrets.py` stays. It is inert against the deployed file and costs nothing,
and it means a stack can switch to Docker secrets later without a code change.
Still open: **rotate the R2 secret key.** Not because of Portainer, but because it
grants read and write over every customer's artwork and has been readable from the
stack environment for some time. The operator password is worth rotating with it.
### `[x]` 2.6 — Base images are not pinned `(F10)`
Dockerfiles default to mutable `python:3.12-slim` / `nginx:1.28-alpine`, the
workflow passes no digest build-args, and `--pull` makes builds non-reproducible —
while `PORTAINER.md` documents digest-pinned immutable bases.
### `[ ]` 2.7 — pdf.js loaded from CDN without integrity `(F11)`
### `[x]` 2.7 — pdf.js loaded from CDN without integrity `(F11)`
3.11.174 from `cdnjs`, no SRI, and CSP allows the whole host for `script-src` **and**
`worker-src`. Vendor the asset or pin `integrity` and narrow the CSP to the exact path.
Vendored rather than integrity-pinned, so the Site no longer depends on a third
party being reachable and honest when a customer opens it. Both files are served
from this origin and their provenance is recorded in `local/static/vendor/README.md`,
verified against the SRI digests cdnjs publishes for 3.11.174.
`cdnjs.cloudflare.com` is gone from `script-src`, `worker-src` and `connect-src` in
both gateway templates: scripts and workers are now `'self'` plus `blob:` for the
worker the Site builds itself.
Verified in a browser against the running stack: pdf.js loads from `/vendor/`,
the blob worker starts, and a real 7-page PDF parses with no CSP violation. Both
browser suites and the full integration suite pass.
**Still open: the version.** 3.11.174 is old. GHSA-wgrm-67xf-hhpq is mitigated —
`dtf-site.html` already passes `isEvalSupported: false`, which is the documented
workaround — but staying on it indefinitely is not a posture. Upgrading is an API
change rather than a file swap and needs its own browser testing, so it is
deliberately not bundled here.
### `[ ]` 2.8 — Single shared operator credential `(F12)`

View File

@@ -4,7 +4,7 @@ The DTF application is one Portainer-owned Docker Swarm stack. Gitea builds,
tests, scans, and publishes the two application images, then calls the stack's
Portainer webhook. Start with the short operator guide in `../PORTAINER.md`.
- `stack.yaml` — the single Portainer stack.
- The deployed stack is the repository's `docker-compose.yml`, not a file here.
- `Dockerfile.api` and `Dockerfile.web` — prebuilt registry images.
- `portainer.env.example` — non-secret Portainer variables.
- `production_preflight.py` — fail-closed application/configuration validator.

View File

@@ -27,7 +27,7 @@ server {
add_header Referrer-Policy no-referrer always;
add_header X-Frame-Options DENY always;
add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;
add_header Content-Security-Policy "default-src 'self'; script-src 'self' @SCRIPT_HASHES@ https://cdnjs.cloudflare.com; script-src-attr 'none'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com; img-src 'self' data: blob: https://cdn.vnda.com.br; connect-src 'self' ${S3_PUBLIC_ENDPOINT} https://cdn.vnda.com.br https://cdnjs.cloudflare.com; worker-src 'self' blob: https://cdnjs.cloudflare.com; object-src 'none'; base-uri 'none'; frame-ancestors 'none'; form-action 'self'" always;
add_header Content-Security-Policy "default-src 'self'; script-src 'self' @SCRIPT_HASHES@; script-src-attr 'none'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com; img-src 'self' data: blob: https://cdn.vnda.com.br; connect-src 'self' ${S3_PUBLIC_ENDPOINT} https://cdn.vnda.com.br; worker-src 'self' blob:; object-src 'none'; base-uri 'none'; frame-ancestors 'none'; form-action 'self'" always;
location = /health { access_log off; return 200 'ok'; }
location /api/ {

View File

@@ -1,310 +0,0 @@
version: "3.8"
x-app-environment: &app-environment
APP_ENV: production
DATABASE_URL_FILE: /run/secrets/database_url
S3_ENDPOINT: ${R2_ENDPOINT:?set R2_ENDPOINT}
S3_PUBLIC_ENDPOINT: ${R2_PUBLIC_ENDPOINT:?set R2_PUBLIC_ENDPOINT}
S3_BUCKET: ${R2_BUCKET:?set R2_BUCKET}
AWS_ACCESS_KEY_ID_FILE: /run/secrets/r2_access_key_id
AWS_SECRET_ACCESS_KEY_FILE: /run/secrets/r2_secret_access_key
AWS_DEFAULT_REGION: auto
# The Kanban authenticates by email; the runtime reads OPERATOR_EMAIL.
OPERATOR_EMAIL: ${OPERATOR_EMAIL:?set OPERATOR_EMAIL}
OPERATOR_PASSWORD_FILE: /run/secrets/operator_password
PAYMENT_ADAPTER: ${PAYMENT_ADAPTER:?set PAYMENT_ADAPTER}
FREIGHT_ADAPTER: ${FREIGHT_ADAPTER:?set FREIGHT_ADAPTER}
TINY_ADAPTER: ${TINY_ADAPTER:?set TINY_ADAPTER}
WHATSAPP_ADAPTER: ${WHATSAPP_ADAPTER:?set WHATSAPP_ADAPTER}
STORAGE_ADAPTER: s3-r2
PAYMENT_TOKEN_FILE: /run/secrets/payment_token
PAYMENT_WEBHOOK_SECRET_FILE: /run/secrets/payment_webhook_secret
TINY_TOKEN_FILE: /run/secrets/tiny_token
WHATSAPP_TOKEN_FILE: /run/secrets/whatsapp_token
PUBLIC_ORIGIN: ${PUBLIC_ORIGIN:?set PUBLIC_ORIGIN}
PUBLIC_HOST: ${PUBLIC_HOST:?set PUBLIC_HOST}
ALLOWED_HOSTS: ${PUBLIC_HOST:?set PUBLIC_HOST},${KANBAN_HOST:?set KANBAN_HOST}
ALLOWED_ORIGINS: ${PUBLIC_ORIGIN:?set PUBLIC_ORIGIN},https://${KANBAN_HOST:?set KANBAN_HOST}
COOKIE_SECURE: "true"
MAX_UPLOAD_BYTES: ${MAX_UPLOAD_BYTES:-5368709120}
UPLOAD_PART_BYTES: ${UPLOAD_PART_BYTES:-8388608}
STORAGE_QUOTA_BYTES: ${STORAGE_QUOTA_BYTES:?set STORAGE_QUOTA_BYTES}
OWNER_UPLOAD_QUOTA_BYTES: ${OWNER_UPLOAD_QUOTA_BYTES:?set OWNER_UPLOAD_QUOTA_BYTES}
MAX_PENDING_UPLOADS: ${MAX_PENDING_UPLOADS:-10}
SCAN_MAX_BYTES: ${SCAN_MAX_BYTES:-134217728}
x-app-secrets: &app-secrets
- database_url
- r2_access_key_id
- r2_secret_access_key
- operator_password
- payment_token
- payment_webhook_secret
- tiny_token
- whatsapp_token
x-rolling: &rolling
update_config:
parallelism: 1
delay: 10s
order: start-first
failure_action: rollback
monitor: 45s
rollback_config:
parallelism: 1
delay: 5s
order: start-first
failure_action: pause
monitor: 45s
restart_policy:
condition: on-failure
delay: 5s
max_attempts: 5
window: 60s
services:
db:
image: ${POSTGRES_IMAGE:?set POSTGRES_IMAGE}
environment:
POSTGRES_DB: ${POSTGRES_DB:?set POSTGRES_DB}
POSTGRES_USER: ${POSTGRES_USER:?set POSTGRES_USER}
POSTGRES_PASSWORD_FILE: /run/secrets/db_admin_password
secrets: [db_admin_password]
volumes:
- postgres-data:/var/lib/postgresql/data
networks: [backend]
healthcheck:
test: [CMD-SHELL, 'pg_isready -U "$$POSTGRES_USER" -d "$$POSTGRES_DB"']
interval: 10s
timeout: 5s
retries: 12
start_period: 20s
stop_grace_period: 60s
deploy:
replicas: 1
placement:
constraints: [node.labels.dtf_database == true]
update_config:
parallelism: 1
order: stop-first
failure_action: rollback
monitor: 60s
rollback_config:
parallelism: 1
order: stop-first
failure_action: pause
monitor: 60s
restart_policy:
condition: on-failure
delay: 10s
max_attempts: 5
window: 120s
resources:
limits: {cpus: "2.0", memory: 4G}
reservations: {cpus: "0.5", memory: 1G}
db-init:
image: ${API_IMAGE:?set API_IMAGE}:${IMAGE_TAG:-latest}
command: python -m local.bootstrap
environment:
APP_ENV: production
DATABASE_ADMIN_URL_FILE: /run/secrets/database_admin_url
APP_DB_USER: ${APP_DB_USER:?set APP_DB_USER}
APP_DB_PASSWORD_FILE: /run/secrets/app_db_password
secrets: [database_admin_url, app_db_password]
networks: [backend]
deploy:
replicas: 1
restart_policy: {condition: none}
placement:
constraints: [node.platform.os == linux]
resources:
limits: {cpus: "0.5", memory: 512M}
scanner:
image: ${CLAMAV_IMAGE:?set CLAMAV_IMAGE}
user: "100:101"
entrypoint: [clamd, --foreground=true, --config-file=/etc/clamav/clamd.conf]
configs:
- source: clamd_config
target: /etc/clamav/clamd.conf
mode: 0444
networks: [backend]
read_only: true
cap_drop: [ALL]
security_opt: [no-new-privileges:true]
tmpfs:
- /tmp:uid=100,gid=101,mode=0750
- /run/clamav:uid=100,gid=101,mode=0750
- /var/log/clamav:uid=100,gid=101,mode=0750
healthcheck:
test: [CMD, clamdscan, --config-file=/etc/clamav/clamd.conf, --ping, "3"]
interval: 15s
timeout: 5s
retries: 20
start_period: 90s
deploy:
replicas: 1
restart_policy: {condition: on-failure, delay: 10s}
resources:
limits: {cpus: "2.0", memory: 3G}
reservations: {cpus: "0.5", memory: 1G}
api:
image: ${API_IMAGE:?set API_IMAGE}:${IMAGE_TAG:-latest}
environment: *app-environment
secrets: *app-secrets
networks: [backend, egress]
read_only: true
tmpfs: [/tmp]
init: true
cap_drop: [ALL]
security_opt: [no-new-privileges:true]
healthcheck:
test:
- CMD-SHELL
- >-
python -c "import os,urllib.request; r=urllib.request.Request('http://localhost:8000/health',headers={'Host':os.environ['PUBLIC_HOST']}); urllib.request.urlopen(r,timeout=3)"
interval: 10s
timeout: 5s
retries: 12
start_period: 30s
stop_grace_period: 30s
deploy:
<<: *rolling
replicas: 2
resources:
limits: {cpus: "1.0", memory: 1G}
reservations: {cpus: "0.25", memory: 256M}
worker:
image: ${API_IMAGE:?set API_IMAGE}:${IMAGE_TAG:-latest}
command: python -m local.worker
environment:
<<: *app-environment
CLAMD_HOST: scanner
secrets: *app-secrets
networks: [backend, egress]
read_only: true
tmpfs: [/tmp]
init: true
cap_drop: [ALL]
security_opt: [no-new-privileges:true]
healthcheck:
test: [CMD, python, -c, "import urllib.request; urllib.request.urlopen('http://localhost:8002/health',timeout=3)"]
interval: 15s
timeout: 5s
retries: 12
start_period: 90s
stop_grace_period: 60s
deploy:
<<: *rolling
replicas: 1
update_config:
parallelism: 1
order: stop-first
failure_action: rollback
monitor: 60s
rollback_config:
parallelism: 1
order: stop-first
failure_action: pause
monitor: 60s
resources:
limits: {cpus: "1.5", memory: 2G}
reservations: {cpus: "0.25", memory: 512M}
site:
image: ${WEB_IMAGE:?set WEB_IMAGE}:${IMAGE_TAG:-latest}
environment:
WEB_INDEX: index.html
PUBLIC_HOST: ${PUBLIC_HOST:?set PUBLIC_HOST}
S3_PUBLIC_ENDPOINT: ${R2_PUBLIC_ENDPOINT:?set R2_PUBLIC_ENDPOINT}
networks: [backend]
ports:
- target: 8080
published: ${SITE_PORT:-8080}
protocol: tcp
mode: ingress
read_only: true
tmpfs:
- /tmp:uid=101,gid=101,mode=0750
- /var/cache/nginx:uid=101,gid=101,mode=0750
- /var/run:uid=101,gid=101,mode=0750
- /etc/nginx/conf.d:uid=101,gid=101,mode=0750
cap_drop: [ALL]
security_opt: [no-new-privileges:true]
healthcheck:
test: [CMD-SHELL, 'wget -q --header="Host: $$PUBLIC_HOST" -O /dev/null http://127.0.0.1:8080/health']
interval: 10s
timeout: 5s
retries: 12
start_period: 15s
deploy:
<<: *rolling
replicas: 2
resources:
limits: {cpus: "0.5", memory: 256M}
reservations: {cpus: "0.1", memory: 64M}
kanban:
image: ${WEB_IMAGE:?set WEB_IMAGE}:${IMAGE_TAG:-latest}
environment:
WEB_INDEX: kanban.html
PUBLIC_HOST: ${KANBAN_HOST:?set KANBAN_HOST}
S3_PUBLIC_ENDPOINT: ${R2_PUBLIC_ENDPOINT:?set R2_PUBLIC_ENDPOINT}
networks: [backend]
ports:
- target: 8080
published: ${KANBAN_PORT:-8081}
protocol: tcp
mode: ingress
read_only: true
tmpfs:
- /tmp:uid=101,gid=101,mode=0750
- /var/cache/nginx:uid=101,gid=101,mode=0750
- /var/run:uid=101,gid=101,mode=0750
- /etc/nginx/conf.d:uid=101,gid=101,mode=0750
cap_drop: [ALL]
security_opt: [no-new-privileges:true]
healthcheck:
test: [CMD-SHELL, 'wget -q --header="Host: $$PUBLIC_HOST" -O /dev/null http://127.0.0.1:8080/health']
interval: 10s
timeout: 5s
retries: 12
start_period: 15s
deploy:
<<: *rolling
replicas: 1
resources:
limits: {cpus: "0.5", memory: 256M}
reservations: {cpus: "0.1", memory: 64M}
configs:
clamd_config:
file: ../local/clamd.conf
secrets:
database_url: {external: true, name: "${DATABASE_URL_SECRET:?set DATABASE_URL_SECRET}"}
database_admin_url: {external: true, name: "${DATABASE_ADMIN_URL_SECRET:?set DATABASE_ADMIN_URL_SECRET}"}
db_admin_password: {external: true, name: "${DB_ADMIN_PASSWORD_SECRET:?set DB_ADMIN_PASSWORD_SECRET}"}
app_db_password: {external: true, name: "${APP_DB_PASSWORD_SECRET:?set APP_DB_PASSWORD_SECRET}"}
r2_access_key_id: {external: true, name: "${R2_ACCESS_KEY_ID_SECRET:?set R2_ACCESS_KEY_ID_SECRET}"}
r2_secret_access_key: {external: true, name: "${R2_SECRET_ACCESS_KEY_SECRET:?set R2_SECRET_ACCESS_KEY_SECRET}"}
operator_password: {external: true, name: "${OPERATOR_PASSWORD_SECRET:?set OPERATOR_PASSWORD_SECRET}"}
payment_token: {external: true, name: "${PAYMENT_TOKEN_SECRET:?set PAYMENT_TOKEN_SECRET}"}
payment_webhook_secret: {external: true, name: "${PAYMENT_WEBHOOK_SECRET:?set PAYMENT_WEBHOOK_SECRET}"}
tiny_token: {external: true, name: "${TINY_TOKEN_SECRET:?set TINY_TOKEN_SECRET}"}
whatsapp_token: {external: true, name: "${WHATSAPP_TOKEN_SECRET:?set WHATSAPP_TOKEN_SECRET}"}
volumes:
postgres-data:
external: true
name: ${POSTGRES_VOLUME:?set POSTGRES_VOLUME}
networks:
backend:
driver: overlay
internal: true
egress:
driver: overlay

View File

@@ -1780,8 +1780,11 @@ function analisarFolha(img, larguraCm){
// ── PDF também é conferido: rasteriza a página e mede o DPI das imagens de dentro.
// Arte vetorial não tem resolução — nesses casos a nota é máxima, e isso é correto.
const PDFJS_URL='https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.min.js';
const PDFJS_WORKER='https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.worker.min.js';
// Served from this origin, not a CDN: the Site keeps working when a third party
// does not, and the CSP can name only 'self' for scripts and workers. Provenance
// and hashes are recorded in local/static/vendor/README.md.
const PDFJS_URL='/vendor/pdf.min.js';
const PDFJS_WORKER=new URL('/vendor/pdf.worker.min.js', location.origin).href;
let pdfLibP=null, temWorker=null;
// Alguns navegadores e iframes bloqueiam Worker de blob. Descobrimos antes de
// tentar, porque sem worker o PDF é lido na thread principal e precisa de outra

View File

@@ -9,7 +9,7 @@ server {
add_header Referrer-Policy no-referrer always;
add_header X-Frame-Options DENY always;
add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;
add_header Content-Security-Policy "default-src 'self'; script-src 'self' @SCRIPT_HASHES@ https://cdnjs.cloudflare.com; script-src-attr 'none'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com; img-src 'self' data: blob: https://cdn.vnda.com.br; connect-src 'self' ${S3_PUBLIC_ENDPOINT} https://cdn.vnda.com.br https://cdnjs.cloudflare.com; worker-src 'self' blob: https://cdnjs.cloudflare.com; object-src 'none'; base-uri 'none'; frame-ancestors 'none'; form-action 'self'" always;
add_header Content-Security-Policy "default-src 'self'; script-src 'self' @SCRIPT_HASHES@; script-src-attr 'none'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com; img-src 'self' data: blob: https://cdn.vnda.com.br; connect-src 'self' ${S3_PUBLIC_ENDPOINT} https://cdn.vnda.com.br; worker-src 'self' blob:; object-src 'none'; base-uri 'none'; frame-ancestors 'none'; form-action 'self'" always;
location = /health { access_log off; return 200 'ok'; }
location /api/ {
limit_req zone=api_limit burst=100 nodelay;

View File

@@ -1,9 +1,9 @@
"""Resolve Docker secret files into the environment before configuration is read.
Swarm mounts each secret as a file and the stack passes its path as `<NAME>_FILE`.
Nothing read `_FILE` settings, so `deploy/stack.yaml` could not boot: the runtime
looked for `DATABASE_URL`, `AWS_ACCESS_KEY_ID` and `OPERATOR_PASSWORD` while the
stack supplied only the `_FILE` form.
The deployed `docker-compose.yml` passes credentials as plain environment
variables, so this module is inert there. It exists so a stack can supply them as
Docker secrets instead without any code change; see `ROADMAP.md` 2.12.
Call `load()` in every entrypoint before any configuration is read.
@@ -12,9 +12,9 @@ secrets` elsewhere in the package to the standard library, not to this file.
"""
import os
# The settings production supplies as secret files. Any other `*_FILE` variable is
# The settings a stack may supply as secret files. Any other `*_FILE` variable is
# resolved the same way; this list documents the contract and is what the release
# gate checks against, so keep it in step with `deploy/stack.yaml`.
# gate checks against.
SECRET_FILE_SETTINGS = (
'DATABASE_URL',
'DATABASE_ADMIN_URL',

33
local/static/vendor/README.md vendored Normal file
View File

@@ -0,0 +1,33 @@
# Vendored third-party assets
Served from this repository rather than a CDN, so the Site does not depend on a
third party being reachable and honest at the moment a customer opens it, and so
the Content-Security-Policy can name only `'self'` for scripts and workers.
## pdf.js 3.11.174
Used by the by-metre flow to measure and rasterise a PDF sheet in the browser.
| File | SHA-256 |
|---|---|
| `pdf.min.js` | `5b5799e6f8c680663207ac5b42ee14eed2a406fa7af48f50c154f0c0b1566946` |
| `pdf.worker.min.js` | `feabdf309770ed24bba31a5467836cdc8cf639c705af27d52b585b041bb8527b` |
Downloaded from `https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/` and
verified against the SRI digests cdnjs publishes for that release:
```
pdf.min.js sha512-q+4liFwdPC/bNdhUpZx6aXDx/h77yEQtn4I1slHydcbZK34nLaR3cAeYSJshoxIOq3mjEf7xJE8YWIUHMn+oCQ==
pdf.worker.min.js sha512-BbrZ76UNZq5BhH7LL7pn9A4TKQpQeNCHOo65/akfelcIBbcVvYWOFQKPXIrykE3qZxYjmDX573oa4Ywsc7rpTw==
```
To verify or refresh, compare against that API before replacing anything:
```bash
curl -s "https://api.cdnjs.com/libraries/pdf.js/<version>?fields=sri"
```
**Version note.** 3.11.174 is old. It is affected by GHSA-wgrm-67xf-hhpq, whose
documented workaround is `isEvalSupported: false`; `dtf-site.html` already passes
that, so the known path is closed. Upgrading is worthwhile but is an API change,
not a file swap, and belongs with its own browser testing — see `ROADMAP.md` 2.7.

22
local/static/vendor/pdf.min.js vendored Normal file

File diff suppressed because one or more lines are too long

22
local/static/vendor/pdf.worker.min.js vendored Normal file

File diff suppressed because one or more lines are too long