Skip to content

fix(serve): bind action replay to sessions - #16

Open
integrate-your-mind wants to merge 7 commits into
chore/oss-contribution-setupfrom
codex/server-actions-hardening
Open

fix(serve): bind action replay to sessions#16
integrate-your-mind wants to merge 7 commits into
chore/oss-contribution-setupfrom
codex/server-actions-hardening

Conversation

@integrate-your-mind

@integrate-your-mind integrate-your-mind commented Jul 29, 2026

Copy link
Copy Markdown
Owner

Summary

  • Bind production server-action replay nonces to the normalized route and a SHA-256 fingerprint of one configured session cookie.
  • Consume replay nonces atomically before renderer invocation so concurrent duplicate submissions and timeout retries fail closed.
  • Reject missing, duplicate, ambiguous, malformed, or rotated session cookies without exposing cookie values or internal renderer errors.
  • Add fail-closed CLI configuration, deployment assumptions, browser coverage, deterministic replay-store regressions, and targeted mutation proof.

This is an AI-authored contribution reviewed by an independent AI security reviewer. It is stacked on #13 and must not merge before that base is finalized and the merge ref is revalidated.

Related issue

No issue. This closes the documented session-binding gap for process-local replay protection; distributed replay defense remains explicitly unsupported.

Change type

  • Bug fix
  • Feature
  • Refactor
  • Tests or proof
  • Documentation
  • Build, CI, packaging, or deployment
  • Security hardening

Verification

  • Tests were added or updated for changed behavior
  • pnpm lint
  • pnpm typecheck
  • pnpm build
  • pnpm test
  • Package checks were run when package or release files changed
  • Deployment or proxy checks were run when those files changed
  • Documentation was updated where needed

Current reviewed head: 22a2fe4129438e7ba3962ef3991487e898058390
Runtime/test head: 94eecd09843c1b9cac76dc102b4962d22d1f1268

22a2fe4 changes only one deployment-summary sentence after 94eecd0; runtime, CLI, browser, and test sources are byte-identical.

EXACT 22a2fe4
PASS  pnpm lint
PASS  pnpm typecheck
PASS  pnpm build
PASS  cargo test --workspace -- --test-threads=1
      ferrite-dev-server: 136/136
PASS  FERRITE_BROWSER_EXECUTABLE=<Chrome> pnpm test:browser 11/11
PASS  gitleaks git --redact 94eecd0..22a2fe4 (no leaks)
PASS  git diff --check
PASS  independent exact-head security/doc review: ACCEPT, no P0-P3
PASS  git merge-tree #13 74e3fa1 + 22a2fe4
      tree b0fed6002adfab7ba82c2be03141c7cb77bcdda2, conflict-free

CODE-IDENTICAL 94eecd0 PROOF
PASS  pnpm --filter @ferrite/protocol test                 6/6
PASS  pnpm --filter @ferrite/protocol-wasm test            6/6
PASS  pnpm --filter @ferrite/runtime test                203/203
PASS  pnpm --filter @ferrite/node test                     35/35
PASS  pnpm test:example (build, render, dev, production serve)
PASS  pnpm test:demos (two real Chrome demos; normal/query/deep/404,
      undeclared-file 404, and tamper fail-closed)
PASS  cargo llvm-cov --locked --workspace --all-targets --summary-only
      91.33% lines, 90.66% functions; ferrite-dev-server 92.56% lines
PASS  targeted cargo-mutants replay/session/config/CLI groups:
      no surviving viable high-risk mutant
PASS  gitleaks git --redact 6363cd96..94eecd0 (no leaks)

KNOWN pnpm test at 22a2fe4: 126/131 script tests passed. Four failures are
inherited verify-npm-packages empty-fixture defects unchanged from base 6363cd96
and already fixed on this PR's #13 base by commit 3b2f200; the fix is intentionally
not duplicated here. One additional Buildkite-hook test could not create its
private temp home because the filesystem returned ENOSPC before hook logic ran.
The same standalone suite at 94eecd0 reached 127/131 with only the four inherited
fixture failures. The eventual exact merge ref requires a complete clean rerun.

CI: no hosted or Buildkite execution is claimed. GitHub currently reports zero
status checks. All results above are local macOS arm64 evidence.

Task-owned cleanup: 611,560 KiB from the first exact functional/coverage cycle, 361,608 KiB from completed coverage, and 248,048 KiB from the documentation-head cycle were removed. The pre-existing packages/protocol/dist remained at tree hash fe29f3fbb176a7a6315d25b3984329eb602d4dfda57145e37e5d497e6f6b486f.

Risk and failure paths

Normal: one valid session-bound nonce invokes its route action once.

Failure/odd paths covered: missing and expired nonce; wrong route; missing, duplicate, quoted, malformed, or rotated session cookie; concurrent duplicate POST; render failure; render timeout and unknown outcome; retry with a fresh nonce; bounded store pruning; selective discard; exact expiry deadline; CSRF and origin checks; trusted-proxy origin; generic public errors and privacy-safe internal logs.

Deployment assumptions:

  • The named session cookie must already be authenticated and integrity-protected by the application or trusted upstream.
  • Replay state is process-local. Sticky routing is required for this protection to work predictably across requests.
  • Multi-process or multi-region replay defense needs a shared atomic store and is not provided by this change.
  • Session rotation invalidates previously rendered nonces by design.

Breaking changes

  • No public API, protocol, artifact, CLI, config, or behavior break
  • Breaking change described below

The new CLI flag is optional. Existing deployments behave as before when it is omitted. If configured, it requires replay protection and fails closed on invalid cookie names or missing sessions.

Security and privacy

  • No secrets, credentials, private URLs, personal data, or production data are included
  • Untrusted input and failure behavior were reviewed
  • This change does not weaken an existing security check to make a test pass

The replay store retains only a SHA-256 session-cookie fingerprint, not the raw cookie. Public action failures remain generic; internal logs do not include tokens, cookies, or submitted form data.

Rollback

Revert the seven branch commits, or omit --server-action-session-cookie-name to disable only session binding. Existing CSRF and process-local replay flags remain independently configurable. No migration or persistent user state is introduced.

Reviewer notes

Start with crates/ferrite-dev-server/src/lib.rs replay-store consumption and cookie parsing, then the CLI fail-closed configuration and test/browser-server-actions.test.mjs. Independent source/security review at 22a2fe4 is ACCEPT with no P0-P3.

Current disposition: READY FOR CODE REVIEW; NOT MERGE ELIGIBLE. Required next gates are #13 finalization, base reconciliation, exact merge-ref full-suite rerun, and repository-required review/checks.

@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.

@integrate-your-mind integrate-your-mind left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed the exact head 22a2fe4. The atomic consume path, route/session matching, failure behavior, and bounded-store logic look consistent. I left four actionable inline comments covering two cookie parsing/API edge cases, one Rust source-compatibility issue, and one deployment requirement that should be stated more precisely. I did not rerun the test suite; this review is based on the exact-head source and the verification evidence recorded in the PR.

}

fn server_action_session_fingerprint(headers: &HttpHeaders, cookie_name: &str) -> Option<[u8; 32]> {
let header = headers.get("cookie")?;

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

HttpHeaders is a public BTreeMap, and the new handle_get_with_headers path is documented for Rust embedders. Looking up only the lowercase key makes Cookie or COOKIE silently behave as a missing session even though HTTP field names are case-insensitive; the socket parser hides this by lowercasing names first. Please use the existing header_value(headers, "cookie") helper here (and consider the adjacent CSRF-cookie helper) or normalize headers at the public boundary, with a mixed-case regression test.

if fingerprint.is_some() {
return None;
}
let value = value.trim();

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These trims make malformed cookie-pairs such as app_session = session-123 and app_session= session-123 valid, while the continue above also lets app_session; app_session=session-123 succeed. That conflicts with the documented fail-closed behavior and can create a parsing difference from the app/auth layer whose session Ferrite is meant to bind. Please reject malformed occurrences of the configured name rather than normalizing them, and add these cases to the fingerprint tests.

pub max_in_flight_requests: usize,
pub server_action_csrf_token: Option<String>,
pub server_action_csrf_cookie_name: Option<String>,
pub server_action_session_cookie_name: Option<String>,

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Adding a field to this public, non-#[non_exhaustive] struct is a source-breaking Rust API change: downstream struct literals and exhaustive patterns no longer compile. The PR currently marks “No public API … break.” At minimum, please record this compatibility impact in the PR/release notes; otherwise the config shape needs a compatibility-preserving extension strategy.

Comment thread docs/deployment.md
- external tracing and audit sinks beyond stderr request/action logs and in-memory metrics counters

Until those exist, deploy server actions only for controlled beta scenarios or behind app-owned authentication and CSRF middleware that has been reviewed separately. If server actions are enabled in production, set `--server-action-csrf-token-env`, prefer `--server-action-csrf-cookie-name`, and set `--server-action-replay-ttl-ms` when a single Ferrite process owns the action form and action POST path. Rotate the referenced CSRF secret as part of the deployment process. The configured token must be cookie-safe when cookie binding is enabled. If the public TLS origin differs from the upstream Ferrite bind origin, set `--trusted-proxy-public-origin` and configure the proxy to own and sanitize the forwarded proto/host headers. If access logs need public client IPs behind the proxy, set `--trusted-proxy-client-ip-hops` to the exact number of trusted proxy hops and make the edge proxy overwrite `X-Forwarded-For`.
Until those exist, deploy server actions only for controlled beta scenarios or behind app-owned authentication and CSRF middleware that has been reviewed separately. If server actions are enabled in production, set `--server-action-csrf-token-env`, prefer `--server-action-csrf-cookie-name`, and set `--server-action-replay-ttl-ms` when a single Ferrite process owns the action form and action POST path. When the application already issues a stable, opaque, high-entropy authenticated session cookie, add `--server-action-session-cookie-name <name>` to bind each new nonce to that cookie and route. Ferrite does not create, authenticate, authorize, renew, revoke, or configure that cookie. The application or identity layer must enforce TLS plus appropriate `Secure`, `HttpOnly`, `SameSite`, `Path`, `Domain`, expiry, signing/encryption, fixation resistance, and logout rotation policy.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please state explicitly that the session cookie Path must cover both the route GET and /_ferrite/action—normally Path=/. With a narrower path, Ferrite can render a bound nonce on the page but the browser omits the cookie on the action POST, so every action fails with the generic replay error. “Appropriate Path” is easy to miss for this specific two-endpoint requirement.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants