Skip to content

fix: post-deploy findings — reset flow, uploads, redirects, deploy env - #123

Merged
goetchstone merged 7 commits into
mainfrom
fix/post-deploy-findings
Sep 30, 2026
Merged

goetchstone merged 7 commits into
mainfrom
fix/post-deploy-findings

Conversation

@goetchstone

Copy link
Copy Markdown
Owner

Why

After #118 went live, a 26-agent outside-in sweep of akritos.com confirmed the Next.js RCE fix and turned up older bugs. An adversarial review of the fixes (49 agents) then caught one leak the first draft introduced, and two latent security bugs next to the code being changed. Everything below is fixed and verified against the real production image.

Security

  • Password-reset token could reach Google Analytics. Opening /reset-password to logged-out users would have put its one-hour token on a page running GA4, which reports the full URL. Analytics now renders only on public pages: never on /dashboard, /portal, /login, /forgot-password, /reset-password or /support/track. That also stops the pre-existing portal and ticket-tracking token leak. The reset form strips the token from the URL on load.
  • Open redirect after login. The security: escape email HTML + 3 hardening fixes #90 guard blocked //host but not /\host, which browsers normalize to //host. lib/safe-redirect.ts now resolves against the origin and compares origins, with 15 test cases.
  • Fresh installs kept the public AUTH_SECRET placeholder. deploy.sh now treats .env.example placeholders as missing, so anyone could previously have forged a session on a rebuilt box. The live site is not affected: the CSRF-cookie hash matches none of the placeholder values. APP_ENCRYPTION_KEY no longer re-rotates on every deploy.
  • Image optimizer disabled (images.unoptimized). No page uses it, it's the GHSA-2xp9 component, and its cache was unwritable, so every request re-processed.
  • App port bound to 127.0.0.1 (nginx already proxies there); X-Powered-By removed; resetPassword rate-limited; PUBLIC_PATHS matches whole segments, so a future /bookkeeping can't become public via /book.

Broken features fixed

  • Password reset was unreachable: proxy.ts sent logged-out users to /login.
  • Uploads never worked: the runtime user (node) couldn't write the root-owned volume. Uploads now block READONLY (403) and are rate-limited (30/hour). Downloads of non-ASCII names (every macOS screenshot has U+202F) now use RFC 6266 instead of crashing into a masked 404.
  • Blog social-preview images returned 500: Next 16 params is a Promise.
  • Sign-out went to https://<container-id>:3000: AUTH_URL is now synced from NEXT_PUBLIC_APP_URL every deploy.
  • SEO: /blog stops canonicalizing to the homepage; /pricing title no longer says "Akritos" twice.

Verified

  • Local production image (Postgres, seeded data, a root-owned uploads volume like the VPS):
    • reset pages return 200 logged-out; protected routes still 307/401; 7 lookalike paths are blocked
    • the OG image renders a 1200×630 PNG and /_next/image returns 404
    • AUTH_URL is honored, and a control container without it reproduces the container-hostname bug
    • the volume ends up node:node, and code files stay root-owned
  • Real browser:
    • the consent banner shows on / but not on private pages
    • with consent granted, /reset-password, /portal/* and /login load no Google scripts and make no Google requests
    • the token is stripped from the URL and the form still works
    • logging in with callbackUrl=/\evil.example lands on /dashboard
  • End to end:
    • an upload with a macOS screenshot name round-trips byte-identical
    • READONLY upload returns 403; upload rate limit: 30, then 429; resetPassword: 10, then 429
    • a file missing on disk is still a clean 404
  • deploy.sh env logic: 14 .env.local variants on Ubuntu 24.04 under set -e, all idempotent. A live-like file changes only by gaining AUTH_URL.
  • Gates: tsc, 104 tests (was 85), npm audit gate, Semgrep 0 findings.

Deploy notes

  • The app container is recreated with the loopback port binding.
  • The empty uploads volume takes node ownership automatically.
  • deploy.sh adds AUTH_URL=<your NEXT_PUBLIC_APP_URL origin>. If NEXT_PUBLIC_APP_URL is unset or local, it only warns and nothing breaks.

Documented, not fixed: cron jobs have never run; booking slots are wrong for visitors east of New York; uploads aren't backed up (see CLAUDE.md Known Issues).

🤖 Generated with Claude Code

Middleware blocked /forgot-password and /reset-password for logged-out
users. Analytics now renders only on public pages, the form strips the
token from the URL, and resetPassword is rate-limited.
Browsers read "/\host" as "//host", which the prefix check allowed.
Resolve against the origin and compare origins instead.
The runtime user couldn't write the root-owned uploads volume. Uploads
now block READONLY and are rate-limited; non-ASCII names (every macOS
screenshot) no longer crash the download header.
The OG route read Next 16's Promise params synchronously and 500'd.
No page uses /_next/image (the GHSA-2xp9 component), and nginx reaches
the app on 127.0.0.1. Also stops sending X-Powered-By.
Fresh installs kept the public .env.example AUTH_SECRET. AUTH_URL now
follows NEXT_PUBLIC_APP_URL, so sign-out stops landing on the
container hostname.
Cron jobs have never run; ROADMAP claimed they worked.
@goetchstone
goetchstone merged commit dc1102f into main Sep 30, 2026
8 checks passed
@goetchstone
goetchstone deleted the fix/post-deploy-findings branch September 30, 2026 14:44
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