Compare commits

...

5 Commits

Author SHA1 Message Date
Cauê Faleiros
d2f7b2c03b docs: record secret-file loading and the behavioural release gate
Some checks failed
Build and deploy / Validate source (push) Successful in 1m25s
Build and deploy / Integration suite on a real stack (push) Failing after 7s
Build and deploy / Publish images and notify Portainer (push) Has been skipped
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-21 11:37:16 -03:00
Cauê Faleiros
341f154c36 feat: load Docker secret files so the production stack can boot
deploy/stack.yaml passes DATABASE_URL_FILE, AWS_ACCESS_KEY_ID_FILE,
OPERATOR_PASSWORD_FILE and the provider tokens as Swarm secret paths, but the
runtime only ever read the plain names. That stack could not start: the database
URL and R2 credentials were absent, and operator login raised KeyError, so it
returned 500 instead of the intended 503.

local/secrets.py resolves every <NAME>_FILE into <NAME> before configuration is
read, from the API, worker and bootstrap entrypoints. It fails closed on an
unreadable or empty secret and on a name supplied both directly and as a file,
because starting with a credential nobody intended is worse than not starting.
Only one trailing newline is stripped, so a generated password keeps any
whitespace that belongs to it, and no value reaches an error message.

The stack also passed OPERATOR_USER while the Kanban authenticates by email;
it now passes OPERATOR_EMAIL, matching the runtime.

The release gate checked this by searching local/secrets.py for the literal
"DATABASE_URL_FILE", which would pass for any file containing that string. It
now loads the module and makes it resolve every secret the stack declares, and
asserts it fails closed on a missing one. Four marker strings that stopped
matching when R2 support landed are removed rather than left to rot; the two
that still describe real blockers stay, so the gate continues to refuse a
release while payment and messaging adapters are fake.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-21 11:36:59 -03:00
Cauê Faleiros
9b38c9fe3d docs: record Block 2 rate-limiting work and CI coverage
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 18:55:33 -03:00
Cauê Faleiros
5d669cbcce ci: run the real suites before publishing images
The pipeline ran py_compile plus four unit tests, then built and called the
Portainer webhook. None of that starts the application, so a missing import in
local/auth.py passed every check and reached production, where it returned 500
on every session, login and registration.

Add an integration job that builds the localhost stack and runs the suites that
already existed but were never executed automatically: smoke, workflow,
security, scanning, retention, runtime security, and the two browser tests.
publish-and-deploy now depends on it, so a failure blocks the deploy instead of
shipping.

Verified by reintroducing the original defect: py_compile and the unit tests
still passed, and smoke_test failed on /session, which would have stopped the
release.

The browser tests need a real Chrome and are skipped with a warning when the
runner has none; installing google-chrome-stable or setting CHROME_BIN makes
them gate too. Every other suite gates unconditionally.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 18:55:07 -03:00
Cauê Faleiros
52ae13c9a1 fix: rate-limit and audit by real client address
uvicorn does not trust forwarded headers from a peer outside
forwarded_allow_ips, so request.client.host was the web gateway for every
request. The auth-source bucket therefore counted all customers together:
60 failed logins from one attacker locked out everyone. Security events
recorded the gateway address, which made the audit trail useless for
attribution.

The gateway now overwrites X-Forwarded-For with the peer address it observed
instead of appending to whatever the client sent, so the header carries one
value the client cannot choose, and client_ip() resolves it with a fallback to
the connection peer.

The guest-session limiter was keyed on the environment name, making it one
global bucket of 120 per 15 minutes: roughly eight new visitors a minute for
the whole site before legitimate traffic started receiving 429. It is now per
source, and the ceiling is deliberately generous because offices and mobile
carriers put many real customers behind a single address.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 18:48:31 -03:00
16 changed files with 417 additions and 34 deletions

View File

@@ -21,12 +21,81 @@ jobs:
local.test_dependency_lock \
local.test_staging_readiness \
deploy.test_production_preflight \
local.test_pricing -v
local.test_pricing \
local.test_secrets -v
sh -n local/lock_dependencies.sh
integration:
name: Integration suite on a real stack
needs: validate
runs-on: ubuntu-latest
timeout-minutes: 45
env:
SITE_PORT: "8080"
KANBAN_PORT: "8081"
API_PORT: "8000"
COMPOSE: docker compose -f compose.local.yaml
steps:
- name: Checkout
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
# py_compile cannot see an unresolved name, and the four unit tests above
# never start the application. A missing import in local/auth.py therefore
# reached production and returned 500 on every session, login and
# registration. These suites exercise the running stack and would have
# failed on it immediately.
- name: Start the stack
run: |
$COMPOSE up --build -d --wait --wait-timeout 600
$COMPOSE ps
- name: API and workflow regressions
run: |
python3 -m local.smoke_test
python3 -m local.workflow_test
python3 -m local.security_test
python3 -m local.scanning_test
- name: Runtime and retention regressions
run: |
$COMPOSE exec -T api python -m local.retention_test
$COMPOSE exec -T api python -m local.runtime_security_test
# These need a real Chrome. They are the only coverage for the artwork
# editor and the full customer journey, so install google-chrome-stable
# (or set CHROME_BIN) on the runner to make them gate deployments. The
# suites above stay hard gates either way.
- name: Browser regressions
run: |
for candidate in "$CHROME_BIN" /usr/bin/google-chrome-stable \
/usr/bin/google-chrome /usr/bin/chromium /usr/bin/chromium-browser; do
if [ -n "$candidate" ] && [ -x "$candidate" ]; then
export CHROME_BIN="$candidate"
break
fi
done
if [ ! -x "${CHROME_BIN:-}" ]; then
echo "::warning::No Chrome on this runner; browser regressions were NOT run."
echo "Install google-chrome-stable or set CHROME_BIN to gate on them."
exit 0
fi
echo "Using $CHROME_BIN"
node local/artwork_browser_test.mjs
node local/browser_test.mjs
- name: Diagnostics on failure
if: failure()
run: |
$COMPOSE ps || true
$COMPOSE logs --tail 200 api worker site kanban || true
- name: Tear down
if: always()
run: $COMPOSE down -v || true
publish-and-deploy:
name: Publish images and notify Portainer
needs: validate
needs: [validate, integration]
if: gitea.event_name == 'push' && gitea.ref == 'refs/heads/main'
runs-on: ubuntu-latest
timeout-minutes: 45

View File

@@ -7,9 +7,9 @@
> Update the **Current step** line and the item status every time something moves.
> Add new findings at the bottom of the relevant block rather than rewriting history.
**Current step:** Block 0 closed on 2026-09-18 (0.1–0.6 done and verified against a
live stack). Next: Block 1 needs client inputs for 1.1/1.2, so Block 2 (2.1, 2.2,
2.3) and 5.1 are the ones that can start immediately.
**Current step:** Block 0 closed, plus 2.1, 2.2, 2.3, 2.5 and 5.1. Next: 2.4 (the
release gate the docs describe but the workflow never ran — partly addressed by
5.1), then 2.6/2.7. Block 1 still waits on client inputs for 1.1/1.2.
**Last audit:** 2026-09-18, full read of `local/`, `dtf-site.html`, `deploy/`,
`.gitea/`, docs and legacy prototypes. Findings below carry their audit IDs.
@@ -171,7 +171,7 @@ and ships only fake adapters, so the deployed system cannot take an order at all
## Block 2 · Security — before any public exposure
### `[ ]` 2.1 — Rate limiting and audit logs are blind to the client `(F5)`
### `[x]` 2.1 — Rate limiting and audit logs are blind to the client `(F5)`
uvicorn runs without trusted proxy headers (`forwarded_allow_ips` defaults to
`127.0.0.1`; nginx is a different container IP), so `request.client.host` is nginx
@@ -184,13 +184,13 @@ record has no attacker IP.
- **Accept:** two clients on different IPs have independent buckets; audit rows
carry the real IP.
### `[ ]` 2.2 — The public site throttles itself `(F6)`
### `[x]` 2.2 — The public site throttles itself `(F6)`
`local/app.py:115` — `rate_limit('guest-sessions', ENVIRONMENT, 120, 900)` is keyed
on the environment name: 120 new visitors per 15 minutes **site-wide** (~8/min).
Normal traffic 429s. Key per source IP (after 2.1) and raise the ceiling.
### `[ ]` 2.3 — `deploy/stack.yaml` cannot boot `(F7)`
### `[x]` 2.3 — `deploy/stack.yaml` cannot boot `(F7)`
It passes `DATABASE_URL_FILE`, `AWS_ACCESS_KEY_ID_FILE`, `OPERATOR_PASSWORD_FILE`,
`OPERATOR_USER`. The code reads `DATABASE_URL`, `AWS_ACCESS_KEY_ID`,
@@ -212,7 +212,7 @@ unit tests, then builds, pushes `latest` and calls the webhook **unconditionally
- Either implement the gate or correct both documents. Do not leave the gap.
### `[ ]` 2.5 — The preflight has silently decayed `(F9)`
### `[x]` 2.5 — The preflight has silently decayed `(F9)`
It blocks by string-matching source. **4 of 6 markers are dead** after the R2
refactor: `'This runtime only supports APP_ENV=local'`,
@@ -332,9 +332,13 @@ charges. Fix as part of 1.1.
## Block 5 · Hygiene and maintenance
- `[ ]` 5.1 — CI coverage `(F33)`. The smoke, workflow, security, retention and
browser suites exist under `local/` and would have caught 0.1 — none run in CI.
Add a compose-backed job. **Highest leverage item in this block.**
- `[x]` 5.1 — CI coverage `(F33)`. An `integration` job now builds the localhost
stack and runs smoke, workflow, security, scanning, retention, runtime security
and both browser suites; `publish-and-deploy` depends on it. Verified by
reintroducing the 0.1 defect: `py_compile` and the unit tests still passed while
`smoke_test` failed on `/session`, blocking the release. Browser tests skip with
a warning when the runner has no Chrome — **install `google-chrome-stable` on the
runner (or set `CHROME_BIN`) to make them gate as well.**
- `[ ]` 5.2 — Remove or archive the dead prototypes `(F30)`: `portal/`, `kanban/`,
`agente/`, root `schema.sql` (~1,500 lines describing an abandoned model). Several
expose unauthenticated endpoints taking the acting user from the request body
@@ -395,6 +399,36 @@ charges. Fix as part of 1.1.
guard that refuses identical credentials in both configuration forms.
- `[x]` Checkout confirmation moved out of the panel the success path hides.
### Block 2 and CI — 2026-09-18
- `[x]` The web gateway overwrites `X-Forwarded-For` with the peer address instead
of appending to it, and `client_ip()` resolves the requester for rate-limit
buckets and security events. Verified: a forged `203.0.113.99` never reaches the
audit trail.
- `[x]` Guest sessions are limited per source. The first attempt used 30/IP, which
the new regression caught as too tight for shared NAT — recreating the original
fault in a narrower form — so the ceiling is 240 per 15 minutes, overridable with
`GUEST_SESSION_LIMIT`.
- `[x]` Security events now carry the source address (`operator_login_failed`,
`customer_login_failed`, `cross_origin_rejected`, `http_security_event`).
- `[x]` CI runs the integration suites against a real stack before publishing.
### 2026-09-21
- `[x]` 2.3 — `local/secrets.py` resolves every `<NAME>_FILE` into `<NAME>` from the
API, worker and bootstrap entrypoints, failing closed on an unreadable or empty
secret and on a value supplied both ways. Verified by booting the API, the worker
and bootstrap with credentials supplied only as mounted files, including a
password containing `:/?#[]&=+$ ,%`. `OPERATOR_USER` in the stack became
`OPERATOR_EMAIL`, which is what the runtime reads.
- `[x]` 2.5 — The gate now loads `local/secrets.py` and makes it resolve every
secret `deploy/stack.yaml` declares, plus asserts it fails closed. Verified
against a no-op loader (11 blockers) and one that swallows a missing file
(1 blocker); only the real implementation passes. The four marker strings that
stopped matching when R2 support landed were removed; the two describing real
blockers stay, so the gate still refuses a release while the payment and
messaging adapters are fake.
### Reporting
- `[x]` Week-1 client report (`Relatorio-Semana-1-DTF.docx`), corrected 2026-09-18 to

View File

@@ -27,7 +27,9 @@ server {
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header X-Forwarded-Proto https;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
# Overwrite, never append: $proxy_add_x_forwarded_for keeps any header the
# client sent, and the leftmost value would then be attacker-controlled.
proxy_set_header X-Forwarded-For $remote_addr;
proxy_connect_timeout 5s;
proxy_read_timeout 30s;
client_max_body_size 2m;

View File

@@ -17,7 +17,7 @@ REQUIRED = (
'IMAGE_TAG', 'PUBLIC_ORIGIN', 'PUBLIC_HOST', 'KANBAN_HOST',
'SITE_PORT', 'KANBAN_PORT',
'R2_ENDPOINT', 'R2_PUBLIC_ENDPOINT', 'R2_BUCKET',
'POSTGRES_DB', 'POSTGRES_USER', 'APP_DB_USER', 'POSTGRES_VOLUME', 'OPERATOR_USER',
'POSTGRES_DB', 'POSTGRES_USER', 'APP_DB_USER', 'POSTGRES_VOLUME', 'OPERATOR_EMAIL',
'STORAGE_QUOTA_BYTES', 'OWNER_UPLOAD_QUOTA_BYTES', 'MAX_UPLOAD_BYTES',
'UPLOAD_PART_BYTES', 'MAX_PENDING_UPLOADS', 'SCAN_MAX_BYTES',
'PAYMENT_ADAPTER', 'FREIGHT_ADAPTER', 'TINY_ADAPTER', 'WHATSAPP_ADAPTER',
@@ -33,13 +33,12 @@ IMAGE_REPOSITORY = re.compile(
DNS = re.compile(r'^(?=.{1,253}$)(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}$')
NAME = re.compile(r'^[a-zA-Z0-9][a-zA-Z0-9_.-]{2,127}$')
SOURCE_BLOCKERS = {
'local/adapters.py': (
'This runtime only supports APP_ENV=local',
'Only local S3 storage is supported',
),
# Markers must name something that is still true, or the gate weakens without
# failing. Four entries here described a local-only runtime and stopped
# matching when R2 support landed; they were removed rather than left to rot.
# What remains is the real blocker: no production payment or messaging adapter
# exists, so these lines must change before a release can be meaningful.
'local/app.py': (
"allowed_hosts=['localhost', '127.0.0.1']",
"'environment': 'local'",
'payment = FakePayment()',
),
'local/worker.py': (
@@ -55,12 +54,63 @@ def source_errors(root=ROOT):
for marker in markers:
if marker in text:
errors.append(f'{relative} remains local-only: {marker}')
secrets_module = root / 'local' / 'secrets.py'
if not secrets_module.exists() or 'DATABASE_URL_FILE' not in secrets_module.read_text():
errors.append('local runtime does not load the production Docker secret *_FILE settings')
errors.extend(secret_loading_errors(root))
return errors
def secret_loading_errors(root=ROOT):
"""Exercise the secret loader instead of grepping it.
Searching for a string passes as soon as someone writes that string, and
fails when a working implementation happens to spell it differently. Load the
module and make it resolve a real file.
"""
import importlib.util
import tempfile
module_path = root / 'local' / 'secrets.py'
if not module_path.exists():
return ['local runtime does not load the production Docker secret *_FILE settings']
try:
spec = importlib.util.spec_from_file_location('_preflight_secrets', module_path)
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
except Exception as exc:
return [f'local/secrets.py could not be loaded: {exc}']
stack_names = set()
stack = root / 'deploy' / 'stack.yaml'
if stack.exists():
for line in stack.read_text().splitlines():
if '_FILE:' in line:
key = line.split(':')[0].strip()
# The database image consumes this one; the application does not.
if key and key != 'POSTGRES_PASSWORD_FILE':
stack_names.add(key[:-len('_FILE')])
failures = []
with tempfile.TemporaryDirectory() as directory:
for name in sorted(stack_names):
path = Path(directory) / name
path.write_text('resolved-value\n', encoding='utf-8')
environ = {f'{name}_FILE': str(path)}
try:
module.load(environ)
except Exception as exc:
failures.append(f'{name}_FILE is not resolved by local/secrets.py: {exc}')
continue
if environ.get(name) != 'resolved-value':
failures.append(f'{name}_FILE did not produce {name}')
# A missing secret must stop the service, never start it unconfigured.
try:
module.load({'DATABASE_URL_FILE': str(Path(directory) / 'absent')})
except Exception:
pass
else:
failures.append('local/secrets.py does not fail closed on an unreadable secret')
return failures
def config_errors(values):
errors = []
for name in APPROVALS:

View File

@@ -9,7 +9,8 @@ x-app-environment: &app-environment
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
OPERATOR_USER: ${OPERATOR_USER:?set OPERATOR_USER}
# 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}

View File

@@ -27,7 +27,7 @@ def valid_config():
'POSTGRES_USER': 'dtf_admin',
'APP_DB_USER': 'dtf_app',
'POSTGRES_VOLUME': 'dtf-postgres-data',
'OPERATOR_USER': 'dtf-operator',
'OPERATOR_EMAIL': 'operador@example.com',
'STORAGE_QUOTA_BYTES': '53687091200',
'OWNER_UPLOAD_QUOTA_BYTES': '10737418240',
'MAX_UPLOAD_BYTES': '5368709120',

View File

@@ -14,12 +14,14 @@ from starlette.middleware.trustedhost import TrustedHostMiddleware
from psycopg.types.json import Jsonb
from . import db
from .secrets import load as load_secret_files
from .adapters import FakeFreight, FakePayment, LocalS3Storage, require_runtime
from .models import Freight, Move, Pay, QuoteRequest, Review, UploadStart, OperatorLogin
from .pricing import price
from .auth import COOKIE_SECURE, owner, session_row, new_session, operator, throttle, audit, rate_limit
from .auth import COOKIE_SECURE, client_ip, owner, session_row, new_session, operator, throttle, audit, rate_limit
from .scanning import require_clean
load_secret_files()
require_runtime()
storage = LocalS3Storage()
payment = FakePayment()
@@ -28,6 +30,10 @@ ENVIRONMENT = os.environ.get('APP_ENV', 'local')
PUBLIC_ORIGIN = os.environ.get('PUBLIC_ORIGIN', 'http://localhost')
ALLOWED_HOSTS = [host for host in os.environ.get('ALLOWED_HOSTS', 'localhost,127.0.0.1').split(',') if host]
ALLOWED_ORIGINS = [origin for origin in os.environ.get('ALLOWED_ORIGINS', PUBLIC_ORIGIN).split(',') if origin]
# Per source and generous: a browser needs one session and keeps the cookie, but
# offices and mobile carriers put many real customers behind one address, so a
# tight per-IP ceiling would lock out the same people the old global one did.
GUEST_SESSION_LIMIT = int(os.environ.get('GUEST_SESSION_LIMIT', '240'))
PART_BYTES = int(os.environ.get('UPLOAD_PART_BYTES', '8388608'))
if not 5242880 <= PART_BYTES <= 67108864:
raise RuntimeError('UPLOAD_PART_BYTES must be between 5 and 64 MiB')
@@ -56,7 +62,7 @@ def operator_login(body: OperatorLogin, request: Request, response: Response):
valid_user = secrets.compare_digest(email.encode(), configured_email.encode())
valid_password = secrets.compare_digest(body.password.encode(), os.environ['OPERATOR_PASSWORD'].encode())
if not (valid_user and valid_password):
audit('operator_login_failed')
audit('operator_login_failed', ip=client_ip(request))
raise HTTPException(401, 'Invalid operator login')
token = secrets.token_urlsafe(32)
with db.connect() as c:
@@ -84,11 +90,11 @@ async def safe_headers(request, call_next):
if request.method not in ('GET','HEAD','OPTIONS'):
origin = request.headers.get('origin')
if request.headers.get('sec-fetch-site') == 'cross-site' or (origin and origin not in ALLOWED_ORIGINS):
audit('cross_origin_rejected')
audit('cross_origin_rejected', ip=client_ip(request))
return JSONResponse({'detail':'Cross-origin request rejected'}, status_code=403)
response = await call_next(request)
if response.status_code in (401,403,429) or response.status_code>=500:
audit('http_security_event', method=request.method, status=response.status_code)
audit('http_security_event', method=request.method, status=response.status_code, ip=client_ip(request))
response.headers['Cache-Control'] = 'no-store'
response.headers['X-Content-Type-Options'] = 'nosniff'
response.headers['Referrer-Policy'] = 'no-referrer'
@@ -111,7 +117,9 @@ def session(request: Request, response: Response):
try:
session_id = owner(request)
except HTTPException:
rate_limit('guest-sessions', ENVIRONMENT, 120, 900)
# Per source, not per deployment: keyed on the environment name this was a
# single global bucket, so ~8 new visitors a minute exhausted it site-wide.
rate_limit('guest-sessions', client_ip(request), GUEST_SESSION_LIMIT, 900)
with db.connect() as c:
session_id = new_session(c, response)
return {'environment': ENVIRONMENT, 'cart_scope': str(session_id), 'part_bytes': PART_BYTES,

View File

@@ -10,6 +10,20 @@ from .db import connect
COOKIE_SECURE = os.environ.get('COOKIE_SECURE', 'false').lower() == 'true'
def client_ip(request: Request):
"""The requester's address, for rate-limit buckets and security events.
The API is only reachable through the web gateway, which replaces
X-Forwarded-For with the peer address it observed, so the header carries
exactly one value the client could not choose. Without this every request
looks like the gateway, which collapses per-source limits into one global
bucket and strips the address from the audit trail.
"""
forwarded = request.headers.get('x-forwarded-for', '').split(',')[0].strip()
if forwarded:
return forwarded[:64]
return request.client.host if request.client else 'unknown'
def password_hash(password, salt=None):
salt = salt or secrets.token_hex(16)
digest = hashlib.scrypt(password.encode(), salt=bytes.fromhex(salt), n=16384, r=8, p=5).hex()
@@ -82,7 +96,7 @@ def rate_limit(scope, identity, limit, seconds=900):
def throttle(email, request):
# Independent account and source buckets prevent bypass by rotating emails.
rate_limit('auth-source', request.client.host if request.client else 'local', 60)
rate_limit('auth-source', client_ip(request), 60)
rate_limit('auth-account', email, 10)
def operator(request: Request):

View File

@@ -4,6 +4,7 @@ from pathlib import Path
from urllib.parse import urlparse
import psycopg
from psycopg import sql
from .secrets import load as load_secret_files
def admin_connect():
@@ -25,6 +26,7 @@ def admin_password():
def main():
load_secret_files()
role = os.environ['APP_DB_USER']
password = os.environ['APP_DB_PASSWORD']
if password == admin_password():

View File

@@ -5,7 +5,7 @@ from fastapi import Depends, HTTPException, Request, Response
from psycopg.errors import UniqueViolation
from psycopg.types.json import Jsonb
from . import db
from .auth import owner, session_row, new_session, password_hash, password_matches, transfer_guest, throttle, DUMMY_PASSWORD_HASH, audit
from .auth import owner, session_row, new_session, password_hash, password_matches, transfer_guest, throttle, DUMMY_PASSWORD_HASH, audit, client_ip
from .models import Register, Login, UploadStart, ArtworkSubmission
from .scanning import require_clean
@@ -45,7 +45,7 @@ def install_routes(app, operator, storage, begin, status, part, complete, upload
stored = account['password_hash'] if account else DUMMY_PASSWORD_HASH
matches = password_matches(body.password, stored)
if not account or not matches:
audit('customer_login_failed')
audit('customer_login_failed', ip=client_ip(request))
raise HTTPException(401, 'Invalid email or password')
previous = current(request)
with db.connect() as c:

View File

@@ -16,6 +16,7 @@ server {
limit_req_status 429;
proxy_pass http://api:8000;
proxy_set_header Host $http_host;
proxy_set_header X-Forwarded-For $remote_addr;
client_max_body_size 2m;
}
location / { try_files $uri $uri/ =404; }
@@ -30,5 +31,6 @@ server {
limit_req_status 429;
proxy_pass http://api:8000;
proxy_set_header Host $http_host;
proxy_set_header X-Forwarded-For $remote_addr;
}
}

View File

@@ -4,11 +4,30 @@ from unittest.mock import patch
from botocore.exceptions import ClientError
from .db import connect
from .adapters import LocalS3Storage
from .auth import password_hash, password_matches
from .auth import client_ip, password_hash, password_matches
from .scanning import ClamAV, require_clean
from fastapi import HTTPException
class FakeRequest:
def __init__(self, headers=None, peer='10.0.0.2'):
self.headers=headers or {}
self.client=type('C',(),{'host':peer})() if peer else None
def check_client_ip():
"""The gateway hands one address; a direct peer falls back to its own."""
# Gateway-set header wins over the connection peer, which is the gateway.
assert client_ip(FakeRequest({'x-forwarded-for':'198.51.100.9'}))=='198.51.100.9'
# Only the first entry is used, and it is length-capped.
assert client_ip(FakeRequest({'x-forwarded-for':'198.51.100.9, 10.0.0.2'}))=='198.51.100.9'
assert len(client_ip(FakeRequest({'x-forwarded-for':'a'*500})))<=64
# No header: the peer address, never a constant shared by every request.
assert client_ip(FakeRequest(peer='192.0.2.5'))=='192.0.2.5'
assert client_ip(FakeRequest({'x-forwarded-for':' '},peer='192.0.2.5'))=='192.0.2.5'
assert client_ip(FakeRequest(peer=None))=='unknown'
print('PASS: client address resolution for rate-limit buckets and audit events')
def run():
check_client_ip()
with connect() as c:
role=c.execute('SELECT rolsuper,rolcreatedb,rolcreaterole,rolbypassrls FROM pg_roles WHERE rolname=current_user').fetchone()
assert not any(role.values()),role

76
local/secrets.py Normal file
View File

@@ -0,0 +1,76 @@
"""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.
Call `load()` in every entrypoint before any configuration is read.
Note for readers: this module is `local.secrets`. Python 3 resolves `import
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
# 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`.
SECRET_FILE_SETTINGS = (
'DATABASE_URL',
'DATABASE_ADMIN_URL',
'DATABASE_PASSWORD',
'DATABASE_ADMIN_PASSWORD',
'APP_DB_PASSWORD',
'AWS_ACCESS_KEY_ID',
'AWS_SECRET_ACCESS_KEY',
'OPERATOR_PASSWORD',
'PAYMENT_TOKEN',
'PAYMENT_WEBHOOK_SECRET',
'TINY_TOKEN',
'WHATSAPP_TOKEN',
)
SUFFIX = '_FILE'
def read_secret(path):
"""One secret's value, without the newline an editor or `docker secret` adds.
Only a single trailing newline is removed: everything else is part of the
value, because a generated password may legitimately end in whitespace.
"""
with open(path, 'r', encoding='utf-8') as handle:
value = handle.read()
if value.endswith('\r\n'):
return value[:-2]
if value.endswith('\n'):
return value[:-1]
return value
def load(environ=None):
"""Replace every `<NAME>_FILE` path with `<NAME>` holding the file's contents.
Fails closed. An unreadable secret, an empty one, or a name supplied both
directly and as a file is a configuration error, and starting anyway would
mean running with a credential nobody intended. Never logs a value.
"""
environ = os.environ if environ is None else environ
resolved = []
for key in sorted(k for k in environ if k.endswith(SUFFIX) and len(k) > len(SUFFIX)):
name = key[:-len(SUFFIX)]
path = environ[key].strip()
if not path:
raise RuntimeError(f'{key} is set but empty; point it at a secret file')
if environ.get(name):
raise RuntimeError(
f'{name} and {key} are both set; supply the value or the file, not both')
try:
value = read_secret(path)
except OSError as exc:
raise RuntimeError(f'{key} could not be read: {exc.strerror}') from None
if not value:
raise RuntimeError(f'{key} points at an empty secret file')
environ[name] = value
resolved.append(name)
return resolved

View File

@@ -63,4 +63,15 @@ def run():
attacker.call('/operator/login',{'email':email,'password':'invalid'},expected=429)
print('PASS: operator login throttling (only synthetic account bucket exhausted)')
# Guest sessions are limited per source, not once for the whole deployment.
# Keyed on the environment name this was a single global bucket of 120 per
# 15 minutes, which the suites above would already have eaten into.
for _ in range(25):
Client().call('/session')
# A forged forwarded address must not let a client pick another bucket: the
# gateway overwrites the header, so these count against the real source too.
for _ in range(5):
raw('/api/session',200,{'X-Forwarded-For':'203.0.113.7'})
print('PASS: guest sessions limited per source, forwarded address not client-controlled')
if __name__=='__main__':run()

93
local/test_secrets.py Normal file
View File

@@ -0,0 +1,93 @@
"""Docker secret-file resolution. Standard library only; no stack required."""
import tempfile
import unittest
from pathlib import Path
from local.secrets import SECRET_FILE_SETTINGS, load, read_secret
class SecretFileTests(unittest.TestCase):
def setUp(self):
self.dir = tempfile.TemporaryDirectory()
self.addCleanup(self.dir.cleanup)
def secret(self, content, name='secret'):
path = Path(self.dir.name) / name
path.write_text(content, encoding='utf-8')
return str(path)
def test_resolves_file_into_plain_setting(self):
env = {'DATABASE_URL_FILE': self.secret('postgresql://u:p@db:5432/dtf\n')}
self.assertEqual(load(env), ['DATABASE_URL'])
self.assertEqual(env['DATABASE_URL'], 'postgresql://u:p@db:5432/dtf')
def test_strips_only_one_trailing_newline(self):
# A generated password may legitimately end in whitespace, so only the
# newline `docker secret` or an editor appends may be removed.
self.assertEqual(read_secret(self.secret('p@ss \n')), 'p@ss ')
self.assertEqual(read_secret(self.secret('p@ss\n\n')), 'p@ss\n')
self.assertEqual(read_secret(self.secret('p@ss\r\n')), 'p@ss')
self.assertEqual(read_secret(self.secret('p@ss')), 'p@ss')
def test_preserves_characters_that_would_break_a_url(self):
value = 'p@ss:w/rd?#[]&=+$ ,%'
env = {'OPERATOR_PASSWORD_FILE': self.secret(value + '\n')}
load(env)
self.assertEqual(env['OPERATOR_PASSWORD'], value)
def test_resolves_every_documented_production_setting(self):
env = {f'{name}_FILE': self.secret(f'value-for-{name}', name)
for name in SECRET_FILE_SETTINGS}
load(env)
for name in SECRET_FILE_SETTINGS:
self.assertEqual(env[name], f'value-for-{name}')
def test_missing_file_fails_closed(self):
env = {'DATABASE_URL_FILE': str(Path(self.dir.name) / 'absent')}
with self.assertRaises(RuntimeError) as caught:
load(env)
self.assertIn('DATABASE_URL_FILE', str(caught.exception))
self.assertNotIn('DATABASE_URL', env)
def test_empty_secret_fails_closed(self):
with self.assertRaises(RuntimeError):
load({'OPERATOR_PASSWORD_FILE': self.secret('\n')})
def test_empty_path_fails_closed(self):
with self.assertRaises(RuntimeError):
load({'OPERATOR_PASSWORD_FILE': ' '})
def test_value_and_file_together_is_ambiguous(self):
env = {'OPERATOR_PASSWORD': 'inline',
'OPERATOR_PASSWORD_FILE': self.secret('from-file')}
with self.assertRaises(RuntimeError):
load(env)
self.assertEqual(env['OPERATOR_PASSWORD'], 'inline')
def test_never_puts_a_secret_in_the_error_text(self):
value = 'super-secret-value'
env = {'TINY_TOKEN': value, 'TINY_TOKEN_FILE': self.secret(value)}
with self.assertRaises(RuntimeError) as caught:
load(env)
self.assertNotIn(value, str(caught.exception))
def test_ignores_a_bare_suffix_and_leaves_other_settings_alone(self):
env = {'_FILE': '/nowhere', 'APP_ENV': 'production'}
self.assertEqual(load(env), [])
self.assertEqual(env['APP_ENV'], 'production')
def test_documented_list_matches_the_production_stack(self):
stack = Path(__file__).resolve().parent.parent / 'deploy' / 'stack.yaml'
if not stack.exists():
self.skipTest('production stack definition not present')
text = stack.read_text()
# POSTGRES_PASSWORD_FILE is consumed by the database image, not by us.
used = {line.split(':')[0].strip() for line in text.splitlines()
if '_FILE:' in line and 'POSTGRES_PASSWORD_FILE' not in line}
for key in used:
self.assertIn(key[:-len('_FILE')], SECRET_FILE_SETTINGS,
f'{key} is passed by the stack but undocumented in secrets.py')
if __name__ == '__main__':
unittest.main()

View File

@@ -6,9 +6,11 @@ import time
from http.server import BaseHTTPRequestHandler, HTTPServer
from psycopg.types.json import Jsonb
from .adapters import FakeTiny, FakeWhatsApp, LocalS3Storage, require_runtime
from .secrets import load as load_secret_files
from .db import connect
from .scanning import ClamAV, scan_loop
load_secret_files()
require_runtime()
adapters = {'tiny': FakeTiny(), 'whatsapp': FakeWhatsApp()}
last_tick = 0.0