Defensive review of the auth, federation, secret-management, and injection surfaces across both apps and the infrastructure — findings verified against source and severity-calibrated.
2026-06-10 · four parallel review sweeps (auth/session/JWT · federation/SSRF · secrets/crypto · injection/uploads/infra) · every load-bearing claim re-checked against the code before inclusion
The automated sweeps over-flagged. I verified each high-severity claim against the source and downgraded or dropped the ones that didn't hold up. That recalibration is itself a finding: don't action the raw scanner output.
jose with a symmetric key already rejects alg:none and asymmetric algs. Explicit allow-list is hardening only. → Info.env is gitignored and never appears in history; values are dev creds. → InfoHigh Medium Low Info
0.0.0.0 Lowapps/planner/app/routes/api.sessions.ts · apps/planner/app/routes/api.save-to-journal.ts · helper apps/planner/app/lib/url-validation.server.ts (exists, unused here)
Verified: POST /api/sessions is anonymous (the Planner is stateless) and stores the caller-supplied callbackUrl with no validation. On save, the Planner server fetches it:
resp = await fetchWithTimeout(session.callbackUrl, {
method: "POST",
headers: { Authorization: `Bearer ${session.callbackToken}` },
...
Anyone can make the Planner backend issue POSTs to arbitrary hosts — internal services, 169.254.169.254, localhost. A SAFE-scheme/host validator (validateFetchUrl) already exists in the repo but is not applied on this path.
Validate callbackUrl at session-create against the existing allowlist helper, rejecting non-HTTP(S) schemes and private/loopback/link-local resolutions:
const v = validateFetchUrl(callbackUrl, {
allowedHosts: getCallbackAllowedHosts(),
});
if (!v.ok) return data(
{ error: "Invalid callback URL" },
{ status: 400 },
);
Re-validate at fetch time too (defends against DNS rebinding between create and save).
apps/journal/app/routes/api.v1.uploads.ts
Verified: the presigned-upload endpoint builds the S3 key from the raw client filename and accepts any content-type:
const key =
`${resourceType}/${resourceId}/${randomUUID()}-${filename}`;
filename in the key lets a caller shape arbitrary key prefixes (the UUID prevents collision/overwrite, and S3 keys aren't a filesystem, so this is shaping not traversal).resourceId ownership is checked so a user can't mint upload URLs under another user's resource path.contentType (e.g. image/jpeg, image/png, application/gpx+xml) and bind it into the presign so the upload can't differ.filename to [A-Za-z0-9._-] or drop it from the key entirely (the UUID is enough).Content-Disposition: attachment / from a separate origin, and verify resourceId belongs to the caller.apps/journal/app/lib/connected-services/oauth-flow.server.ts (~line 113) · pattern also in manager.ts (markNeedsRelink reason)
Verified: the code-exchange failure path logs the whole error object:
} catch (e) {
console.error(
`OAuth callback failed for ${manifest.id}:`, e);
If a provider's error response embeds the authorization code, a token, or other sensitive context in the thrown error, it lands in server logs and Sentry. The blast radius is small (only the failure branch, and the affected user is the token's owner), hence Low — but credentials in logs are worth closing.
console.error(
`OAuth callback failed for ${manifest.id}:`,
e instanceof Error ? e.message : String(e),
);
Log a redacted shape (name + truncated message), never the raw object. Apply the same to markNeedsRelink's provider-supplied reason.
0.0.0.0:2019 Lowinfrastructure/Caddyfile (line 3)
Verified: admin 0.0.0.0:2019. The port isn't published to the host, so it's not internet-reachable — but it is reachable by every container on the Docker network. The Caddy admin API can rewrite routes and reverse-proxy targets, so an RCE in the journal or planner container becomes "redirect all traffic" with no extra auth.
admin localhost:2019
Bind admin to loopback inside the Caddy container (or disable it with admin off if no live-reload is needed). Turns a one-step lateral move into a non-path.
apps/journal/app/lib/auth.server.ts (register + magic-link create paths)
Verified: the API returns distinguishable messages — "Email already in use" vs "Username already taken", and magic-link create throws "No account found for this email". An attacker can probe which emails/usernames are registered.
Calibrated to Low: username availability is intentionally visible at registration anyway, and this is a privacy/info-leak issue, not an account-takeover one.
apps/journal/app/lib/federation*.server.ts · apps/journal/app/jobs/poll-remote-*.ts
Verified: Fedify verifies HTTP Signatures on inbox listeners by default; allowPrivateAddress is gated behind an env flag used only in e2e; private profiles don't federate (checked in every dispatcher); inbox replay is guarded via the Postgres KV store. The "unsigned activities accepted" alarm does not hold.
/users/:u/inbox is rejected — so a future config change can't silently disable verification.X-Forwarded-For Lowapps/journal/app/lib/rate-limit.server.ts
Map. Correct and safe for the single-instance flagship; horizontal scaling silently weakens every limit (each instance has its own counters).clientIp() trusts the first X-Forwarded-For hop. Safe behind Caddy today (the app ports aren't published); becomes spoofable the moment the container is exposed directly.Both are documented assumptions in the code, not oversights — recorded here so the assumption stays visible when the topology changes.
XFF[0].apps/journal/app/lib/crypto.server.ts · connected-services/providers/komoot/* · api.sync.komoot.connect.ts
Verified: the Komoot web-login password is encrypted at rest with AES-256-GCM, random 12-byte IV per encryption, scrypt-derived key. It must be reversibly stored because re-login replays it — that's inherent to web-login providers (ADR-recorded), not a flaw. The cipher usage is correct.
scrypt cost params (N, r, p) so a future Node default change can't alter derivation.Credentials type off the Record<string, unknown> catch-all so a future kind can't accidentally store a secret in the clear.sql.identifier().safeReturnTo() blocks open redirects (local paths only, no //).requireSecret() refuses to boot prod on a dev-fallback secret..env gitignored and never in history.sendDefaultPii: false, no session replay.consumed_jwt_jti, atomic ON CONFLICT) and single-use magic tokens (atomic UPDATE … RETURNING) — both race-proof.E2E env, never set in prod compose.unsafe-eval); Garmin webhook host allowlist; app ports unpublished behind Caddy; deploy secrets SOPS-encrypted, not echoed.| # | Finding | Sev | Do |
|---|---|---|---|
| 1 | SSRF via Planner session callback URL | High | Apply validateFetchUrl at create + fetch |
| 2 | Upload content-type / filename / ownership | Medium | Allowlist type, sanitize key, check owner |
| 3 | OAuth callback logs raw exception | Low | Log redacted message only |
| 4 | Caddy admin on 0.0.0.0:2019 | Low | Bind to localhost / admin off |
| 5 | User enumeration via auth errors | Low | Generic responses on the email paths |
| 6 | Federation tests + fetch caps | Low | Add signature-rejection test, size cap |
| 7 | In-process rate limit / XFF trust | Low | Shared store before scaling out |
| 8 | Komoot credential edges | Info | Encrypt email, pin scrypt params |
No Critical issues. One High, externally-reachable and unauthenticated — fix first. Everything else is hardening on an already-solid base. Four scanner "Critical/High/Medium" alarms were verified false and dropped; trust the code, not the raw sweep.