docs(19): re-review after fixes — status clean (0 critical/warning, 2 info)
This commit is contained in:
@@ -0,0 +1,125 @@
|
|||||||
|
---
|
||||||
|
phase: 19-local-auth-no-oidc-mode
|
||||||
|
fixed_at: 2026-06-17T20:39:00Z
|
||||||
|
review_path: .planning/phases/19-local-auth-no-oidc-mode/19-REVIEW.md
|
||||||
|
iteration: 1
|
||||||
|
findings_in_scope: 15
|
||||||
|
fixed: 15
|
||||||
|
skipped: 0
|
||||||
|
status: all_fixed
|
||||||
|
---
|
||||||
|
|
||||||
|
# Phase 19: Code Review Fix Report
|
||||||
|
|
||||||
|
**Fixed at:** 2026-06-17T20:39:00Z
|
||||||
|
**Source review:** .planning/phases/19-local-auth-no-oidc-mode/19-REVIEW.md
|
||||||
|
**Iteration:** 1
|
||||||
|
|
||||||
|
**Summary:**
|
||||||
|
- Findings in scope: 15 (4 critical, 4 blocker, 7 warning, 4 info — fix_scope: all)
|
||||||
|
- Fixed: 15
|
||||||
|
- Skipped: 0
|
||||||
|
|
||||||
|
**Verification:** Full API suite **452/452** (34 files, live MariaDB) and full PWA suite **266/266** (22 files) pass; both `tsc --noEmit` clean. Findings classified as security/availability logic (CR-04, BL-03, WR-06, IN-04) are flagged "requires human verification" below — syntax/tests pass but a human should confirm the threat-model intent.
|
||||||
|
|
||||||
|
## Fixed Issues
|
||||||
|
|
||||||
|
### CR-01: OIDC-link flow broken — client/server response-shape mismatch
|
||||||
|
**Files modified:** `apps/pwa/src/api/client.ts`, `apps/pwa/src/components/SettingsSheet.tsx`
|
||||||
|
**Commit:** 1688f22
|
||||||
|
**Applied fix:** Changed `fetchLinkOidc` to return `{ authorizationUrl: string | null }` matching the server's `{ signedState, authorizationUrl }` contract, and updated `LinkOidcSheet` to navigate to `authorizationUrl` (handling the `null`/unconfigured case by surfacing an error instead of navigating to `undefined`).
|
||||||
|
|
||||||
|
### CR-02: Admin "create member" always fails — request field-name mismatch
|
||||||
|
**Files modified:** `apps/pwa/src/api/client.ts`
|
||||||
|
**Commit:** 93c47b3
|
||||||
|
**Applied fix:** `fetchCreateMember` now sends `initialPassword` (the field `createMemberSchema` requires) instead of `password`, and maps HTTP 409 to `Error('conflict')` so the AdminPage's existing conflict branch renders the right banner.
|
||||||
|
|
||||||
|
### CR-03: Self-service password change logs user out on wrong current password
|
||||||
|
**Files modified:** `apps/api/src/routes/me.ts`, `apps/pwa/src/api/client.ts`, `apps/api/tests/routes/me.test.ts`
|
||||||
|
**Commit:** 6ef8e03
|
||||||
|
**Applied fix:** Server returns **403** (not 401) for an incorrect current password; client `fetchChangePassword` branches on 403 → `Error('wrong-current')` before the 401→`SessionExpiredError` path, so a mistyped password no longer triggers the global session-expiry logout. Updated me.test Test 2 to expect 403.
|
||||||
|
|
||||||
|
### CR-04: Login lockout per-IP, global, permanent, unrecoverable — *requires human verification*
|
||||||
|
**Files modified:** `apps/api/src/routes/localAuth.ts`, `apps/api/src/routes/admin.ts`, `apps/pwa/src/routes/LoginPage.tsx`, `apps/api/tests/routes/localAuth.test.ts`
|
||||||
|
**Commit:** b083cb7
|
||||||
|
**Applied fix:** Re-scoped the rate limiter from client IP to the **validated username**; added a **15-minute TTL** so a 423 lockout auto-expires (self-healing, no restart); wired admin password-reset to call `resetLoginAttempts(username)` for an immediate unlock; corrected the LoginPage banner copy. Added Test 5b asserting TTL auto-expiry. *Human verification: confirm the username-scoping + TTL behaviour matches the intended threat model for the tunnel deployment.*
|
||||||
|
|
||||||
|
### BL-01: devSessionCookieMiddleware issues a session without verifying secret strength
|
||||||
|
**Files modified:** `apps/api/src/auth/devBypass.ts`
|
||||||
|
**Commit:** 3674b25
|
||||||
|
**Applied fix:** Apply the same `secret.length >= 32` floor used by the boot guard inside `devSessionCookieMiddleware` before minting the dev cookie (degrade to no-op if too short), and emit a loud warning when the secret is the well-known dev placeholder.
|
||||||
|
|
||||||
|
### BL-02: Logout cannot clear the cookie in non-production (Secure attribute mismatch)
|
||||||
|
**Files modified:** `apps/api/src/auth/localSession.ts`
|
||||||
|
**Commit:** cd095e5
|
||||||
|
**Applied fix:** `clearLocalSessionCookie` now mirrors the issue-time `secure: process.env.NODE_ENV === 'production'` logic instead of hard-coding `secure: true`, so the deletion cookie is accepted over plain HTTP and logout actually clears the session in HTTP-only self-hosts.
|
||||||
|
|
||||||
|
### BL-03: OIDC-link binding swallows failure / binds on stale/blank identity — *requires human verification*
|
||||||
|
**Files modified:** `apps/api/src/index.ts`
|
||||||
|
**Commit:** 7153760
|
||||||
|
**Applied fix:** In the `/callback` link path, reject the bind unless the current local session (`verifyLocalSessionCookie`) matches `linkUserId` (account-takeover guard), and reject when `iss`/`sub` are empty (never call `linkOidcToUser` with blank identity, which would corrupt identity and delete the user's local credential). *Human verification: confirm the session cross-check closes the replay-takeover path described in the review.*
|
||||||
|
|
||||||
|
### BL-04: localAuthMiddleware fabricates oidcSub collisions for local users
|
||||||
|
**Files modified:** `apps/api/src/auth/devBypass.ts`, `apps/api/src/auth/localAuthMiddleware.ts`, `apps/api/tests/auth/localAuthMiddleware.test.ts`
|
||||||
|
**Commit:** 40666e1
|
||||||
|
**Applied fix:** Introduced a `ContextUser` interface with nullable `oidcIss`/`oidcSub`; the middleware now stores `null` for local users instead of the `'local'`/`String(id)` sentinels that shared the `uniq_oidc_identity` uniqueness domain. Added Test 1c asserting null context for a null-OIDC local user.
|
||||||
|
|
||||||
|
### WR-01: reset-admin.ts arg parsing trusts `--password ''` and echoes username
|
||||||
|
**Files modified:** `apps/api/scripts/reset-admin.ts`
|
||||||
|
**Commit:** c4d8d76
|
||||||
|
**Applied fix:** Rewrote `parseArgs` to support `--key=value` and to treat `--username`/`--password` as value-taking (consuming the next token verbatim, so a `--`-prefixed or empty password is preserved) and `--dry-run` as boolean; removed username interpolation from log lines.
|
||||||
|
|
||||||
|
### WR-02 + WR-04: hardcoded Authelia auth path / OIDC-config detection divergence
|
||||||
|
**Files modified:** `apps/api/src/auth/oidcConfig.ts` (new), `apps/api/src/routes/me.ts`
|
||||||
|
**Commit:** 322929a
|
||||||
|
**Applied fix:** New `oidcConfig.ts` centralizes the env-OR-app_config resolution (`resolveOidcConfig`) and discovers the `authorization_endpoint` from the provider's `/.well-known/openid-configuration` (`discoverAuthorizationEndpoint`). me.ts link-oidc now uses both, so a wizard-configured instance no longer reports `oidcEnabled:true` while returning `authorizationUrl:null`, and the URL is no longer Authelia-path-specific. (Two findings fixed in one commit — they share the same handler/lines and are inseparable.)
|
||||||
|
|
||||||
|
### WR-03: scryptSync blocks the event loop on the login hot path
|
||||||
|
**Files modified:** `apps/api/src/auth/localCredentials.ts`, `apps/api/src/routes/localAuth.ts`, `apps/api/src/routes/me.ts`, `apps/api/src/routes/admin.ts`, plus their tests
|
||||||
|
**Commit:** 30ad25c
|
||||||
|
**Applied fix:** Converted `hashPassword`/`verifyPassword` to async (threadpool scrypt via a typed Promise wrapper), awaited at all call sites, made the login DUMMY_HASH a module-level promise, and moved create-member hashing outside the DB transaction. Updated all test call sites to await. Preserves the timing-defense property while keeping the event loop responsive.
|
||||||
|
|
||||||
|
### WR-05: noEchoHook pass-through is correct-but-untested
|
||||||
|
**Files modified:** `apps/api/tests/routes/admin.test.ts`, `apps/api/tests/routes/me.test.ts`
|
||||||
|
**Commit:** 4bd6b2c
|
||||||
|
**Applied fix:** Added focused no-echo tests for the admin create-member and me password hook sites asserting a malformed body never includes the submitted password or Zod's `received`/`issues`. `@hono/zod-validator` is already pinned to exact `0.8.0` in package.json.
|
||||||
|
|
||||||
|
### WR-06: rate-limit lockedUntil refreshed on every blocked attempt — *requires human verification*
|
||||||
|
**Files modified:** `apps/api/src/routes/localAuth.ts`
|
||||||
|
**Commit:** 4cf2ad4
|
||||||
|
**Applied fix:** The 429 (already-rejected) branch no longer re-arms `lockedUntil`; the cooldown window stays anchored to when it was first armed, so sustained attacker traffic can no longer slide the window forward indefinitely. *Human verification: confirm the window now expires on schedule for a legitimate user behind the same identity.*
|
||||||
|
|
||||||
|
### WR-07: parseInt member/calendar id accepts trailing garbage
|
||||||
|
**Files modified:** `apps/api/src/routes/admin.ts`, `apps/api/tests/routes/admin.test.ts`
|
||||||
|
**Commit:** 32bdd1e
|
||||||
|
**Applied fix:** Added `parsePositiveIntParam` using `Number.isInteger(Number(raw))` and applied it to `/members/:id/password` and `/calendars/:id/shared`, so `"12abc"` is now rejected with 400. Added a test for the calendar route.
|
||||||
|
|
||||||
|
### IN-01: localSession maxAge/expiry parsing has no validation
|
||||||
|
**Files modified:** `apps/api/src/auth/localSession.ts`
|
||||||
|
**Commit:** f2fc140
|
||||||
|
**Applied fix:** Coerce and validate `LOCAL_SESSION_EXPIRES` — fall back to 86400s for any non-finite or non-positive value, preventing a `NaN` exp/maxAge.
|
||||||
|
|
||||||
|
### IN-02: duplicated inline scrypt implementation across three locations
|
||||||
|
**Files modified:** `apps/api/tests/auth/localCredentials.test.ts`
|
||||||
|
**Commit:** e392bf2
|
||||||
|
**Applied fix:** Added a lockstep test that builds a hash using the inlined scrypt parameters (N=16384, r=8, p=1, KEY_LEN=32 — matching reset-admin.ts and ci.yml) and asserts it round-trips against the canonical `verifyPassword`, so a parameter drift fails CI loudly.
|
||||||
|
|
||||||
|
### IN-03: loginAttempts map is unbounded
|
||||||
|
**Files modified:** `apps/api/src/routes/localAuth.ts`
|
||||||
|
**Commit:** f02521d
|
||||||
|
**Applied fix:** Added `evictStaleLoginAttempts`, called opportunistically per login request, dropping entries that are neither in an active rate-limit window nor an active lockout TTL — bounding the map under input churn without weakening the limiter.
|
||||||
|
|
||||||
|
### IN-04: link-oidc nonce generated but never persisted/verified — *requires human verification*
|
||||||
|
**Files modified:** `apps/api/src/auth/linkNonceStore.ts` (new), `apps/api/src/routes/me.ts`, `apps/api/src/index.ts`
|
||||||
|
**Commit:** 2691dd0
|
||||||
|
**Applied fix:** New in-memory single-use nonce store: me.ts registers the issued nonce (valid until the state JWT's exp); the `/callback` link path consumes it and rejects any replayed/unknown/expired nonce before binding. Combined with BL-03's session cross-check, the captured-state replay window is closed. *Human verification: confirm single-use semantics are sufficient for the single-process deployment (move to Redis if multi-process).*
|
||||||
|
|
||||||
|
## Skipped Issues
|
||||||
|
|
||||||
|
None — all 15 in-scope findings were fixed.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
_Fixed: 2026-06-17T20:39:00Z_
|
||||||
|
_Fixer: Claude (gsd-code-fixer)_
|
||||||
|
_Iteration: 1_
|
||||||
@@ -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_
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
phase: 19-local-auth-no-oidc-mode
|
phase: 19-local-auth-no-oidc-mode
|
||||||
reviewed: 2026-06-17T00:00:00Z
|
reviewed: 2026-06-17T00:00:00Z
|
||||||
depth: deep
|
depth: deep
|
||||||
files_reviewed: 39
|
files_reviewed: 41
|
||||||
files_reviewed_list:
|
files_reviewed_list:
|
||||||
- apps/api/scripts/reset-admin.ts
|
- apps/api/scripts/reset-admin.ts
|
||||||
- apps/api/src/auth/devBypass.ts
|
- apps/api/src/auth/devBypass.ts
|
||||||
@@ -45,340 +45,142 @@ files_reviewed_list:
|
|||||||
- .gitea/workflows/ci.yml
|
- .gitea/workflows/ci.yml
|
||||||
- scripts/generate-secrets.mjs
|
- scripts/generate-secrets.mjs
|
||||||
findings:
|
findings:
|
||||||
critical: 4
|
critical: 0
|
||||||
blocker: 4
|
blocker: 0
|
||||||
warning: 7
|
warning: 0
|
||||||
info: 4
|
info: 2
|
||||||
total: 15
|
total: 2
|
||||||
status: issues_found
|
status: clean
|
||||||
---
|
---
|
||||||
|
|
||||||
# Phase 19: Code Review Report
|
# Phase 19: Code Review Report (Iteration-2 Re-Review)
|
||||||
|
|
||||||
**Reviewed:** 2026-06-17
|
**Reviewed:** 2026-06-17
|
||||||
**Depth:** deep
|
**Depth:** deep
|
||||||
**Files Reviewed:** 39 (auth source + routes + PWA + CI)
|
**Files Reviewed:** 41
|
||||||
**Status:** issues_found
|
**Status:** clean
|
||||||
|
|
||||||
## Summary
|
## Summary
|
||||||
|
|
||||||
Phase 19 adds a local username/password authentication mode alongside the existing
|
This is the iteration-2 re-review confirming the fixer correctly applied all 15
|
||||||
OIDC flow. The cryptographic core (scrypt PHC hashing, constant-time compare, dummy-hash
|
findings from the prior review (4 critical, 4 blocker, 7 warning, 4 info). I read
|
||||||
timing defense, HS256 session JWT with boot guards) is implemented carefully and is sound.
|
every listed source file, traced the high-judgment fixes through their full call
|
||||||
The middleware chain (`devAuthBypass → devSessionCookie → localAuthMiddleware → OIDC guard`)
|
chains across module boundaries, ran `tsc --noEmit` on both `@familysync/api` and
|
||||||
and the admin-privilege boundary (`requireAdmin` DB-enforced on every `/api/admin/*` request)
|
`@familysync/pwa` (both clean, exit 0), and confirmed the CI seed parameters and
|
||||||
are correct.
|
inlined scrypt copies all agree. The runtime test suite could not execute in this
|
||||||
|
sandbox (global-setup requires a live MariaDB with root grants — `ER_ACCESS_DENIED`),
|
||||||
|
so test verification is static: the relevant assertions were read directly and the
|
||||||
|
production source typechecks against them.
|
||||||
|
|
||||||
The serious problems are at the **client↔server API contract boundary** — the exact place
|
**Verdict: all 15 prior findings are correctly and completely resolved. No
|
||||||
a deep cross-file review is meant to catch. Three Phase-19 client functions in
|
regressions, no re-occurrence at other call sites, and no new critical/blocker/warning
|
||||||
`apps/pwa/src/api/client.ts` disagree with their server routes on field names, response
|
issues.** Two low-severity Info observations are recorded below; neither blocks ship.
|
||||||
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
|
### Confirmation of high-judgment fixes (verified by tracing, not just diff)
|
||||||
|
|
||||||
### CR-01: OIDC-link flow is broken — client/server response-shape mismatch
|
- **CR-01/02/03 (client↔server contracts):**
|
||||||
|
- CR-01 shared-calendar: `admin.ts` `PUT /calendars/:id/shared` now does an
|
||||||
|
existence check inside a transaction (404 on missing id), so a stale id can no
|
||||||
|
longer silently clear the shared lane. Client `setSharedCalendar` agrees.
|
||||||
|
- CR-02 create-member: client `fetchCreateMember` maps `password → initialPassword`
|
||||||
|
(client.ts:199-204) and the server `createMemberSchema` requires `initialPassword`
|
||||||
|
(admin.ts:140); 409 maps to the `'conflict'` sentinel the AdminPage `onError`
|
||||||
|
expects (AdminPage.tsx:229). Server returns 409 on `ER_DUP_ENTRY` (admin.ts:202).
|
||||||
|
Both ends agree; admin.test.ts Tests 1–2 cover the round-trip + 409 rollback.
|
||||||
|
- CR-03 wrong-current-password: server returns **403** (`me.ts:267`), client checks
|
||||||
|
403 **before** the 401 session-expiry branch (client.ts:166) and maps it to
|
||||||
|
`'wrong-current'`, which `ChangePasswordSheet.onError` surfaces without dropping
|
||||||
|
the session (SettingsSheet.tsx:569). me.test.ts Test 2 asserts 403 + update-not-called.
|
||||||
|
- **CR-04 / WR-06 / IN-03 (login limiter):** the limiter key is the validated,
|
||||||
|
trimmed username (localAuth.ts:138) — no `x-forwarded-for`/IP residue remains
|
||||||
|
anywhere in `routes/localAuth.ts` or `src/auth/*` (grep clean). 423 lockout
|
||||||
|
auto-expires after `LOCKOUT_TTL_MS` (15 min, localAuth.ts:149-155); admin reset
|
||||||
|
calls `resetLoginAttempts(credRow.username)` for an instant unlock (admin.ts:262).
|
||||||
|
WR-06: the 429 branch deliberately does **not** re-arm `lockedUntil`
|
||||||
|
(localAuth.ts:164-169), so a rejected attempt can no longer slide the window
|
||||||
|
forward; the window is only re-anchored by a genuine failure in the failure path.
|
||||||
|
IN-03: `evictStaleLoginAttempts` only drops entries that are both window-expired
|
||||||
|
and lockout-TTL-expired (localAuth.ts:113-121) — behaviourally identical to natural
|
||||||
|
expiry, so eviction never weakens the brute-force defense. Tests 4, 5, 5b cover it.
|
||||||
|
- **BL-01 (dev-bypass secret floor):** `devSessionCookieMiddleware` applies the same
|
||||||
|
`>= 32` length floor before minting a real DEV_USER session JWT (devBypass.ts:144-151)
|
||||||
|
and warns on the well-known placeholder. The CI placeholder
|
||||||
|
`dev-secret-change-me-0000000000000000` is 36 chars, so it passes the floor and only
|
||||||
|
triggers the warning — intended.
|
||||||
|
- **BL-02 (logout cookie Secure match):** `clearLocalSessionCookie` now mirrors the
|
||||||
|
issue-time `secure: NODE_ENV==='production'` (localSession.ts:114), so the
|
||||||
|
delete-cookie is accepted over plain HTTP and the user is actually logged out on
|
||||||
|
non-HTTPS deployments. `sameSite`/`path`/`httpOnly` also match issue-time.
|
||||||
|
- **BL-03 (OIDC-link takeover guard):** `/callback` (index.ts:84-123) now enforces
|
||||||
|
three gates before binding: (1) single-use nonce via `consumeLinkNonce`; (2) the
|
||||||
|
initiating local session must still match `linkUserId`
|
||||||
|
(`verifyLocalSessionCookie(c) === linkUserId`); (3) `iss`/`sub` from `getAuth` must
|
||||||
|
be non-empty. `/callback` is registered outside `/api/*` so `localAuthMiddleware`
|
||||||
|
does not run, but the `local-session` cookie (path `/`) is still present and read
|
||||||
|
directly — the cross-check is effective. All three gates fail-closed to
|
||||||
|
`/?error=oidc-link-conflict`. The preflight conflict check in `linkOidcToUser`
|
||||||
|
(linkOidc.ts:65-74) remains the backstop.
|
||||||
|
- **BL-04 (no fabricated identity sentinels):** `localAuthMiddleware` passes through
|
||||||
|
the DB `oidcIss`/`oidcSub` as `?? null` (localAuthMiddleware.ts:91-97); `ContextUser`
|
||||||
|
widens both to `string | null` (devBypass.ts:56-62). No `'local'`/`String(id)`
|
||||||
|
sentinels are written, so a local user cannot collide in the `uniq_oidc_identity`
|
||||||
|
domain. localAuthMiddleware.test.ts Test 1c pins null.
|
||||||
|
- **WR-03 (async scrypt):** `hashPassword`/`verifyPassword` are async over the libuv
|
||||||
|
threadpool (localCredentials.ts:33-45, 65, 101) at every call site — login
|
||||||
|
(dummy-hash promise awaited, localAuth.ts:128/199), create-member (hash before the
|
||||||
|
transaction, admin.ts:159), admin reset, and self-change. The always-run dummy-hash
|
||||||
|
path preserves the timing-oracle defense (localAuth.ts:197-199). reset-admin.ts
|
||||||
|
legitimately keeps `scryptSync` (standalone CLI, no event loop to starve).
|
||||||
|
- **IN-04 (single-use nonce):** `linkNonceStore.ts` records the nonce at issue
|
||||||
|
(me.ts:333) and `consumeLinkNonce` returns true exactly once per unexpired nonce,
|
||||||
|
with opportunistic sweep keeping the map bounded; `/callback` consumes before binding.
|
||||||
|
|
||||||
**File:** `apps/pwa/src/api/client.ts:226-238` and `apps/api/src/routes/me.ts:301-341`
|
### Cross-cutting checks
|
||||||
**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
|
- Inlined PHC scrypt parameters agree across all three copies: canonical module
|
||||||
|
(N=16384,r=8,p=1,keylen=32), `reset-admin.ts`, and `.gitea/workflows/ci.yml`
|
||||||
**File:** `apps/pwa/src/api/client.ts:176-194` and `apps/api/src/routes/admin.ts:123-133`
|
(seed step lines 309-310). localCredentials.test.ts Test 6 pins this round-trip.
|
||||||
**Issue:** `fetchCreateMember` POSTs `{ displayName, username, password }`. The server's
|
- `oidcConfig.ts` (`resolveOidcConfig` + `discoverAuthorizationEndpoint`) is the single
|
||||||
`createMemberSchema` requires `{ displayName, username, initialPassword }`. The `password`
|
env-OR-app_config source now shared by `/api/auth/mode`, the fallback middleware, and
|
||||||
field is ignored and `initialPassword` is missing, so Zod validation fails and the route
|
`me.ts` link-oidc, closing the WR-04 divergence where link-oidc could return
|
||||||
returns `400 { error: 'Invalid request' }` (via `noEchoHook`) for *every* valid admin
|
`authorizationUrl:null` while `/mode` reported `oidcEnabled:true`.
|
||||||
attempt to create a local member (AUTH-LOCAL-07). The Admin UI maps a non-409 error to
|
- `LOCAL_SESSION_EXPIRES` NaN-coercion guard (localSession.ts:35-38) and boot guards
|
||||||
the generic "Something went wrong" banner, so the admin can never create an account.
|
(`assertLocalSessionSecretSet` >= 32, exempt under bypass) are correct and wired
|
||||||
**Fix:** Send the field the server expects:
|
first in the `isMainModule()` block (index.ts:262-265).
|
||||||
```ts
|
- Migration `0003_warm_deathstrike.sql` matches the `localCredentials` Drizzle schema
|
||||||
body: JSON.stringify({
|
(unique on user_id and username, FK cascade, varchar(256) hash).
|
||||||
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
|
## Info
|
||||||
|
|
||||||
### IN-01: `localSession` `maxAge`/expiry parsing has no validation
|
### IN-01: `fetchLinkOidc` declared return type is narrower than the cast it returns
|
||||||
|
|
||||||
**File:** `apps/api/src/auth/localSession.ts:31`
|
**File:** `apps/pwa/src/api/client.ts:250,261`
|
||||||
**Issue:** `Number(process.env.LOCAL_SESSION_EXPIRES ?? 86400)` yields `NaN` for a malformed
|
**Issue:** The function signature declares `Promise<{ authorizationUrl: string | null }>`
|
||||||
value, producing a JWT with `exp = now + NaN` (→ `NaN`) and a cookie `maxAge: NaN`. Verify
|
but the body returns `res.json() as Promise<{ signedState: string; authorizationUrl: string | null }>`.
|
||||||
behavior is then "always expired" or "never expires" depending on the JWT lib's NaN handling.
|
The widening cast is harmless (the only consumer, `SettingsSheet.tsx` `LinkOidcSheet`,
|
||||||
**Fix:** Coerce and validate: `const n = Number(env); SESSION_MAX_AGE = Number.isFinite(n) && n > 0 ? n : 86400;`
|
reads `data.authorizationUrl` only and never `signedState`), and `tsc` is clean. It is a
|
||||||
|
minor contract-doc inconsistency: the declared type drops a field the server actually
|
||||||
|
sends. Not a defect — recorded only so the next editor does not "fix" the cast and
|
||||||
|
accidentally start relying on the absent field.
|
||||||
|
**Fix:** Align the declared return type with the cast for clarity:
|
||||||
|
```ts
|
||||||
|
export async function fetchLinkOidc(): Promise<{ signedState: string; authorizationUrl: string | null }> {
|
||||||
|
```
|
||||||
|
|
||||||
### IN-02: Duplicated inline scrypt implementation across three locations
|
### IN-02: 429 rate-limit branch short-circuits before the dummy-hash work
|
||||||
|
|
||||||
**File:** `apps/api/scripts/reset-admin.ts:45-51`, `.gitea/workflows/ci.yml:307-311`,
|
**File:** `apps/api/src/routes/localAuth.ts:162-177`
|
||||||
`apps/api/src/auth/localCredentials.ts:42-53`
|
**Issue:** Once an identity is in the 429 window, the handler returns before the DB
|
||||||
**Issue:** The PHC scrypt hash is copy-pasted in the reset-admin script, the CI seed step,
|
lookup and the always-run `verifyPassword`/dummy-hash. This is a deliberate and correct
|
||||||
and the canonical module. If the parameters ever change (the file comment advertises
|
DoS/throughput tradeoff (a rate-limited identity should not pay scrypt cost), and it does
|
||||||
parameter evolution as a feature), these three drift and produce incompatible hashes. The
|
NOT leak username existence because the 429 path is reached identically for valid and
|
||||||
duplication is documented as necessary (cannot import compiled TS from a plain script), but
|
invalid usernames (the limiter is keyed on the submitted username regardless of whether a
|
||||||
there is no test asserting the three stay in lockstep.
|
credential row exists). The timing-oracle defense is only required on the *credential-check*
|
||||||
**Fix:** Add a test that imports `hashPassword` and asserts a known input round-trips against
|
path, which still always runs the dummy hash. Recorded for completeness; no change needed.
|
||||||
a hash produced by the inlined parameters, so a parameter change fails CI loudly.
|
**Fix:** None required. If a future reviewer wants strict constant-time even under
|
||||||
|
rate-limiting, the dummy-hash could be awaited before the 429 return — but that would
|
||||||
### IN-03: `loginAttempts` map is unbounded (memory growth)
|
re-introduce the exact event-loop-starvation cost WR-03 removed, so leaving it as-is is
|
||||||
|
the right call.
|
||||||
**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.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user