Skip to content

fix: work through the security and correctness audit queue - #108

Merged
lnieuwenhuis merged 21 commits into
stagingfrom
dev
Aug 19, 2026
Merged

fix: work through the security and correctness audit queue#108
lnieuwenhuis merged 21 commits into
stagingfrom
dev

Conversation

@lnieuwenhuis

Copy link
Copy Markdown
Owner

Works through the audit queue in AUDIT-REMEDIATION.md. Every P0 and P1 is closed, along with the P2/P3/P4 items. One item is deliberately partial and two audit recommendations turned out to be wrong and were reversed — details below.

P0

ID What was wrong
SEC-01 /api/auth/shoo/verify was unauthenticated, parsed with request.json() (which ignores Content-Type) and set the session cookie with no origin check. A cross-site text/plain form post needed no preflight and no JS, and SameSite=Lax governs sending a cookie, not setting one — so a victim's browser silently stored the attacker's session. Now requires same-origin and fails closed when neither Sec-Fetch-Site nor Origin is present.
SEC-02 Validation seeds required_spec_claims with only exp, and its iss/aud match arms fall through on an absent claim — so both were enforced only for tokens that happened to carry them. A token omitting aud authenticated. Also stopped deriving the algorithm from the token's own header.
SEC-03 Login matched by email and then unconditionally rebound that row's shoo_pairwise_sub, so an ID token carrying a victim's email under a different subject took over the account. Now matches on subject only; an email held under another subject is a conflict.
DB-01 meta/ had snapshots 00000004 against a 15-entry journal, so the next db:generate would have diffed against the 0004 state and emitted a destructive migration. Migrations are now declared hand-authored, with MIGRATIONS.md and a test locking it in.
DEP-01 next 16.2.3 → 16.3.1, clearing 22 advisories including a beforeInteractive XSS affecting the mt_tz bootstrap this app actually uses.

Behavioural changes worth reviewing closely

  • CSP nonce (SEC-10) — the policy moves out of next.config.ts (baked into the routes manifest at build time) into proxy.ts. 'unsafe-inline' is gone from script-src. style-src deliberately keeps it and gets no nonce, since a style nonce makes browsers ignore 'unsafe-inline' and would break every inline style Next and next/font emit.
  • Session lifetime (SEC-07) — renewal is capped against the original iat. Previously every request re-minted a 7-day token, so a captured one never expired. No schema change; no "sign out everywhere".
  • Database TLS (SEC-04)sslmode=require now verifies the chain. It previously yielded rejectUnauthorized: false, the same posture no-verify is rejected for, and it is the value Railway/Neon/Supabase hand out. ALLOW_UNVERIFIED_DB_TLS=true is the only way back and warns loudly.
  • Rate limiting (SEC-09) — per-IP limiter on /api/v1, pool default 3 → 10, /health probe cached. Note it keys on the peer IP, so behind Railway's edge this degrades to a global cap; switching to X-Forwarded-For is only safe if Railway strips inbound XFF.
  • Migrations 0015/0016admin_audit_events.actor_user_id becomes nullable with ON DELETE SET NULL, and enum-like columns gain CHECK constraints added NOT VALID so a blocking preDeployCommand cannot fail on legacy rows.

Deliberately not done

SEC-18 (owner demotion) is partial. AppUser has no column recording whether ownership came from ADMIN_OWNER_EMAILS or the admin UI, so demoting every owner absent from the list would strip admin-granted owners and could leave zero owners. Ships as a tested policy function that warns instead. Real revocation needs a schema change.

Two audit recommendations that were wrong

  • OPS-05 proposed collapsing the frontend's two railway.toml copies into the root one. Reversed: Railway applies the root config to any service without its own config-as-code path — which is exactly what 8cac5bea had to fix when it hijacked the cliproxyapi service. apps/web/railway.toml is restored and the ownership of each file documented.
  • API-13 claimed unsupported_model should be a 500/503. After the AI-gateway refactor its premise (free-model filtering) no longer exists, and the branch's only producer is the gateway reporting a server-configured model can't accept images — already a 502, which is correct. Dropped and pinned with a test rather than reinstating the removed allowlist.

Verification

Suite Result
cargo test -p macro-tracker-backend 178 passed
cargo clippy --all-targets silent
cargo fmt --all --check clean
web unit (vitest) 368 passed / 57 files
packages/db 100 passed
Playwright e2e 25 passed
typecheck / lint / audit:code clean

Web and db suites were run against a live Rust backend and a real Postgres, matching how CI runs them — several are integration tests that pass vacuously without one.

Test-coverage items were each verified by breaking the behaviour and confirming the test failed, including removing the __-prefix strip to confirm the mass assignment reappears.

Notes

  • Rebased onto the AI-gateway refactor that landed on dev mid-flight; the provider-specific fixes were re-targeted rather than redone.
  • Advisories: 50 → 5, all remaining in vite, reachable only as vitest's bundler and never in a production artifact. An override for it is inert (pnpm resolves it through vitest's range regardless), so none was left behind implying protection that isn't there.
  • CONCERN-C3 needs a product decision: ten benchmark fixtures use loremflickr.com, which 302s to a different random photo per request, so those benchmarks can't be comparable between runs. Left unchanged but pinned by a test; fixing needs licence-checked replacements.
  • Version bumped to 3.3.0 (v3.03), since sessions, CSP, headers, rate limiting and DB TLS all change behaviour.

The AI gateway is now the only food-photo provider; the free-model
allowlist, deprecated-model handling, OpenRouter request shape and
headers, and the OPENROUTER_* configuration are gone. The per-attempt
timeout override moves to AI_GATEWAY_MODEL_TIMEOUT_MS (default 20s,
clamped 3-30s). Without AI_GATEWAY_URL the feature reports itself
unavailable instead of falling back.

OpenRouter still works as a stand-in gateway by pointing AI_GATEWAY_URL
at its chat-completions endpoint, which is why the error reader keeps
parsing openrouter_metadata details.
…od-photo

refactor(ai): drop the free-OpenRouter food-photo path
Resolves the 22 next advisories reported by pnpm audit, including the
beforeInteractive script XSS that affects the mt_tz bootstrap in
app/layout.tsx. Patch-level bump within 16.x, no migration required.
OPS-02: audit:code now runs as a CI job inside the checks gate; nothing
invoked the audit scripts before, which is why the broken knip config went
unnoticed.
OPS-03: SHA-pin every action and pin the Rust toolchain to 1.89 to match
railpack.json. dtolnay/rust-toolchain@stable was a moving branch, so CI's
toolchain could change between runs. Adds Dependabot to keep the pins fresh.
OPS-04: add Swatinem/rust-cache to the five Rust jobs.
OPS-05: drop the duplicate apps/web/railway.toml in favour of the root one.
DOC-01: fix the two broken lnieuwenhuis/marco-tracker URLs, stop hardcoding
the app version, and drop the db:generate reference.
SEC-08: stop handing operators a publicly known SESSION_SECRET placeholder.
CLEAN-04: gitignore .codex/ and the remediation queue; delete audit-report.html.
…skipped tests visible

SEC-03: match logins on shoo_pairwise_sub only. The previous query also
matched by email and then unconditionally rebound that row's subject, so an
ID token carrying a victim's email under a different subject took over the
account. An email already held under another subject is now a conflict.
DATA-01/DATA-03: enforce MAX_QUANTITY and MAX_MACRO_GRAMS in
validate_meal_components so the product-linked path cannot bypass them, bound
calories, and aggregate as bigint so existing rows cannot overflow the ::int
cast and 500 the summary, dashboard, stats and leaderboard queries.
BUG-01: stats_page_data_json hardcoded a zero streak, so every user's Summary
page read 0. It now shares the dated_islands/streaks CTE with leaderboard_json.
DATA-05..09, PERF-01..04, LOW-A3, LOW-B1, API-07, API-10, CLEAN-03, CLEAN-11.
TEST-01: 21 integration tests returned early when no database was configured,
reporting as passed with zero assertions. A build script now sets a cfg so they
report as ignored instead, while still running in CI.
…est guard

SEC-04: sslmode=require yielded rejectUnauthorized:false, the same TLS posture
no-verify is rejected for a few lines earlier, and it is the value Railway, Neon
and Supabase hand out. It now verifies the chain; ALLOW_UNVERIFIED_DB_TLS=true
is the only way back, and it warns loudly.
DB-01: drizzle-kit diffs against meta/0004, the last snapshot present, while the
journal has 15 entries, so the next db:generate would have emitted a destructive
migration. Migrations are now declared hand-authored: db:generate is gone,
MIGRATIONS.md documents the procedure, and a test locks the invariant in.
DB-02/DB-07: set lock_timeout and statement_timeout on the migration connection
and bound the advisory lock, so a blocked ALTER during preDeployCommand aborts
instead of stalling every query queued behind it.
DB-03..DB-06, DB-08..DB-10, LOW-F1, LOW-F2.
OPS-01: knip rejected a '//' comment key, so audit:unused had been exiting 2
before analysing anything.
CLEAN-01/CLEAN-02/CLEAN-09: drop the dead wrappers, types, and the unreachable
date helpers whose logic now lives in SQL, along with their tests.
…en headers

SEC-01: /api/auth/shoo/verify was unauthenticated, parsed with request.json()
(which ignores Content-Type) and set the session cookie, with no origin check.
A cross-site text/plain form post needed no preflight and no JS, and SameSite=Lax
governs sending a cookie, not setting one, so a victim's browser silently stored
the attacker's session. It now requires same-origin and fails closed when neither
Sec-Fetch-Site nor Origin is present.
SEC-07: sessions were re-minted for 7 days from the old token's claims on every
request, so a captured token never expired. Renewal is now capped against the
original iat.
SEC-08: mirror the backend's SESSION_SECRET validation, measured on the trimmed
value, and reject the known placeholder and dev literals.
SEC-12..SEC-16, SEC-21, SEC-22, API-16, LOW-D1, LOW-D2.
DATA-04: thread a clientMutationId through recipe portion logging; it was the
one create path without one, so a double-tap wrote two entries.

SEC-10 (CSP nonce) and DATA-10 (parseDecimalInput) are NOT in this commit and
land later in the branch.
OPS-02 added audit:code to the CI gate, but knip still exited 1: types that are
exported and used only within their own file were reported as unused, and
postgres-config.d.ts's exports are a known false positive (they are consumed by
client.ts, start-with-migrations.mjs and the tests across a boundary knip's
resolution misses). ignoreExportsUsedInFile plus an ignore entry settles both.
CLEAN-06: drop the unused @testing-library/user-event devDependency.
Removes resolveBrowserTimeZone, which had no callers as far back as the audited
commit.
SEC-02: Validation::new seeds required_spec_claims with only "exp", and
neither set_issuer nor set_audience adds to it. In validate(), the iss and aud
match arms both end in `_ => {}`, so an absent claim fell through and passed -
issuer and audience were enforced only for tokens that happened to carry them.
A token signed by any key in Shoo's JWKS but omitting aud was accepted as a
login. Requires both explicitly now.

Also stops deriving the algorithm from the token's own header, which let the
caller choose it. Shoo's live JWKS serves a single ES256 key over P-256, so the
algorithm is pinned to that.

SEC-19: DecodingKey::from_jwk builds an HMAC key from a symmetric oct JWK, so a
symmetric key in the JWKS would have let anyone who can read the public JWKS
mint logins for any email. The key type is now pinned to EC P-256. The check is
on kty/crv rather than the optional alg member, so a JWKS that omits alg keeps
working instead of taking every login down.

Tests were confirmed to fail against the unfixed code: dropping the required
claims fails the two absent-claim tests, and dropping the key-type guard lets a
symmetric key verify a login. The test fixtures move from HS256 to a throwaway
P-256 keypair, since a symmetric key is no longer accepted.
The data-layer changes in c3adfde7 were not rustfmt-clean, and CI runs
cargo fmt --all --check as a hard gate.
…CHEMA_SQL to the migrations

API-07: completeOnboardingSetup was the third write path into
meal_templates.type and the only one still calling required_string instead of
normalize_template_type. Migration 0016 adds a CHECK on that column, so an
out-of-union value would have surfaced as a raw 23514 -> 500 rather than a 400.

CLEAN-03: SCHEMA_SQL carried #[allow(dead_code)] and a comment calling it a
temporary parity reference, but test_db() runs it to build the schema for every
backend integration test - so drift from packages/db/drizzle silently stops the
suite testing the schema production runs. The comment is corrected and a new
test applies the migrations and SCHEMA_SQL into two scratch schemas and diffs
the resulting catalogs.

That test immediately caught real drift: migration 0015 made
admin_audit_events.actor_user_id nullable with ON DELETE SET NULL, and
SCHEMA_SQL still declared it NOT NULL. Aligned.
SEC-10: script-src carried 'unsafe-inline', so any injected script would have
run, with img-src https: as an open exfiltration channel. next.config.ts
headers() is baked into the routes manifest at build time and cannot carry a
per-request value, so the policy moves to proxy.ts, which is also where Next
reads the nonce back out. 'unsafe-inline' is gone from script-src in both dev
and production; style-src deliberately keeps it and gets no nonce, since a
style nonce makes browsers ignore 'unsafe-inline' and would break every inline
style Next and next/font emit. Verified in a real browser: zero CSP violations
across sign-in, sign-out and soft navigation, both bootstraps still run, mt_tz
still tracks the browser zone, nonce differs per request, 23/23 e2e green.

UI-02: todayStr was computed with getLocalDateString() during render in
app-shell and dashboard-shell, resolving to the Node process zone on the
server and the browser zone on hydration. loadOnboardedDateParam now also
returns getRequestToday(), and all eight pages pass it down like selectedDate.
recipe-card.tsx and onboarding-shell.tsx call the same helper inside click
handlers, which is correct, and were left alone.

UI-01: revert the optimistic meal-group change when the save fails.
UI-03: dismiss the MealCard overflow menu on Escape and outside pointerdown.
UI-04: clear the pending 'Copied' timers on unmount.
UI-05: stop BarcodeResult's manual-entry form being dismissable mid-save.
DATA-10: parseDecimalInput turned "1,234" into 1.234 silently. A comma is now
only a decimal separator when unambiguous; grouped forms are rejected rather
than mangled.
CLEAN-05: drop the dead slideIn/fadeIn keyframes.

apps/web/AGENTS.md is regenerated by next dev; committed so the tree stays clean.
…cated, rate limit

SEC-05: BACKEND_ENABLE_TEST_ROUTES was accepted for any APP_URL, unlike its
sibling BACKEND_ALLOW_INSECURE_LOCAL, while playwright.config.ts ships it in a
copy-pasteable env block. Copied into a staging service, anyone reaching
/internal/rpc with the shared secret could call ensureUserRoleForTesting and
become owner, bypassing the actor check, the FOR UPDATE, the last-owner refusal
and the audit event. Now refused unless APP_URL is loopback.
SEC-06: insecure-local mode drops internal auth entirely while the listener
bound 0.0.0.0, exposing the whole internal RPC surface to the local network.
The bind address is now a pure, tested function that returns 127.0.0.1 in that
mode.
SEC-08: validate_secret measured the untrimmed value, so 32 spaces passed. It
now measures the trimmed one, and the committed development secrets are
blocklisted.
SEC-09: no rate limiting existed and the pool defaulted to 3, while both
/health and /api/v1 hit the database before any credential check - a few
hundred garbage-bearer requests could starve the pool and 503 /health, which
trips Railway's restart policy. Adds a per-IP limiter on /api/v1, raises the
pool to 10, and caches the /health probe for a second.
SEC-11, SEC-17 (subtle::ConstantTimeEq), SEC-18 (warned variant - see below),
SEC-20, CLEAN-A1, CLEAN-A2 (JWKS single-flight + negative caching).

SEC-18 is deliberately partial: AppUser has no column recording whether
ownership came from ADMIN_OWNER_EMAILS or the admin UI, so demoting every owner
absent from the list would strip admin-granted owners and could leave zero
owners. Ships as a tested policy function that warns once per account instead.

Scopes SCHEMA_SQL to cfg(test), which is what it is - removing its
#[allow(dead_code)] in 4152b6ef left a clippy warning in non-test builds.
…ill the API spec

API-01: a method listed in Endpoint::methods but absent from Endpoint::scopes
silently required no scopes. The 130-line match is now an ordered table keyed on
the OpenAPI path template, required_scopes returns Option, and a missing tuple
is a logged 500 rather than an empty set. The hand-kept test path list that
omitted four endpoint shapes is now derived from the table and cross-checked
against the published contract in both directions.
DATA-02: the patch-merge loop copied every client key, so a caller could set
__recalculateProductMacros and have its own macro numbers stored verbatim
against a product snapshot. Reserved keys are stripped and the flag is
re-derived from the stored row.
API-02/API-12: the two aiResponse branches and the reqwest transport error went
straight to the caller, contradicting the invariant the same module documents.
Both now go through upstream_photo_failure.
API-03, API-04, API-05, API-06, API-08, API-09, API-11, API-13, API-14, API-15,
CLEAN-C1, CLEAN-C2.
CONCERN-C3: every benchmark fixture pointed at a Commons article URL serving
text/html, so the benchmark scored models against a web page. Swapped for direct
file URLs, with image_url split from image_source_url so the licence link
survives.

Also fixes two problems the rate limiter shipped with:
- the 429 carried no CORS headers, so a browser client saw a CORS failure
  instead of the throttle - the same defect class API-06 just fixed. Verified
  on the wire.
- the limiter throttled the request-level web suite, which drives hundreds of
  sequential calls from one address. The burst widens behind
  enable_test_routes, which SEC-05 restricts to a loopback APP_URL.
…ailpack split

CLEAN-07: round1 was defined identically in api.rs, legacy_api.rs and db.rs, and
round2 in two of them. Both move to a shared module.
api.rs's require_object/require_string_field/require_date are deliberately NOT
merged with db.rs's object_arg/required_string/date_arg. They return different
error types, take different shapes (owned body vs nested arg), and db.rs's
required_string trims and enforces MAX_TEXT_FIELD_LENGTH while api.rs's does
not - merging would silently move validation across a trust boundary. This
repo has already been bitten twice by drifted 'duplicates' (DATA-05, DATA-08).
CLEAN-10: 18 clones -> 14, 2.17% -> 1.62%, all in test scaffolding. The
remaining clones are jsonb_build_object projections and positional bind chains,
left as plain readable SQL on purpose - hiding a column list behind a macro is
how DATA-08 happened.

API-15 follow-up: PATCH /meal-entries/{id} and POST /templates/{id}/apply can
both return 409 now that unique violations map to Conflict, so the published
spec and the web contract say so. openapi-parity passes both directions again.
CLEAN-08: drop the parameters resolvePresetModalActiveKind never read, keeping
the behaviour its test pins, with a note on why the counts are ignored.
OPS-06: document why railpack.json carries a Rust toolchain for both services
and what splitting it would actually require. Not expressed as a comment key in
the JSON itself - the schema sets additionalProperties:false, which is exactly
how the knip config in OPS-01 came to be broken.
TEST-02: /settings/api had no coverage at any level - api-token-actions.test.ts
mocked ApiSettingsClient out entirely. The surface where a raw secret is shown
exactly once is now rendered for real: create -> reveal -> next action clears it,
and create -> revoke never leaks it into the revoke form or the token list.
TEST-03: the recipe journey had no e2e at all, which is why DATA-04 shipped.
Builds a recipe, checks per-portion macro scaling, logs a portion, and asserts
the dashboard entry. A second spec dispatches two clicks in one page task to
reproduce the double-tap race and asserts exactly one entry is written.
TEST-04: lazy-modal-mounting.test.tsx asserted on component source text, so
renaming a guard variable or breaking its boolean logic still passed. Replaced
with RTL renders that check the modal subtree is really absent or present.
TEST-05: canAccessAdmin/isOwnerRole are the sole admin authorization primitive
and admin-auth.test.ts reimplemented them inline rather than importing them, so
a bug in the real code could not fail that suite. Tested directly.
TEST-06: calculateMacroTargets only had loss scenarios; adds a surplus case.
DATA-02: request-level coverage for the reserved-key strip, which could not live
in api.rs because it has no database harness.

Every one of these was verified by breaking the behaviour it covers and
confirming the test failed - including removing the __-prefix strip from
apply_client_patch, which let the mass assignment through as expected.
DEP-02. Every remaining advisory after the next bump was transitive through dev
tooling, so pnpm overrides pull in the patched versions without chasing the
eslint 9->10 and typescript 5->7 majors the audit called out as not worth doing
opportunistically. esbuild, postcss, nanoid, js-yaml, @babel/core and
brace-expansion (across all three majors in the tree) are now on patched
releases, and vitest moves 4.1.2 -> 4.1.11.

28 advisories -> 5. The five that remain are all vite, reached only as vitest's
bundler. An override for it is genuinely inert - pnpm keeps resolving 8.0.3
through vitest's dependency range whatever is pinned, verified with
'pnpm install --force' and 'pnpm update vite --latest' - so no dead override is
left behind implying a protection that is not there. vite never reaches a
production artifact; next build uses its own bundler.
This remediation pass changes behaviour rather than only fixing bugs: sessions
now have an absolute lifetime, the CSP serves a nonce instead of 'unsafe-inline',
COOP/CORP are enforced, /api/v1 is rate limited, and database TLS verifies by
default. Per AGENTS.md that is a feature-level change, not a bug fix.
…-path read

Reverses the OPS-05 consolidation. The audit proposed collapsing the frontend's
two railway.toml copies into the root one, and that is wrong: Railway applies the
root config to any service without a config-as-code path of its own, which is
exactly what commit 8cac5be had to fix when this file's 'pnpm start' replaced
the cliproxyapi container entrypoint and failed that deploy. Restores
apps/web/railway.toml and documents which file belongs to which service.

Also caps read_upstream_error, which still buffered an unbounded error body via
response.json() while the success path went through read_capped_json. Same
exhaustion CLEAN-C1 closed, on the branch a hostile gateway actually controls.
getByText("Operations Panel") also matched Next's route announcer, the
#__next-route-announcer__ live region that mirrors the page heading after a
client-side navigation, so the locator resolved to two elements and tripped
strict mode. The page itself renders once - one h1, one main - and the announcer
is correct accessibility behaviour, so the selector was the problem. Asserting
on the heading role is both unambiguous and closer to what the test means.

Surfaced by rebasing onto the CSP-nonce work, which makes that navigation soft
and therefore populates the announcer where a full load left it empty.
Pinning dtolnay/rust-toolchain to an explicit 1.89 in OPS-03 dropped the
components the 'stable' alias installs by default, so the Rust job failed with
"'cargo-fmt' is not installed for the toolchain '1.89-x86_64-unknown-linux-gnu'"
before running a single check.
@lnieuwenhuis
lnieuwenhuis merged commit ba85082 into staging Aug 19, 2026
6 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant