Skip to content

Fix the first security audit's findings - #14

Merged
nedtwigg merged 5 commits into
mainfrom
audit-fixes
Sep 23, 2026
Merged

nedtwigg merged 5 commits into
mainfrom
audit-fixes

Conversation

@nedtwigg

Copy link
Copy Markdown
Member

Fixes every finding in #13, the first run of the nightly security audit.

  • Q1, rate limiting (Email codes FAIL). Better Auth's limiter stays on, keeping its short-window limits.
    • Its keys are now an HMAC of <ip>|<path>, written by one atomic upsert through rateLimit.customStorage. Migration 005 deletes the old plaintext rows.
    • The client IP comes from the Node socket, or from a forwarded header only when the app opts in with ipAddressHeaders. The Workers adapter keeps cf-connecting-ip.
    • A client-sent x-pgstencil-client-ip is always overwritten. Both limiters read the same trusted IP and group IPv6 by /64.
    • Tests: no raw IP is stored, rotating the header doesn't escape the limit, pinning a victim's IP spends only the caller's budget, the opt-in is honoured, counting is atomic, and addresses in one /64 share a budget.
  • Q2, diagnostics spec drift (OAuth FAIL). SECURITY.md now says what the code does. A provider error description never reaches a redirect, a page or a diagnostic record. An allowlisted error code, and Microsoft's bounded numeric code, may reach a diagnostic record only.
  • Q7, controls no test exercised. New tests cover:
    • ID-token algorithm pinning (HS256 keyed with the public key, none, PS256, RS512)
    • 415 for a non-JSON body and 405 for callback methods
    • authOptions validation, including off-origin redirect paths
    • the 15-attempt verification budget
    • X-Content-Type-Options and X-Frame-Options
  • Q3, Q4, Q5, Q8. A pgstencil.json migrations path can no longer leave the project root. The testing module is documented as inert unless injected. The Facebook-email and shared-secret limits are stated under "What is not defended".

Residuals, not rules today:

  • Better Auth still stores the trusted IP in session."ipAddress".
  • An app that builds betterAuth(authOptions(...)) itself, bypassing createAuthApp, would trust the header. The JSDoc and PACKAGES.md say to use createAuthApp.

Verified: format, typecheck, db:verify, pnpm test (175), test:scripts, packages:verify.

🤖 Generated with Claude Code

nedtwigg and others added 5 commits September 22, 2026 17:08
… (audit Q1)

The audit's Email codes FAIL: Better Auth's database limiter stored
"<ip>|<path>" in plaintext, and on Node the IP came from
x-pgstencil-client-ip, a header the client supplies, so rotating it escaped
the limit and pinning a victim's address exhausted theirs.

- rateLimit.customStorage keeps Better Auth's limits and windows but stores
  keyed(secret, 'rate-limit', key) through one atomic upsert; migration 005
  deletes the plaintext rows.
- protectAuth overwrites x-pgstencil-client-ip on every request from the
  @hono/node-server socket (env.incoming), or from a header only when the
  application opts in with ipAddressHeaders. Better Auth and the per-IP
  email budget both read that one trusted value.
- SECURITY.md states the hashed-key and trusted-IP rules and pins them to
  five new integration tests; PACKAGES.md documents the opt-in.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
… Q2)

The audit's OAuth FAIL: SECURITY.md said no provider error code reaches a
diagnostic record, but diagnosticError deliberately records allowlisted
OAuth codes and Microsoft's bounded numeric code, and a unit test pins that.
The rule now says exactly that: a provider's error description, or a code
outside the errorCodes allowlist, never reaches a redirect, a page or a
diagnostic record; an allowlisted code may reach only a diagnostic record.
The Diagnostics rule names the allowlist and provider descriptions too.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The audit found controls that exist in code but that no Pinned-by test
drives, so a regression would pass CI. New integration tests:

- RS256 pinning: ID tokens forged under HS256 (keyed with the public key),
  none, PS256 and RS512 are rejected for Google, Apple and Microsoft. With
  the pin removed, Microsoft's PS256 token signs in, so this test fails.
- 415 for a non-JSON body on an allowlisted write.
- 405 for a non-GET on a non-Apple callback and for Apple POST without form
  encoding.
- authOptions rejects a non-canonical origin, a secret under 32 characters
  and off-origin success/error paths (//, /\, tab variants).
- The fifteen-verifications-per-fifteen-minutes budget, across IPs and
  against the right code.
- X-Content-Type-Options and X-Frame-Options on a page and an API response.

SECURITY.md names each test in its Pinned-by line.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…, Q4, Q5, Q8)

- Q5: paths.ts refuses a pgstencil.json migrations path that resolves
  outside the project root; a unit test pins it and PACKAGES.md says so.
- Q3: the Build and test isolation rule now says better-auth-testing is a
  published export, imported by packages:verify, and inert unless esbuild
  injects it; the rule fails on an injection in a normal build or a
  global changed by import, pinned by the production-bundle and
  unchanged-host-clock tests.
- Q4, Q8: "What is not defended" names Facebook's unverified email and two
  applications sharing both a database and AUTH_SECRET.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The per-IP email budget hashed the full address, so a client rotating
within its IPv6 /64 got fresh budgets while Better Auth's limiter held.
It now keys on ipBucket, which reproduces Better Auth's
normalizeIP(ip, { ipv6Subnet: 64 }). Better Auth does not re-export that
helper, and depending on @better-auth/core directly drags in its exactly
pinned peers, which failed packages:verify's fresh install; a unit test
instead compares ipBucket with the normalizeIP Better Auth's limiter runs.
An integration test shows two addresses in one /64 share one budget, and
the trusted-IP rule names the /64 grouping.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@nedtwigg
nedtwigg merged commit eaba6d3 into main Sep 23, 2026
1 check passed
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