Skip to content

fix: post-0.2.0 audit remediation — Express 4 crash, CLI env/flag contract, a11y, packaging guards#6

Merged
PeterM45 merged 28 commits into
mainfrom
fix/audit-0.2.1
Jun 10, 2026
Merged

fix: post-0.2.0 audit remediation — Express 4 crash, CLI env/flag contract, a11y, packaging guards#6
PeterM45 merged 28 commits into
mainfrom
fix/audit-0.2.1

Conversation

@PeterM45

@PeterM45 PeterM45 commented Jun 10, 2026

Copy link
Copy Markdown
Owner

Context

A full multi-agent audit of the codebase and the published npm artifact ran after the 0.2.0 release (68 adversarially-verified findings: 1 high / 10 medium / 32 low / 25 suggestions). This branch fixes every defect-tier finding plus the zero-risk suggestions. The API-design suggestions (programmatic mint/verify, multi-password, cookie Domain, etc.) are deliberately not included — they deserve their own discussion.

Security

  • Express 4 process crash (high): the adapter rethrew body-read failures, and Express 4 doesn't route rejected middleware promises — one unauthenticated client opening a login POST and dropping the socket killed the server as an unhandled rejection. Errors now go to next(error) (correct on both majors), and the adapter conformance suite runs against both express@4 and express@5.
  • Proxy: headers named by the inbound Connection header are stripped in both directions (RFC 7230 §6.1) on top of the static hop-by-hop list.

Fixed (highlights)

  • Core: fractional sessionSeconds minted tokens that never validated → silent permanent login loop. Now floored.
  • Core: login page accessibility — failed-login notice is tied to the input via aria-describedby/aria-invalid (server-rendered role="alert" is never announced), WCAG AA contrast in both schemes, color-scheme, 100dvh.
  • CLI: env files now parse the way dotenv (Next/Astro/wrangler) parses them (export prefixes, quotes, inline comments) and values are quoted on write — so the password init writes is the password the framework reads, and init never regenerates an existing secret. Strict argument handling: positionals, single-dash typos, bad boolean values, bare value flags, and missing --env-file are all hard errors; --origin is validated at startup; --port 0 reports the real bound port.
  • Examples: spa-cloudflare now depends on ^0.2.0 (a ^0.1 range can never resolve to 0.2.x), pins a wrangler compatibility_date, and documents /__gate/logout.
  • Docs: README claims, CLI reference, publicPaths example, and .env.example corrected against actual behavior; JSDoc on the whole public type surface.

Added

  • Proxy trustProxy option / --trust-proxy flag for TLS-terminator deployments (off by default).

Packaging / supply chain

  • publint and @arethetypeswrong/cli pinned as devDependencies and run from the lockfile in CI and the publish workflow (attw is a new CI gate — this regression class shipped once before).
  • smoke-dist now packs a real tarball, installs it into a temp consumer, and imports all 10 subpaths through the exports map in both ESM and CJS.
  • publish.yml publishes the exact tarball it smoke-tested (npm publish ./sitepass-*.tgz) and pins the npm upgrade instead of npm@latest.
  • Dist keeps node:-prefixed builtin imports; the Astro CJS build sheds a dead import.meta branch and its require('url') shim.
  • tsconfig: noImplicitReturns on; ignoreDeprecations removed from the main typecheck and scoped to tsup's dts build (which injects a deprecated baseUrl).

Internal quality

  • core.ts split at its HTML seam into src/login-page.ts (all source files now ≤ 500 lines), avoidable type assertions removed, adapter options/env wiring/gate constants single-sourced across all 8 adapters, shared proxy test fixtures.

Upgrade notes (in CHANGELOG)

  • An unquoted # in a pre-existing env file now reads truncated at the # — the value frameworks were already reading; quote it to keep the #.
  • The stricter CLI turns previously-silent misuse into hard errors.

These behavior changes likely warrant a 0.3.0 rather than 0.2.1, per the 0.2.0 precedent.

Verification

  • 155 tests, coverage 97.85% stmts / 90.06% branch / 100% funcs / 99.72% lines (thresholds enforced)
  • publint clean, attw 44/44 green (node10, node16 CJS+ESM, bundler × 11 entries)
  • Tarball smoke: all subpaths import in both formats, gate works, CLI runs
  • Three-lens adversarial review of the full diff (security regressions / consumer-facing correctness / code standards): zero must-fix findings

Summary by CodeRabbit

  • New Features

    • Added --trust-proxy flag for deployments behind TLS-terminating proxies
    • Added --bypass-token flag support for token-based authentication
    • Enhanced login page with improved accessibility (ARIA attributes, color scheme)
  • Bug Fixes

    • Fixed session token validation for fractional-second values
    • Improved reverse proxy header handling for hop-by-hop connection headers
  • Documentation

    • Expanded reverse proxy configuration guide
    • Updated CLI flag references and environment variable documentation

PeterM45 added 28 commits June 9, 2026 18:11
peerDependencies and the README claim express >=4, but only express@5 was
ever exercised. Alias express4 (npm:express@^4) as a devDependency and run
the same conformance behaviors against both majors; only the catch-all
route syntax differs ('/{*path}' on 5, '*' on 4).
…ecting

Express 4 does not route a rejected middleware promise to error handlers,
so the rethrow on a non-413 body-read failure became an unhandled rejection
and killed the process: one unauthenticated client opening a login POST and
dropping the socket mid-body crashed the whole server. Wrap the body-read/
handle path in try/catch and hand every failure to next(error), the error
contract on both majors; the BodyTooLargeError 413 branch is unchanged.

Regression test drives both express majors with a raw-socket login POST
that under-delivers Content-Length and then destroys the connection,
asserting the error reaches next(error), the adapter promise never
rejects, and the server keeps gating afterwards.
createGate only normalized sessionSeconds against NaN/Infinity, so a
fractional value (e.g. one computed by division) minted a token whose
stringified expiry fails verifyToken's Number.isSafeInteger check: login
succeeds, the redirect fires, and the visitor bounces back to the login
page forever. Floor the value, matching the nowSeconds convention and
the integer Max-Age the cookie spec expects.
- Tie the failed-login notice to the input with aria-describedby and
  aria-invalid: role=alert on a server-rendered page never announces,
  and autofocus drops screen-reader users into the empty field with no
  audible failure indication.
- Dark-mode error text #f87171 (6.40:1 on the card, was 3.67:1) and
  input border #71717a in both schemes (4.83:1 light / 3.67:1 dark,
  was 1.48:1 / 1.70:1) to clear WCAG AA and 1.4.11 non-text contrast.
- Declare color-scheme (meta + :root) so UA surfaces follow the dark
  theme, and add a 100dvh min-height with 100vh fallback so the card
  centers under mobile dynamic toolbars.
…dCookie

- Move the self-contained HTML section (renderDefaultLoginPage,
  renderNotConfiguredPage, documentShell, pageStyles, escapeHtml) into
  internal src/login-page.ts, bringing core.ts under the 500-line cap.
  escapeHtml is re-exported from core so the package-root import keeps
  working; tsup entries unchanged (the module lands in shared chunks,
  verified via build + smoke-dist).
- Replace the two 'as number' index assertions with checked iteration
  that compiles under noUncheckedIndexedAccess; the constant-time
  comparison still walks the full length with no early exit.
- Declare ImportMeta.env optional so the compiler enforces the runtime
  guard astro.ts already performs outside Vite builds.
- Add readCookie unit tests pinning the contract every adapter depends
  on (absent cookie, name-prefix non-collision, valueless parts,
  whitespace, verbatim values).
Pure move of TARGETS and SNIPPETS so cli.ts stays inside the
500-line file budget as its validation logic grows.
Read and write env files the way dotenv (Next, Astro dev, wrangler)
does: honor 'export ' prefixes, strip unquoted inline comments, expand
\n only inside double quotes, and quote written values containing #,
quotes, or edge whitespace so both parsers read back the same password.
init no longer regenerates a secret or appends last-wins duplicates
when the file uses export lines.

Argument handling now matches the documented strict-flag guarantee:
single-dash typos and stray positionals are hard errors, boolean flags
reject unrecognized values (--insecure-cookie=yes), and an explicitly
passed --env-file that does not exist throws instead of silently
starting fail-closed (help notes Node's own --env-file pre-scan).

Also: require an http(s) --origin with a friendly error, print the
OS-assigned port for --port 0, say so when init skips .gitignore
outside a git root, and drop the unreachable KNOWN_FLAGS help entry.
…client-drop teardown

The proxy's most common real-world failure paths shipped untested: the
502 side of the catch handler, the !upstream.body early return (HEAD,
204), and the no-reply teardown when the client vanishes mid-upload.
Extract the listen/proxy/loginCookie/rawRequest harness into a shared
fixture so the new failure-mode file stays a focused module.
…eader

RFC 7230 6.1 requires an intermediary to remove every header the
Connection header names, not just the static hop-by-hop set, so
`Connection: x-foo` no longer smuggles x-foo to the origin (or, on the
way back, to the client). Defense in depth: undici re-frames the
forwarded request, so no smuggle was demonstrated.
…minator deployments

The proxy always overwrote X-Forwarded-* from the connecting socket, so
behind the README's own recommended TLS terminator the origin was told
every request arrived over plain http from the terminator's loopback —
downgrading Secure-cookie logic and per-IP banning. With trustProxy the
front hop's X-Forwarded-* pass through (peer appended to the For chain).
Default stays the safe overwrite, and the docs now say the proxy reports
proto=http unless the flag is set.
…constants

The eight adapters each hand-wrote the same options type and SITEPASS_*
credential block (already drifted into two bypass-normalization forms), and
the bypass header, 64 KiB login cap, and request-target split were declared
per file. One copy of each now lives in the internal shared modules:

- web.ts: AdapterGateOptions, createGateFromEnv (owns the env var names and
  empty-bypass normalization), BYPASS_HEADER; toResponse is no longer
  exported (gateWebRequest is its only caller)
- node-body.ts: splitRequestTarget and firstHeaderValue for the Node-side
  consumers, so the proxy's gate decision and forward target cannot diverge
  from each other or from Express
- proxy.ts: the 10 MiB forward cap is renamed DEFAULT_MAX_FORWARD_BYTES so
  the shared DEFAULT_MAX_BODY_BYTES name keeps one meaning; stripCookie now
  matches via core's readCookie, so the strip rule is definitionally the
  accept rule
- cloudflare.ts: the env cast becomes a checked assignment

Per-adapter option type names stay as public aliases; the emitted d.ts
surface is equivalent (verified via build + dist inspection).
…ed types

The unannotated export widened to { path: string }, so a consumer writing
the natural typed form `export const config: Config = netlifyConfig` in a
Deno-checked edge function got a type error: Netlify's Config types path as
a /${string} template literal. Annotating locally keeps the literal without
taking the @netlify/edge-functions dependency.
…edup proxy fixtures

Pins behavior that no test exercised:

- hono: c.env bindings win over process.env (the Workers path, where
  process.env does not exist)
- netlify: an absent Netlify global fails closed with the 503 page instead
  of a ReferenceError, and config.path keeps its /${string} literal type
- node-body: a mid-read socket 'error' rejects with that error, and an
  error after end still resolves (the last uncovered function in the suite)
- adapter conformance: HEAD and OPTIONS without a cookie get the 401 login
  result, documenting the CORS-preflight consequence
- core: a non-ASCII password with form-special characters round-trips
  through the form-decode -> TextEncoder -> HMAC pipeline end-to-end

proxy.test.ts also moves its repeated origin+proxy setup into gatedProxy /
headerCapturingProxy fixtures so each test body shows only what it asserts.
…r compatibility_date

Caret ranges don't float across 0.x minors, so ^0.1.0 could never resolve
to the 0.2.0 security release. Also bump devDependencies to current majors
(typescript ^6, vite ^8, plugin-react ^6) and add a minimal wrangler.jsonc
so pages dev stops floating runtime semantics to today's date.
…in vite dev is ungated

The middleware is two lines, not three; the README never mentioned the
built-in logout endpoint; and npm run dev serves the SPA without the
Pages Function, which deserves an explicit warning in a gating example.
GateResult, Gate/handle, GateOptions, GateRequest (+method),
ProxyOptions (origin, port, and the proxy-specific maxBodyBytes
meaning), and each adapter's options alias now carry hover docs;
netlify's config gets a /** */ doc so the re-export instruction
surfaces in editors. Also corrects the segment-matching example in
core's publicPaths comment: /apixyz never demonstrated the rule,
/api/webhooksxyz does.
…in .env.example

- The proxy section no longer implies maxBodyBytes is the 64 KiB login
  cap: on the proxy it caps the forwarded body (10 MiB default).
- The CLI reference and printHelp both list --help | --version (-h / -v)
  and note Node's own --env-file pre-scan, which fires for the installed
  bin too, not just node dist/cli.js (verified on Node 22).
- The publicPaths example now demonstrates segment matching:
  /api/webhooks covers /api/webhooks/stripe but not /api/webhooksxyz
  (verified against dist; /apixyz proved nothing a prefix match would
  not). Test 7 pins the cited example.
- .env.example lists the optional SITEPASS_BYPASS_TOKEN every adapter
  and the proxy CLI read.
Security (Express 4 unhandled-rejection crash, proxy Connection-named
hop-by-hop headers), Fixed (fractional sessionSeconds, login-page a11y,
dotenv-compatible env files, strict CLI args, netlify config.path type,
README/docs corrections, example pins), Added (trustProxy /
--trust-proxy).
Strict positional parsing ran before the help-command check, so
`sitepass help init` exited 1 with a confusing flag error; help's
positionals are now dropped before parsing (--version still wins).
A value flag given bare (`--env-file` with nothing after it) parsed
as boolean true and collapsed to the implicit default, silently
ignoring explicit user intent — it is now a hard error for every
value flag.
…trictness

A pre-0.2.1 env file holding an unquoted value with '#' in it now
reads truncated at the '#' (the value dotenv-following frameworks were
already reading) — say so, with the quote-it remedy, instead of leaving
the consequence for existing files implicit.
The local joiner and node-body's firstHeaderValue had near-identical
names with opposite duplicate semantics (join per RFC 9110 5.3 vs
first-wins), and nothing but the name stops a future swap — make the
difference self-documenting.
@coderabbitai

coderabbitai Bot commented Jun 10, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: a7454c4d-0d05-4245-ac6d-efcb1d7a0864

📥 Commits

Reviewing files that changed from the base of the PR and between 403bf0b and d5fd5d2.

⛔ Files ignored due to path filters (1)
  • bun.lock is excluded by !**/*.lock
📒 Files selected for processing (37)
  • .env.example
  • .github/workflows/ci.yml
  • .github/workflows/publish.yml
  • CHANGELOG.md
  • README.md
  • examples/spa-cloudflare/README.md
  • examples/spa-cloudflare/package.json
  • examples/spa-cloudflare/src/App.tsx
  • examples/spa-cloudflare/wrangler.jsonc
  • package.json
  • scripts/smoke-dist.mjs
  • src/astro.ts
  • src/bun.ts
  • src/cli-snippets.ts
  • src/cli.ts
  • src/cloudflare.ts
  • src/core.ts
  • src/express.ts
  • src/hono.ts
  • src/login-page.ts
  • src/netlify.ts
  • src/next.ts
  • src/node-body.ts
  • src/proxy.ts
  • src/sveltekit.ts
  • src/web.ts
  • test/adapters.test.ts
  • test/cli.test.ts
  • test/core.test.ts
  • test/fixtures/express4.ts
  • test/fixtures/proxy-helpers.ts
  • test/node-body.test.ts
  • test/proxy-failures.test.ts
  • test/proxy.test.ts
  • tsconfig.json
  • tsup.config.ts
  • types/ambient.d.ts

📝 Walkthrough

Walkthrough

This PR refactors all adapters to use shared AdapterGateOptions and createGateFromEnv helper, extracts login-page rendering to a dedicated module, hardens proxy header handling and CLI flag parsing with dotenv compatibility, and substantially expands test coverage across adapters, proxy, and CLI command scenarios.

Changes

Adapter consolidation and web abstraction

Layer / File(s) Summary
Web adapter abstraction and login-page extraction
src/web.ts, src/login-page.ts, src/node-body.ts, src/core.ts, types/ambient.d.ts
Introduces BYPASS_HEADER constant, AdapterGateOptions type, and createGateFromEnv for environment-aware gate construction. Extracts login-page HTML rendering to new module with escapeHtml, renderDefaultLoginPage, and renderNotConfiguredPage. Adds request helpers (splitRequestTarget, firstHeaderValue) for consistent URL/header parsing. Updates core module to delegate rendering and re-export HTML escaping. Makes import.meta.env optional in ambient types.
Adapter migration to AdapterGateOptions
src/astro.ts, src/bun.ts, src/cloudflare.ts, src/express.ts, src/hono.ts, src/netlify.ts, src/next.ts, src/sveltekit.ts
Updates all adapters to replace local Omit-based option types with AdapterGateOptions alias and to use createGateFromEnv instead of manual environment variable extraction. Each adapter maintains its existing function signature and behavior while centralizing credential sourcing through the shared helper.

Proxy hardening and CLI refactoring

Layer / File(s) Summary
Proxy trustProxy option and header hardening
src/proxy.ts
Extends ProxyOptions with trustProxy boolean flag and conditional X-Forwarded-* logic: when enabled, appends peer to forwarded-for chain and preserves scheme/host; otherwise overwrites from immediate peer. Adds connectionNamed helper to parse Connection-header-listed hop-by-hop headers. Enhances filtering to drop headers named in inbound and upstream Connection headers. Updates request parsing and bypass-token extraction to use shared helpers.
CLI flag parsing and dotenv compatibility
src/cli-snippets.ts, src/cli.ts
Extracts targets and snippets to dedicated module. Introduces asString helper to reject bare flags and flagEnabled to validate explicit truthy/falsey spellings. Adds parseEnvLine with dotenv-compatible quoting, export-prefix, and comment handling; formatEnvValue for safe value quoting. Updates upsertEnv to preserve export prefixes and loadDotenv to support required flag. Makes main exported and enhances validation across init and proxy commands.

Test infrastructure and coverage expansion

Layer / File(s) Summary
Shared proxy test helpers and lifecycle management
test/fixtures/proxy-helpers.ts
Adds centralized open list and closeAll() for server lifecycle. Provides listen for origin servers, proxy for ephemeral proxy port, gatedProxy for composed setup with shared credentials, headerCapturingProxy to record forwarded headers, loginCookie to extract gate cookie, and rawRequest for raw HTTP lines enabling SSRF/header testing.
Adapter conformance and environment-binding tests
test/adapters.test.ts, test/fixtures/express4.ts
Adds HEAD/OPTIONS conformance check, shared listen helper, and loops Express tests over both v5 and v4 via new fixture. Adds Express "error contract" test for next(error) routing. Adds Hono test for c.env binding precedence and Netlify test for 503 failure-closed and leading-slash literal type preservation.
Proxy functionality and failure-mode tests
test/proxy.test.ts, test/proxy-failures.test.ts
Refactors proxy tests to use shared helpers; updates gating, robustness, and forwarding tests. Adds new failure-mode suite covering 502 on origin-down, HEAD response completion, 204 bodiless responses, and client disconnect mid-login graceful teardown.
CLI and core gate tests
test/cli.test.ts, test/core.test.ts
Adds dotenv round-trip validation, end-to-end main testing via process.argv, comprehensive env-file and upsertEnv tests with export-prefix and quoting behavior. Extends core tests for publicPaths non-over-match, fractional sessionSeconds flooring, ARIA attributes, color-scheme/AA styling, and non-ASCII password round-trip.

Build configuration, workflows, and documentation

Layer / File(s) Summary
Build configuration and package management
package.json, scripts/smoke-dist.mjs, tsconfig.json, tsup.config.ts
Adds publint and attw --pack . scripts; adds @arethetypeswrong/cli and publint devDependencies plus express4 alias. Extends smoke-dist.mjs with stage-2 consumer validation: generates check.mjs/check.cjs for subpath export verification, performs real createGate/handle integration test, packs and installs tarball. Adds noImplicitReturns: true to tsconfig; updates tsup to suppress TS-6 warnings and disable removeNodeProtocol.
CI and publish workflow updates
.github/workflows/ci.yml, .github/workflows/publish.yml
Updates CI quality job to run pinned bun run publint and bun run attw. Updates publish workflow to run pre-publish validation, pack tarball with npm pack --ignore-scripts, smoke-test the exact tarball, and publish with pinned npm@11.16.0 and provenance flag.
Documentation and example updates
CHANGELOG.md, README.md, examples/spa-cloudflare/*
Adds CHANGELOG ## [Unreleased] documenting adapter, proxy, and CLI changes. Expands README proxy section with environment reading, X-Forwarded-* behavior, session cookie stripping, and body limits. Clarifies publicPaths matching and CLI flag behavior. Updates Cloudflare example with compatibility_date, corrected preview command guidance, and version bump. Adds bypass-token documentation to .env.example.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

  • PeterM45/sitepass#2: Both PRs implement the same core security-hardening refactor—centralizing bypass-token/web request handling in src/web.ts/gateWebRequest and updating adapters/proxy with cookie/header stripping and login body caps/publicPaths hardening.

🐰 Hops with glee through refactored gates,
Adapters now speak one language true,
While proxies trim headers like morning dew—
The security warren celebrates clean states!

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 56.72% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately summarizes the main change: a comprehensive post-0.2.0 audit remediation covering multiple defect fixes (Express 4 crash, CLI validation, accessibility, packaging).
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/audit-0.2.1

@PeterM45 PeterM45 merged commit 5dd34d1 into main Jun 10, 2026
6 checks passed
@PeterM45 PeterM45 deleted the fix/audit-0.2.1 branch June 10, 2026 01:21
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.

1 participant