Phase 19: Local Auth (No-OIDC Mode) #23
@@ -0,0 +1,387 @@
|
||||
---
|
||||
phase: 19-local-auth-no-oidc-mode
|
||||
reviewed: 2026-06-17T00:00:00Z
|
||||
depth: deep
|
||||
files_reviewed: 39
|
||||
files_reviewed_list:
|
||||
- apps/api/scripts/reset-admin.ts
|
||||
- apps/api/src/auth/devBypass.ts
|
||||
- apps/api/src/auth/linkOidc.ts
|
||||
- apps/api/src/auth/localAuthMiddleware.ts
|
||||
- apps/api/src/auth/localCredentials.ts
|
||||
- apps/api/src/auth/localSession.ts
|
||||
- apps/api/src/auth/middleware.ts
|
||||
- apps/api/src/db/migrations/0003_warm_deathstrike.sql
|
||||
- apps/api/src/db/schema.ts
|
||||
- apps/api/src/index.ts
|
||||
- apps/api/src/lib/bootGuards.ts
|
||||
- apps/api/src/routes/admin.ts
|
||||
- apps/api/src/routes/authMode.ts
|
||||
- apps/api/src/routes/localAuth.ts
|
||||
- apps/api/src/routes/me.ts
|
||||
- apps/api/tests/auth/localAuthMiddleware.test.ts
|
||||
- apps/api/tests/auth/localCredentials.test.ts
|
||||
- apps/api/tests/auth/localSession.test.ts
|
||||
- apps/api/test/setup.ts
|
||||
- apps/api/tests/lib/requireAdmin.test.ts
|
||||
- apps/api/tests/routes/admin.test.ts
|
||||
- apps/api/tests/routes/authMode.test.ts
|
||||
- apps/api/tests/routes/lists.test.ts
|
||||
- apps/api/tests/routes/localAuth.test.ts
|
||||
- apps/api/tests/routes/me.test.ts
|
||||
- apps/api/tests/routes/push.test.ts
|
||||
- apps/api/tests/routes/setup.test.ts
|
||||
- apps/pwa/e2e/global-setup.ts
|
||||
- apps/pwa/e2e/login.spec.ts
|
||||
- apps/pwa/src/api/client.ts
|
||||
- apps/pwa/src/App.test.tsx
|
||||
- apps/pwa/src/App.tsx
|
||||
- apps/pwa/src/components/BrandSlot.tsx
|
||||
- apps/pwa/src/components/InstructionSheet.test.tsx
|
||||
- apps/pwa/src/components/SettingsSheet.tsx
|
||||
- apps/pwa/src/routes/AdminPage.tsx
|
||||
- apps/pwa/src/routes/LoginPage.tsx
|
||||
- apps/pwa/src/styles/tokens.css
|
||||
- .gitea/workflows/ci.yml
|
||||
- scripts/generate-secrets.mjs
|
||||
findings:
|
||||
critical: 4
|
||||
blocker: 4
|
||||
warning: 7
|
||||
info: 4
|
||||
total: 15
|
||||
status: issues_found
|
||||
---
|
||||
|
||||
# Phase 19: Code Review Report
|
||||
|
||||
**Reviewed:** 2026-06-17
|
||||
**Depth:** deep
|
||||
**Files Reviewed:** 39 (auth source + routes + PWA + CI)
|
||||
**Status:** issues_found
|
||||
|
||||
## Summary
|
||||
|
||||
Phase 19 adds a local username/password authentication mode alongside the existing
|
||||
OIDC flow. The cryptographic core (scrypt PHC hashing, constant-time compare, dummy-hash
|
||||
timing defense, HS256 session JWT with boot guards) is implemented carefully and is sound.
|
||||
The middleware chain (`devAuthBypass → devSessionCookie → localAuthMiddleware → OIDC guard`)
|
||||
and the admin-privilege boundary (`requireAdmin` DB-enforced on every `/api/admin/*` request)
|
||||
are correct.
|
||||
|
||||
The serious problems are at the **client↔server API contract boundary** — the exact place
|
||||
a deep cross-file review is meant to catch. Three Phase-19 client functions in
|
||||
`apps/pwa/src/api/client.ts` disagree with their server routes on field names, response
|
||||
shape, or status-code handling, so the corresponding features (OIDC-link, admin
|
||||
create-member, and self-service password change error handling) are broken end-to-end
|
||||
despite each side individually passing its own unit tests. There is also an
|
||||
authentication availability defect: the per-IP login lockout is mislabeled as
|
||||
"account locked," is global, and never expires — a single attacker IP can permanently
|
||||
deny login for the whole household with no self-recovery path.
|
||||
|
||||
## Critical Issues
|
||||
|
||||
### CR-01: OIDC-link flow is broken — client/server response-shape mismatch
|
||||
|
||||
**File:** `apps/pwa/src/api/client.ts:226-238` and `apps/api/src/routes/me.ts:301-341`
|
||||
**Issue:** `fetchLinkOidc()` reads `result.redirectUrl` and its return type is
|
||||
`{ redirectUrl: string }`. The server's `POST /api/me/link-oidc` returns
|
||||
`{ signedState, authorizationUrl }` — there is no `redirectUrl` key. The caller
|
||||
(Surface 13 "Link OIDC") will navigate to `undefined`, so the entire OIDC-link feature
|
||||
(AUTH-LOCAL-10) cannot work in the browser. Each side's own unit tests pass because
|
||||
neither test crosses the boundary. Additionally `authorizationUrl` can legitimately be
|
||||
`null` (OIDC unconfigured), which the client type does not model.
|
||||
**Fix:** Make the contract agree. Either return `redirectUrl` from the server, or read
|
||||
`authorizationUrl` on the client and handle `null`:
|
||||
```ts
|
||||
export async function fetchLinkOidc(): Promise<{ authorizationUrl: string | null }> {
|
||||
// ...
|
||||
return res.json() as Promise<{ signedState: string; authorizationUrl: string | null }>;
|
||||
}
|
||||
// caller:
|
||||
const { authorizationUrl } = await fetchLinkOidc();
|
||||
if (authorizationUrl) window.location.href = authorizationUrl;
|
||||
```
|
||||
|
||||
### CR-02: Admin "create member" always fails — request field-name mismatch
|
||||
|
||||
**File:** `apps/pwa/src/api/client.ts:176-194` and `apps/api/src/routes/admin.ts:123-133`
|
||||
**Issue:** `fetchCreateMember` POSTs `{ displayName, username, password }`. The server's
|
||||
`createMemberSchema` requires `{ displayName, username, initialPassword }`. The `password`
|
||||
field is ignored and `initialPassword` is missing, so Zod validation fails and the route
|
||||
returns `400 { error: 'Invalid request' }` (via `noEchoHook`) for *every* valid admin
|
||||
attempt to create a local member (AUTH-LOCAL-07). The Admin UI maps a non-409 error to
|
||||
the generic "Something went wrong" banner, so the admin can never create an account.
|
||||
**Fix:** Send the field the server expects:
|
||||
```ts
|
||||
body: JSON.stringify({
|
||||
displayName: body.displayName,
|
||||
username: body.username,
|
||||
initialPassword: body.password,
|
||||
}),
|
||||
```
|
||||
|
||||
### CR-03: Self-service password change logs the user out on a wrong current password
|
||||
|
||||
**File:** `apps/pwa/src/api/client.ts:148-165` and `apps/api/src/routes/me.ts:233-261`
|
||||
**Issue:** `fetchChangePassword` treats `res.status === 401` as `SessionExpiredError`,
|
||||
which the global MutationCache handler interprets as "session expired → arm the
|
||||
re-auth interstitial / redirect to login." But `POST /api/me/password` returns **401**
|
||||
`{ error: 'Current password incorrect' }` when the supplied current password is wrong
|
||||
(me.ts:259-260). So a user who simply mistypes their current password is forcibly logged
|
||||
out instead of seeing "current password incorrect." The client doc comment even claims
|
||||
"401 → wrong current password" while the code routes 401 to `SessionExpiredError`. The
|
||||
documented 422 validation branch also never fires — the server returns 400 (noEchoHook)
|
||||
or 404, not 422.
|
||||
**Fix:** Distinguish auth-expiry from an in-app 401. Have the route return a distinct
|
||||
status for "wrong current password" (e.g. 403 or a body code), and branch on it client-side
|
||||
before treating 401 as session expiry:
|
||||
```ts
|
||||
if (res.status === 401) {
|
||||
const body = await res.json().catch(() => ({}));
|
||||
if (body?.error === 'Current password incorrect') throw new Error('wrong-current');
|
||||
throw new SessionExpiredError();
|
||||
}
|
||||
```
|
||||
|
||||
### CR-04: Login lockout is per-IP, global, permanent, and unrecoverable
|
||||
|
||||
**File:** `apps/api/src/routes/localAuth.ts:55-140`
|
||||
**Issue:** Multiple correctness/availability defects in one mechanism:
|
||||
1. The `loginAttempts` map is keyed by **IP**, yet the 423 response and the PWA banner say
|
||||
"This account is temporarily locked." It is neither account-scoped nor temporary.
|
||||
2. Once `count >= LOCKOUT_FAILURES (10)`, `lockedOut` is set permanently. The only documented
|
||||
clear path is "admin password reset" — but no admin route ever clears `loginAttempts`
|
||||
(admin.ts reset-password updates the hash, not the in-memory map). So **the lockout is
|
||||
genuinely unrecoverable without a process restart**.
|
||||
3. Because it is per-IP and all household traffic arrives via the Pangolin tunnel with the
|
||||
same `X-Forwarded-For` first hop, one bad actor (or one user fat-fingering 10 times) can
|
||||
lock out **every** member at that egress IP. This is a self-inflicted DoS on a 2-person
|
||||
household whose entire reason for existing is low-friction access.
|
||||
4. `X-Forwarded-For` is attacker-controllable on any request that does not pass through the
|
||||
trusted proxy; an attacker can rotate the header to get unlimited fresh rate-limit
|
||||
buckets, defeating the brute-force defense entirely while still being able to lock
|
||||
*other* identities by spoofing their IP if it were ever known.
|
||||
**Fix:** Re-scope the limiter to the submitted username (not IP), make the 423 lockout
|
||||
expire on a timer (or actually wire admin reset to clear it), correct the banner copy, and
|
||||
only trust `X-Forwarded-For` when the request demonstrably came from the known proxy
|
||||
(or use the leftmost-trusted hop). At minimum, give the lockout a TTL so a restart is not
|
||||
required:
|
||||
```ts
|
||||
// derive key from the validated username, and expire lockout after N minutes
|
||||
const LOCKOUT_TTL_MS = 15 * 60 * 1000;
|
||||
if (attempt?.lockedOut && Date.now() < attempt.lockedUntil) { /* 423 */ }
|
||||
```
|
||||
|
||||
## Blockers
|
||||
|
||||
### BL-01: `devSessionCookieMiddleware` issues a session for user id=1 without verifying the user exists or the secret is strong
|
||||
|
||||
**File:** `apps/api/src/auth/devBypass.ts:102-131` and `apps/api/src/lib/bootGuards.ts:53-66`
|
||||
**Issue:** In dev-bypass mode the boot guard `assertLocalSessionSecretSet()` is *skipped*
|
||||
entirely (returns early when `DEV_AUTH_BYPASS==='true'`). `devSessionCookieMiddleware` then
|
||||
only checks that `LOCAL_SESSION_SECRET` is *present* (truthy), not that it is ≥32 chars, and
|
||||
mints a real, fully-valid `local-session` JWT for `DEV_USER.id (=1)`. The CI sets a weak
|
||||
fixed secret `'dev-secret-change-me-0000000000000000'`. Any cookie minted under bypass is a
|
||||
genuine, signature-valid session token for user 1 — if that same weak/known secret is ever
|
||||
present in a non-bypass environment (e.g. an operator copies the dev compose), forged
|
||||
sessions are trivial. The hard `NODE_ENV==='production'` guard mitigates the worst case, but
|
||||
this is a latent footgun: the boot guard's length check is the documented defense and it is
|
||||
bypassed here.
|
||||
**Fix:** Apply the same `secret.length >= 32` floor inside `devSessionCookieMiddleware`
|
||||
before issuing, and emit a loud warning if the dev secret is the placeholder value. Do not
|
||||
treat "present" as "safe."
|
||||
|
||||
### BL-02: Logout cannot clear the cookie in non-production (attribute mismatch)
|
||||
|
||||
**File:** `apps/api/src/auth/localSession.ts:53-59` vs `97-106`
|
||||
**Issue:** `issueLocalSessionCookie` sets `secure: process.env.NODE_ENV === 'production'`
|
||||
(i.e. `secure:false` in dev/test over HTTP). `clearLocalSessionCookie` hard-codes
|
||||
`secure: true`. Browsers require the `Secure` attribute on a deletion cookie to match the
|
||||
context: over plain HTTP a `Secure` delete-cookie is rejected, so `POST /local/logout`
|
||||
returns 200 but the `local-session` cookie is **not actually cleared** in any non-HTTPS
|
||||
deployment (local dev, and any HTTP-only self-host). The user appears logged in after
|
||||
"logout." The inline comment acknowledges the mismatch but waves it away with "logout should
|
||||
happen over HTTPS" — that is an unsafe assumption for a self-hosted app that explicitly
|
||||
supports private-IP/HTTP internal access.
|
||||
**Fix:** Mirror the issue-time logic on delete:
|
||||
```ts
|
||||
deleteCookie(c, COOKIE_NAME, {
|
||||
path: '/', httpOnly: true,
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
sameSite: 'Lax',
|
||||
});
|
||||
```
|
||||
|
||||
### BL-03: OIDC-link binding swallows `processOAuthCallback` failure and can bind on a stale/blank identity
|
||||
|
||||
**File:** `apps/api/src/index.ts:57-101`
|
||||
**Issue:** In the `/callback` link path the code calls `processOAuthCallback(c)` then
|
||||
`getAuth(c)` and binds whatever `iss`/`sub` it finds to `linkUserId`. Two problems:
|
||||
(1) `iss`/`sub` fall back to `''` (`auth.iss ?? ''`, `auth.sub ?? ''`); if `getAuth` returns
|
||||
a partially-populated session, `linkOidcToUser(linkUserId, '', '')` will write
|
||||
`oidc_iss=''`/`oidc_sub=''` onto the user and **delete their local_credentials** — locking
|
||||
them out of both auth methods. (2) `linkUserId` comes from a JWT signed with
|
||||
`LOCAL_SESSION_SECRET`, but nothing verifies the *currently authenticated session* matches
|
||||
`linkUserId`; the signed-state CSRF defense assumes the state can only have been produced by
|
||||
`POST /api/me/link-oidc`, but the callback binds to `linkUserId` regardless of who completes
|
||||
the OIDC login. An attacker who can get a victim to complete an OIDC login while replaying a
|
||||
captured (still-valid, 10-min) link state binds the *attacker's* OIDC identity to the
|
||||
*victim's* account — account takeover.
|
||||
**Fix:** Reject the bind unless `iss` and `sub` are both non-empty, and cross-check that the
|
||||
OIDC identity being bound is the one the initiating user intended (e.g. require the
|
||||
post-callback session's subject to be confirmed by the user, or bind only when the
|
||||
initiating local session is still present and matches `linkUserId`). Never call
|
||||
`linkOidcToUser` with empty iss/sub.
|
||||
|
||||
### BL-04: `localAuthMiddleware` fabricates `oidcSub` collisions for local users
|
||||
|
||||
**File:** `apps/api/src/auth/localAuthMiddleware.ts:88-94` and `apps/api/src/db/schema.ts:60-64`
|
||||
**Issue:** When populating context for a local user with null OIDC fields, the middleware
|
||||
substitutes `oidcIss: 'local'` and `oidcSub: String(row.id)`. This is only a context shape
|
||||
and is not persisted by the middleware — but `me.ts:resolveUserId` (the OIDC path) and
|
||||
`upsertUser` key identity on `iss+sub`, and the `users` table has
|
||||
`unique('uniq_oidc_identity').on(oidcIss, oidcSub)`. If any code path ever upserts using the
|
||||
context's `('local', String(id))` pair (e.g. a future call to `upsertUser` with these
|
||||
values), two local users would deterministically collide or a local user could shadow a real
|
||||
OIDC identity whose `(iss,sub)` happened to equal `('local','<n>')`. The fabricated values
|
||||
leak a synthetic identity namespace that overlaps the real one.
|
||||
**Fix:** Keep the context `oidcIss/oidcSub` as `null` for local users (widen the
|
||||
`ContextVariableMap` `user` type to allow null) rather than inventing `'local'`/`String(id)`
|
||||
sentinels that share a uniqueness domain with real OIDC identities.
|
||||
|
||||
## Warnings
|
||||
|
||||
### WR-01: `reset-admin.ts` interpolates the username into a log line and trusts `--password ''`
|
||||
|
||||
**File:** `apps/api/scripts/reset-admin.ts:60-66, 123, 133`
|
||||
**Issue:** Two issues. (1) The arg parser treats any token starting with `--` as a new flag,
|
||||
so `--password --foo` yields `password=''`; combined with the dry-run branch the validation
|
||||
is loose. More importantly a password that legitimately begins with `--` (or is the empty
|
||||
string) is silently coerced to `''`. (2) The username is interpolated directly into
|
||||
`console.log(... username="${username}")`; while not an injection into SQL (queries are
|
||||
parameterized — good), logging the username is a minor info disclosure for a break-glass tool
|
||||
and inconsistent with the password-never-logged contract.
|
||||
**Fix:** Parse `--password=value` and `--password value` explicitly; do not infer empty
|
||||
strings from a following flag. Avoid echoing the username, or document it as acceptable.
|
||||
|
||||
### WR-02: `me.ts` builds the OIDC authorization URL with a hardcoded Authelia path
|
||||
|
||||
**File:** `apps/api/src/routes/me.ts:331`
|
||||
**Issue:** `new URL(`${issuer}/api/oidc/authorization`)` hardcodes Authelia's authorization
|
||||
endpoint path. The project's stated design is to discover endpoints via
|
||||
`/.well-known/openid-configuration` (the whole reason `@hono/oidc-auth` is used). Any
|
||||
non-Authelia or differently-mounted provider will get a wrong URL. This is a latent
|
||||
correctness bug that compounds CR-01.
|
||||
**Fix:** Resolve the authorization endpoint from the discovery document rather than assuming
|
||||
`/api/oidc/authorization`.
|
||||
|
||||
### WR-03: `scryptSync` blocks the event loop on the login hot path
|
||||
|
||||
**File:** `apps/api/src/auth/localCredentials.ts:42-53, 71-90` and `localAuth.ts:127-129`
|
||||
**Issue:** `verifyPassword` always runs `scryptSync` (N=16384) synchronously, including the
|
||||
dummy-hash branch on every failed/unknown login. The file comment justifies this for a
|
||||
2-person household, but combined with the per-IP rate limiter and the always-run dummy hash,
|
||||
a burst of unauthenticated `POST /local/login` requests can pin the single Node event loop
|
||||
(each scrypt is ~tens of ms of blocking CPU) and stall *all* other API traffic — a cheap
|
||||
unauthenticated DoS. The rate limiter does not protect this because the scrypt runs *before*
|
||||
the failure counter is consulted on the dummy path for new IPs.
|
||||
**Fix:** Use `promisify(scrypt)` (async) so hashing does not block the loop, as the comment
|
||||
itself suggests. This keeps the timing-defense property while preventing loop starvation.
|
||||
|
||||
### WR-04: `authMode` / OIDC-enabled detection diverges across three files
|
||||
|
||||
**File:** `apps/api/src/routes/authMode.ts:33-49`, `apps/api/src/auth/middleware.ts:62-114`,
|
||||
`apps/api/src/routes/me.ts:323-328`
|
||||
**Issue:** Three independent notions of "is OIDC configured": `authMode` checks
|
||||
`OIDC_ISSUER` env OR `app_config.oidc_issuer`; the fallback middleware injects
|
||||
issuer/client-id/external-url from app_config; but `me.ts` link-oidc only builds a URL when
|
||||
`OIDC_ISSUER && OIDC_CLIENT_ID && OIDC_REDIRECT_URI` are all in **env** (it never consults
|
||||
app_config). So a wizard-configured-but-not-restarted instance reports `oidcEnabled:true`
|
||||
from `/api/auth/mode`, shows the "Link OIDC" button, but `link-oidc` returns
|
||||
`authorizationUrl:null` — inconsistent state surfaced to the user.
|
||||
**Fix:** Centralize the "OIDC configured" resolution (env-or-app_config) in one helper and
|
||||
use it in all three sites.
|
||||
|
||||
### WR-05: `noEchoHook` return value is ignored by `@hono/zod-validator` in one of two styles
|
||||
|
||||
**File:** `apps/api/src/routes/localAuth.ts:39-43`, `apps/api/src/routes/admin.ts:74-78`,
|
||||
`apps/api/src/routes/me.ts:178-182`
|
||||
**Issue:** The hook signature is `(result, c)` and returns `c.json(...)` only on failure. This
|
||||
relies on zValidator short-circuiting when the hook returns a Response. That contract holds
|
||||
for current `@hono/zod-validator`, but the hook does not `return` anything on success and does
|
||||
not assert `result.success` narrows the type, so a future validator version that requires an
|
||||
explicit early-return-on-success, or that passes through when the hook returns `undefined`,
|
||||
would silently start echoing Zod errors (the exact T-19-14 leak this guards against). It is
|
||||
correct today but fragile and untested for the pass-through case.
|
||||
**Fix:** Add a focused test asserting that a malformed body never includes `received`/the
|
||||
submitted value for each hook site (localAuth has one; admin/me password routes should too),
|
||||
and pin the `@hono/zod-validator` version.
|
||||
|
||||
### WR-06: Rate-limit `lockedUntil` is refreshed on every blocked attempt, extending the window indefinitely
|
||||
|
||||
**File:** `apps/api/src/routes/localAuth.ts:97-106, 133-137`
|
||||
**Issue:** On a 429 the code sets `attempt.lockedUntil = Date.now() + RATE_WINDOW_SECS*1000`
|
||||
again, so an attacker who keeps hitting the endpoint perpetually slides the cooldown forward —
|
||||
a legitimate user behind the same IP can never get back in even after pausing, because every
|
||||
attacker request re-arms the window. Coupled with CR-04 this makes the household-wide lock
|
||||
effectively permanent under sustained traffic.
|
||||
**Fix:** Do not extend `lockedUntil` on requests that are themselves rejected by the window;
|
||||
only set it when transitioning from below-threshold to at-threshold.
|
||||
|
||||
### WR-07: `parseInt` member/calendar id accepts trailing garbage
|
||||
|
||||
**File:** `apps/api/src/routes/admin.ts:215-218, 307-311`
|
||||
**Issue:** `parseInt(c.req.param('id'), 10)` returns `12` for `"12abc"` and the `isNaN`
|
||||
guard passes. Not exploitable here (the value is used only in a parameterized `eq`), but it
|
||||
silently accepts malformed ids and could mask client bugs. The `/members/:id/password` and
|
||||
`/calendars/:id/shared` routes both use this pattern.
|
||||
**Fix:** Validate with `Number.isInteger(Number(raw))` or a Zod param schema so `"12abc"` is
|
||||
rejected with 400.
|
||||
|
||||
## Info
|
||||
|
||||
### IN-01: `localSession` `maxAge`/expiry parsing has no validation
|
||||
|
||||
**File:** `apps/api/src/auth/localSession.ts:31`
|
||||
**Issue:** `Number(process.env.LOCAL_SESSION_EXPIRES ?? 86400)` yields `NaN` for a malformed
|
||||
value, producing a JWT with `exp = now + NaN` (→ `NaN`) and a cookie `maxAge: NaN`. Verify
|
||||
behavior is then "always expired" or "never expires" depending on the JWT lib's NaN handling.
|
||||
**Fix:** Coerce and validate: `const n = Number(env); SESSION_MAX_AGE = Number.isFinite(n) && n > 0 ? n : 86400;`
|
||||
|
||||
### IN-02: Duplicated inline scrypt implementation across three locations
|
||||
|
||||
**File:** `apps/api/scripts/reset-admin.ts:45-51`, `.gitea/workflows/ci.yml:307-311`,
|
||||
`apps/api/src/auth/localCredentials.ts:42-53`
|
||||
**Issue:** The PHC scrypt hash is copy-pasted in the reset-admin script, the CI seed step,
|
||||
and the canonical module. If the parameters ever change (the file comment advertises
|
||||
parameter evolution as a feature), these three drift and produce incompatible hashes. The
|
||||
duplication is documented as necessary (cannot import compiled TS from a plain script), but
|
||||
there is no test asserting the three stay in lockstep.
|
||||
**Fix:** Add a test that imports `hashPassword` and asserts a known input round-trips against
|
||||
a hash produced by the inlined parameters, so a parameter change fails CI loudly.
|
||||
|
||||
### IN-03: `loginAttempts` map is unbounded (memory growth)
|
||||
|
||||
**File:** `apps/api/src/routes/localAuth.ts:66`
|
||||
**Issue:** Entries are only removed on a *successful* login for that IP. Spoofed/rotated
|
||||
`X-Forwarded-For` values (see CR-04) accumulate map entries with no eviction, a slow memory
|
||||
leak. Out of strict v1 perf scope, noted because it is reachable by unauthenticated input.
|
||||
**Fix:** Add periodic eviction of entries whose `lockedUntil` is far in the past.
|
||||
|
||||
### IN-04: `me.ts` link-oidc nonce is generated but never persisted/verified
|
||||
|
||||
**File:** `apps/api/src/routes/me.ts:312-320` and `apps/api/src/index.ts:60-74`
|
||||
**Issue:** The signed state carries a `nonce` "to prevent replay," but the `/callback`
|
||||
handler never records or checks the nonce — it only verifies the JWT signature and reads
|
||||
`linkUserId`. A captured state JWT is fully replayable within its 10-minute window (the
|
||||
signature stays valid), so the nonce provides no actual replay protection. This underlies
|
||||
the takeover concern in BL-03.
|
||||
**Fix:** Persist issued nonces (or a single-use jti) and reject a state whose nonce was
|
||||
already consumed, or shorten the window and bind the state to the initiating session cookie.
|
||||
|
||||
---
|
||||
|
||||
_Reviewed: 2026-06-17_
|
||||
_Reviewer: Claude (gsd-code-reviewer)_
|
||||
_Depth: deep_
|
||||
Reference in New Issue
Block a user