Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
108 changes: 108 additions & 0 deletions .agents/inbox/2026-10-27-captcha-proof-must-be-server-issued.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
# Client-side puzzle completion is not a server-verifiable authentication proof

Date: 2026-10-27. Searched the symptom/security indexes and the October 23
security inventory before addressing #183. The existing helper accepted any
six uppercase alphanumeric characters; login/signup also skipped the helper
when a payload was omitted. Correct credentials remained necessary for password
login: this was an abuse-control bypass, not proof of account takeover.

## Reproduction and correction

With Node 22.18.0 / npm 10.8.2 in an owned worktree based on
`ef495eff15ed7aac51e72dc7a1db1bb96d2b3695`:

```sh
npm run test -w @graphdone/server -- tests/captcha.test.ts --maxWorkers=1 --minWorkers=1
# Before: arbitrary code ABC123 was accepted; the new rejection assertion failed.
```

The corrected policy requires proof for production login/signup and for Core
magic-link, forgot-password and reset-password requests in every mode. Missing
proof is allowed only for login/signup in explicit development/test mode to
retain development quick login. Supplied proofs are always verified. Guest
access retains its existing separate policy; OAuth callbacks and email-code
verification are not reclassified as these challenge actions.

The existing installed `altcha-lib@1.4.1` / `altcha@2.3.0` protocol is retained;
no dependency upgrade or provider account is added. The server issues signed
SHA-256 challenges with a five-minute expiry and action. A bounded ledger
authenticates the exact issued salt/signature/action, validates the solution,
rechecks expiry after asynchronous verification and atomically consumes it.
At most 5,000 outstanding/pending challenges are admitted; concurrent issuance
reserves capacity before awaiting cryptography. Live proofs are not evicted to
admit more work. Challenge issuance is limited to 30/minute per observed socket
IP (IPv6 normalized); forwarded headers do not redefine Core's trust policy.

An unset key generates a random process-local signing key. Explicit keys must
be at least 32 characters; the old public fallback is rejected, including at
production startup. Outstanding challenges intentionally fail after restart.

## Evidence

- Original failure retained in `captcha-before.log` under the owned temporary
resolution evidence directory.
- All 139 server tests passed, including nine helper/policy negatives, three
real HTTP GraphQL/SQLite tests, existing password/IP/alias-concurrency tests,
and production startup's fallback-key rejection.
- Three UI component tests cover proof invalidation, action/reset, failed policy
loading and accessible retry. Policy fetch failure never becomes success.
- Four headed browser cases passed through the built frontend served by nginx
and a production-mode API: direct REST/GraphQL omission/forgery rejection;
390px/1440px real worker-solved proof, failed password then new-proof success;
and policy-load failure with keyboard retry. No email was delivered.
- Clean install, types and build passed; lint passed before the final explicit
socket-IP key generator, whose final composed checks remain to be recorded.
- Earlier browser failures are retained: the test initially forgot to choose
the Password tab; `.check()` incorrectly expected a PoW checkbox to become
checked before asynchronous solving; reusing the same API process exhausted
its existing strict rate limit. The corrected test clicks, waits for verified
status and asserts checked state, against a fresh owned API process. Limits
were not weakened. Initial Vite invocation from the wrong cwd omitted styles;
final evidence uses the built artifact through nginx.

The browser uses the self-host nginx configuration with only its listener and
API upstream replaced for isolated loopback ports (28548 / 28537). Neo4j/APOC
and SQLite fixtures are owned; the user's localhost application is untouched.
Local traces can include synthetic fixture credentials and remain private.

## Corrective rule and limits

Enforce challenge policy at each server action, including direct GraphQL aliases
and the auth-only schema. Client disabling/hiding controls is not enforcement.
Clear consumed/expired proofs and provide a keyboard-accessible retry. Preserve
backend contracts explicitly: Core advertises ALTCHA through `/config`; Cloud's
existing contract remains separate and is not proven by these tests.

This is a bounded proof-of-work abuse control, not proof that a visitor is human.
The ledger/rate limits are process-local; multiple API instances require a
shared or sticky challenge contract before availability parity can be claimed.
Unresponsive, restarted or over-capacity verification fails closed. Email token
purpose, OAuth linking and reset-session revocation remain #184. Hosted parity,
distributed limits, release/deployment and actual email delivery remain separate
work. Independent exact-head review and final CI are still required.

## Review corrections

Independent review of `b12ef2c1f0064ac7cc838ac1e806c52134a6802f` found that
`req.ip` can incorporate caller-supplied forwarding headers with the shipped
`TRUST_PROXY=2` configuration. The challenge limiter now reads only
`req.socket.remoteAddress`; an actual HTTP negative rotates 31 forwarded IPs
under Express trust proxy 2 and confirms that the 31st request is denied.

Review also found that an enabled password button could spend login attempts
while proof was missing/expired or policy loading failed. The UI now honors
advertised `requiredActions`, disables ordinary submission until ready, and
guards the submit handler too. Tests assert zero Login mutations in those
states, preserving explicitly optional development/legacy actions. A
non-navigating unverified-email result also clears consumed proof. Reset/recovery
forms clear their payload before releasing their loading state.

The focus regression exposed that ALTCHA's autofocus concerns completion,
not initial mode entry. Initial checkbox focus is now explicit on desktop mode
entry and user-requested retry; typing an email does not transfer focus. The
existing empty-email test now actually leaves and re-enters passwordless mode
instead of clicking the already-selected tab. Both focus tests passed after
that correction. Earlier test implementation errors (Fetch `status` property
versus Playwright's `status()` method and an unsupported Testing Library
`exact` option) are retained in local logs and were corrected before accepting
the results. Final composed evidence belongs in the PR discussion.
46 changes: 46 additions & 0 deletions .agents/inbox/2026-10-27-dev-launch-must-declare-mode.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
# Development launch must declare its mode before authentication policy is read

On 2026-10-27, [PR #195 CI run40573347298](https://github.com/GraphDone/GraphDone-Core/actions/runs/40573347298)
failed every basic smoke case at authentication on source
`8c9712b7bda0c7515568c08bae710be4bea675a1` (merge composition
`2b16ba2e2318e765883c1fd5ef312a0445addec2`). The sequence reported0 passes,
6 failed cases plus its failed-process evidence row. Trace inspection, with
credentials/tokens omitted, showed `/config` requiring all five CAPTCHA actions,
GraphQL `Please complete verification and try again`, then the existing
password-attempt limiter returning429 after repeated quick-login attempts.
The dedicated production proof/browser tests had passed and did not cover this
development launch contract.

Cause: CI invokes `npm run dev` without a `.env` or explicit `NODE_ENV`.
The API's old dev command launched `src/index.ts` directly, leaving mode unset.
CAPTCHA deliberately requires proof in unknown modes, while developmentInfo
advertised default-account shortcuts in every non-production mode. Changing
that policy to treat unknown mode as optional would mask the mismatch.

Correction: the explicit dev command launches `src/dev.ts`. It first loads and
validates environment configuration, defaults only an absent mode to
`development`, then dynamically imports the API. Explicit production, unknown
and blank modes are preserved. Development shortcuts are advertised only for
explicit development/test modes. Production `start` still imports the normal
API directly, and production/unknown CAPTCHA remains required.

Validation on Node22.18.0: six entry-point controls plus nine production startup
and nine CAPTCHA controls passed (24/24); server typecheck/build passed. An
owned invocation of the actual npm dev command with `NODE_ENV` unset, an empty
owned server data directory, disposable Neo4j and separate loopback ports
reported only magic-link/forgot-password/reset-password as required actions.
The existing browser smoke suite passed6/6 headed, one worker, zero retries,
2.3 minutes. This exercised actual quick login rather than a stubbed frontend.

The first entry test incorrectly used Vitest1's `stubEnv(undefined)`, which
created the literal string `undefined`; its cached mocked API factory also did
not rerun after `resetModules`. Correct fixtures delete the variable explicitly
and reset the mock factory. Those six fixture failures are retained separately
from the real CI failure. Assertions, challenge difficulty, login budgets and
CI thresholds were not relaxed. Final full server/harness and exact-head CI
results remain in the PR discussion; no deployment or hosted result is claimed.

Corrective rule: make the launch command's mode explicit without overwriting
operator configuration, then test that command as well as isolated policy.
Keep unknown-mode policy fail closed and record failed mode/shortcut alignment
as an application regression, not merely a flaky test retry.
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
# Attach verification listeners before a committed widget can emit

Date: 2026-10-27.

## Evidence

Token head `017a509443ead9b58febc940b4b63ed4aab70a97`, CI
[40591509438](https://github.com/GraphDone/GraphDone-Core/actions/runs/40591509438),
failed the web CAPTCHA case: expected the latest verified callback to contain
`issued-proof`, received an empty string. The DOM-presence assertion had passed.
The log does not itself record React phase ordering.

The deterministic regression wraps the actual component in `React.Profiler`.
Its commit callback dispatches one verification event for each newly observed
widget, after child layout effects and before passive effects. Against the old
component, `npm exec --workspace @graphdone/web -- vitest run src/components/AuthCaptcha.test.tsx`
failed the new case while four existing controls passed. Changing only the
listener/reset block to `useLayoutEffect` passed all five. An independent probe
reproduced the same before/after outcome.

A first local unbounded full-suite run also retained an unrelated 5-second
WorkItemDetailsModal timeout and its subsequent save-control failure. With the
same action deadlines and bounded workers (`--maxWorkers=2 --minWorkers=1`),
the complete web suite passed 353/353. This is explicitly different execution
configuration, not a silent retry or proof of that timeout's precise cause.

## Cause and corrective rule

A committed custom element can be visible before a passive effect installs its
event listener. An immediately emitted proof can therefore be lost. Attach and
clean up the verification listener in the layout phase; policy fetching and
focus behavior remain passive. Preserve failure, expiry and replacement resets.
See [React layout effect timing](https://react.dev/reference/react/useLayoutEffect).

Test the early event through actual React commit ordering. Retain controls that
replacement widgets deliver events and removed, prior-action and unmounted
widgets cannot deliver stale proofs. Do not fix this with an added test wait,
retry, or longer timeout. No authentication policy or proof validation changes.

## Limits

The regression proves this lifecycle hazard, not the exact CI scheduling trace.
It does not certify arbitrary custom elements that synchronously emit from a
constructor or connectedCallback before React attaches refs. Installed ALTCHA
initialization yields asynchronously; actual production-mode browser acceptance
and final exact-head CI remain separate gates.

The corrected source passed build, types, lint and 179/179 harness checks.
The rebuilt SPA against its owned production-mode API passed all four
`qc:captcha` browser cases in 17.8 seconds with one worker and zero retries:
real proof/wrong-password/retry at 390px and 1440px, direct forged/absent REST
and GraphQL rejection, and pending/failed/expired submission blocking.
3 changes: 3 additions & 0 deletions .agents/issues/INDEX.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@

| Symptom | Canonical lesson and regression |
| --- | --- |
| CAPTCHA widget is visible but an immediate verification event leaves proof empty | [Attach widget listeners during commit](../inbox/2026-10-27-widget-events-need-commit-phase-listeners.md); `AuthCaptcha.test.tsx` Profiler regression |
| CI development quick login fails required proof and exhausts login attempts with NODE_ENV unset | [Declare mode in the dev entry point without overriding production](../inbox/2026-10-27-dev-launch-must-declare-mode.md); `development-startup.test.ts` and actual dev smoke |
| Arbitrary client CAPTCHA strings succeed, or direct login/signup omits proof | [Require server-issued action-bound proof and atomic consumption](../inbox/2026-10-27-captcha-proof-must-be-server-issued.md); server `captcha` / `captcha-auth` tests and `qc:captcha` |
| An old audit lists a dependency that the current advisory identifies as patched | [Reconcile advisory backports with the existing lock](../inbox/2026-10-27-reconcile-advisory-backports-with-locked-consumers.md); `selector-parser.test.mjs` |
| Phone idle-motion sample includes one camera jump immediately after rotation while physics is already at rest | [Wait for edits and camera resize readiness](../inbox/2026-10-27-motion-sampling-needs-camera-readiness.md); controlled ResizeObserver delivery in `graph-motion.spec.ts` |
| Animated zoom stops near the overview exit threshold instead of reaching its target | [Title refresh must not own the camera](../inbox/2026-10-27-lod-title-refresh-must-not-own-camera.md); frame-controlled `semantic-lod.spec.ts` negative control |
Expand Down
1 change: 1 addition & 0 deletions .agents/issues/security-audit-index.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

| Symptom | Evidence and corrective rule |
| --- | --- |
| CAPTCHA validates only a client string's format or skips missing GraphQL proof | [Server policy, signed action/expiry and bounded replay](../inbox/2026-10-27-captcha-proof-must-be-server-issued.md); `packages/server/tests/captcha*.test.ts`, `qc:captcha` |
| Historical CSS audit findings disappear while the lock is unchanged | [Reconcile official backport ranges with actual consumers](../inbox/2026-10-27-reconcile-advisory-backports-with-locked-consumers.md); locked-path and Tailwind/parser regressions |
| Router advisory requires a major update and older upstream navigation expectations differ | [Installed consumer and route compatibility](../inbox/2026-10-27-router-upgrades-need-current-consumer-contracts.md); every locked Router path plus desktop/phone route gates |
| Fresh production audit flags Undici through optional SQLite build tooling without a direct application import | [Trace the native-build consumer and patch its actual resolved copy](../inbox/2026-10-24-undici-native-build-dependency.md); `tests/lib/dependencies/undici.test.mjs` rejects affected locked and installed consumer versions |
Expand Down
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,8 @@
"qc:project-transfer": "playwright test --project=GraphDone-Core/dev-neo4j/chromium --headed --workers=1 --reporter=line tests/e2e/quality/project-transfer.spec.ts",
"qc:operations": "playwright test tests/e2e/quality/operations.spec.ts --project=GraphDone-Core/dev-neo4j/chromium --headed --workers=1 --reporter=line",
"qc:operator-login": "playwright test tests/e2e/quality/operator-login.spec.ts --project=GraphDone-Core/dev-neo4j/chromium --headed --workers=1 --reporter=line",
"test:operations": "tsx scripts/test-operations-local.mts && tsx scripts/test-admin-bootstrap.mts"
"test:operations": "tsx scripts/test-operations-local.mts && tsx scripts/test-admin-bootstrap.mts",
"qc:captcha": "playwright test --config playwright.captcha.config.ts --headed --workers=1 --retries=0"
},
"devDependencies": {
"@types/node": "^20.10.0",
Expand Down
2 changes: 1 addition & 1 deletion packages/server/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
"main": "dist/index.js",
"scripts": {
"build": "tsc",
"dev": "tsx watch src/index.ts",
"dev": "tsx watch src/dev.ts",
"start": "node dist/index.js",
"test": "vitest --run",
"test:coverage": "vitest run --coverage",
Expand Down
5 changes: 5 additions & 0 deletions packages/server/src/config/secrets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,11 @@ export function validateProductionSecrets(environment: Record<string, string | u
return !value || knownInsecureSecrets.has(value.toLowerCase());
});

const captchaKey = environment.ALTCHA_HMAC_KEY?.trim();
if (captchaKey !== undefined && (captchaKey.length < 32 || captchaKey === 'your-secret-hmac-key-change-in-production')) {
invalid.push('ALTCHA_HMAC_KEY');
}

if (invalid.length) {
throw new Error(`Invalid production secrets: ${invalid.join(', ')}. Configure independent random secrets before starting the API.`);
}
Expand Down
4 changes: 4 additions & 0 deletions packages/server/src/dev.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
import './config/environment.js';

process.env.NODE_ENV ??= 'development';
await import('./index.js');
Loading
Loading