fix: secure authentication request identity
Replace Better Auth's default trust of a caller-controlled X-Forwarded-For with a platform-owned client-address resolution boundary: direct mode (the safe default) trusts only the real socket peer, and an explicit trusted-proxy mode requires a header name, a concrete trusted-proxy list, and only honors a forwarded chain from an actual trusted peer. This closes a rate-limit bypass where rotating a spoofed header let a caller choose a fresh bucket on every request. Public sign-up is also disabled by default and requires an explicit typed opt-in, using Better Auth's own disableSignUp gate so no email-existence leak or second signup path is introduced. Co-Authored-By: Claude Sonnet 5 <[email protected]>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
5e09330001
commit
cafd513019
+88
-16
@@ -11,15 +11,22 @@ timeouts, idempotent shutdown, and pinned password-length boundaries) without ch
|
||||
above; see [ADR 0012](../architecture/decisions/0012-identity-origin-csrf-protection-under-test.md).
|
||||
Phase 4C adds Core's authorization foundation and, on top of this transport, a platform-owner
|
||||
bootstrap command plus an enforced explicit-grant HTTP API — see [Authorization](authorization.md).
|
||||
Phase 5D pass 1 hardened this same public perimeter against an independent security audit: a
|
||||
server-owned client-address resolution boundary for the rate limiter, public sign-up disabled by
|
||||
default, an explicit request-body-size policy for every public route (including a module route's
|
||||
own declared contract), and finite Node header/request timeouts — see
|
||||
[ADR 0017](../architecture/decisions/0017-public-request-perimeter-hardening.md).
|
||||
|
||||
## What this is, and is not
|
||||
|
||||
After this slice it is possible to start the API, publicly create an ordinary account, sign in,
|
||||
resolve a request to an Ekklesia principal, and sign out. It is **not** possible to verify an email
|
||||
address, recover a forgotten password, use any provider besides email/password, reach a Dashboard,
|
||||
or hold any role or permission — none of that exists yet. **Public registration is not ready for a
|
||||
real public launch after this PR**: there is no email delivery service, so an unverified email is
|
||||
trusted as entered and a forgotten password has no recovery path.
|
||||
After this slice it is possible to start the API, sign in, resolve a request to an Ekklesia
|
||||
principal, and sign out; public account creation is also possible once explicitly enabled (see
|
||||
[Public registration policy](#public-registration-policy)). It is **not** possible to verify an
|
||||
email address, recover a forgotten password, use any provider besides email/password, reach a
|
||||
Dashboard, or hold any role or permission — none of that exists yet. **Public registration is not
|
||||
ready for a real public launch**: there is no email delivery service, so an unverified email is
|
||||
trusted as entered and a forgotten password has no recovery path, and no durable, distributed
|
||||
abuse control exists yet even with signup enabled.
|
||||
|
||||
## Starting the API
|
||||
|
||||
@@ -48,6 +55,10 @@ No real values are shown here — configure per environment and never commit the
|
||||
| `EKKLESIA_IDENTITY_SECRET` | At least 32 characters, high entropy (for example `openssl rand -base64 32`). Same requirement as Phase 4A. |
|
||||
| `EKKLESIA_IDENTITY_BASE_URL` | An absolute `http`/`https` URL. `https` is required in production; loopback hosts are rejected in production. |
|
||||
| `EKKLESIA_IDENTITY_TRUSTED_ORIGINS` | Comma-separated list of absolute origins (no path/query/fragment, no wildcards). Used for both Better Auth's own CSRF/origin checks and the API's CORS policy — there is only one trusted-origin list. |
|
||||
| `EKKLESIA_PUBLIC_SIGNUP_ENABLED` | Optional. Exactly `"true"` to enable public sign-up. Defaults to disabled — omitted, misspelled, or any other value never enables it. See [Public registration policy](#public-registration-policy). |
|
||||
| `EKKLESIA_CLIENT_ADDRESS_MODE` | Optional. `"direct"` (default when unset) or `"trusted-proxy"`. See [Client-address resolution](#client-address-resolution). |
|
||||
| `EKKLESIA_CLIENT_ADDRESS_HEADER` | Required only when `EKKLESIA_CLIENT_ADDRESS_MODE=trusted-proxy`. The one forwarded-header name the reverse proxy is trusted to set (for example `x-forwarded-for`). |
|
||||
| `EKKLESIA_CLIENT_ADDRESS_TRUSTED_PROXIES` | Required only when `EKKLESIA_CLIENT_ADDRESS_MODE=trusted-proxy`. Comma-separated list of the proxy's own IPv4/IPv6 addresses or CIDR ranges — never a broad private range that also covers real clients. |
|
||||
| `NODE_ENV` | `development` explicitly opts into relaxed (loopback/`http`) origin validation for local development. Anything else, including unset, validates as production. |
|
||||
|
||||
## Routes
|
||||
@@ -93,7 +104,22 @@ permission, membership, or volunteer data.
|
||||
|
||||
## Public registration policy
|
||||
|
||||
Anyone may create an account (`emailAndPassword.enabled: true`, `disableSignUp: false`). A
|
||||
**Public sign-up is disabled by default** (`EKKLESIA_PUBLIC_SIGNUP_ENABLED` unset or anything other
|
||||
than exactly `"true"`). This is a strict, typed gate (`IdentityConfig.publicSignupEnabled`,
|
||||
`core/src/identity/config.ts`) wired directly to Better Auth's own `emailAndPassword.disableSignUp`
|
||||
flag (`core/src/identity/runtime.ts`) — there is no second, platform-owned signup gate, because the
|
||||
pinned release's own mechanism already has every required property: every `POST
|
||||
/api/auth/sign-up/email` call is rejected with a fixed, sanitized `400
|
||||
{"code":"EMAIL_PASSWORD_SIGN_UP_DISABLED"}` and `Cache-Control: no-store` *before* Better Auth ever
|
||||
looks up the email, so a disabled deployment never reveals whether an address is already
|
||||
registered. Sign-in, session resolution, and sign-out are entirely unaffected by this gate. Set
|
||||
`EKKLESIA_PUBLIC_SIGNUP_ENABLED=true` explicitly to enable it (development and test composition do
|
||||
this routinely; production composition must do so deliberately, with the same audit trail as any
|
||||
other configuration change). This pass adds no email verification, CAPTCHA, invitation, or durable
|
||||
distributed abuse control — enabling public signup in production without those remains explicitly
|
||||
not ready for a real public launch; see [ADR 0017](../architecture/decisions/0017-public-request-perimeter-hardening.md).
|
||||
|
||||
Once enabled, the rest of this section's behavior is unchanged from ADR 0011. A
|
||||
registered account is an **authentication identity** — proof that someone can present this email
|
||||
and password — not a church-membership record; it does not imply attendance or involvement, and
|
||||
nothing about it is recorded beyond Better Auth's own required `user`/`account` rows. Every
|
||||
@@ -160,8 +186,9 @@ for the root-cause investigation and the full account of what changed.
|
||||
|
||||
Cookies keep Better Auth's defaults: `HttpOnly` always, `SameSite=Lax` always, `Secure` when the
|
||||
configured base URL is `https://`. Cross-subdomain cookies are not enabled and no cookie `Domain`
|
||||
attribute is set, so every cookie is host-only. Trusted proxy headers are not configured in this
|
||||
slice — a deployment behind a reverse proxy is future work.
|
||||
attribute is set, so every cookie is host-only. See [Client-address resolution](#client-address-resolution)
|
||||
for how (and whether) a reverse proxy's forwarded headers are trusted — a separate concern from
|
||||
CORS/CSRF above.
|
||||
|
||||
## Logging
|
||||
|
||||
@@ -195,17 +222,61 @@ none of it reaches `console.error`, and separately asserts `onAPIError` is confi
|
||||
integration test forces a real identity-database failure through Better Auth's own sign-in handler
|
||||
and asserts nothing at all reaches `console.error`/`warn`/`log` in the real process.
|
||||
|
||||
## Client-address resolution
|
||||
|
||||
Better Auth's rate limiter (below) needs a trustworthy per-client identity, and the pinned
|
||||
release's own client-address resolution only ever reads request headers — it cannot see the real
|
||||
TCP socket peer. `apps/api` resolves and sanitizes that identity once, at the Node/API transport
|
||||
boundary (`apps/api/src/client-address.ts`, wrapping the `fetch` callback passed to
|
||||
`@hono/node-server`'s `serve()` in `apps/api/src/server.ts`), *before* Better Auth or any other
|
||||
route ever sees the request. The resolution policy itself (`core/src/identity/client-address.ts`)
|
||||
is framework-agnostic and lives in Core; only the Node-specific glue that reads
|
||||
`env.incoming.socket.remoteAddress` lives in `apps/api`.
|
||||
|
||||
- **Direct mode** (the default, `EKKLESIA_CLIENT_ADDRESS_MODE` unset or `"direct"`). Trusts only
|
||||
the actual immediate socket peer. No `X-Forwarded-For`, `Forwarded`, `CF-Connecting-IP`,
|
||||
`X-Real-IP`, or any other request header is ever consulted for identity.
|
||||
- **Trusted-proxy mode** (`EKKLESIA_CLIENT_ADDRESS_MODE=trusted-proxy`, with
|
||||
`EKKLESIA_CLIENT_ADDRESS_HEADER` and `EKKLESIA_CLIENT_ADDRESS_TRUSTED_PROXIES` both required).
|
||||
A forwarded header's content is trusted **only** when the actual immediate socket peer matches
|
||||
one of the configured trusted-proxy addresses/CIDR ranges; otherwise the peer address itself is
|
||||
used, exactly as in direct mode. A comma-separated forwarded chain is walked **right to left**:
|
||||
trailing hops matching a trusted proxy are skipped, and the first hop from the right that does
|
||||
not match is the resolved client address. A missing, empty, malformed, or entirely-trusted-hops
|
||||
chain from an otherwise-trusted peer fails closed with a sanitized
|
||||
`503 {"code":"CLIENT_ADDRESS_UNRESOLVED"}` — Better Auth, Core, and every module handler are all
|
||||
unreachable in that case. IPv4, IPv6, and IPv4-mapped IPv6 (`::ffff:a.b.c.d`, the form a
|
||||
dual-stack socket or a real proxy actually emits) all normalize consistently before comparison.
|
||||
|
||||
Whichever address is resolved is written into one fixed internal header
|
||||
(`TRUSTED_CLIENT_ADDRESS_HEADER = "x-ekklesia-client-address"`, `core/src/identity/runtime.ts`),
|
||||
**overwriting** — never appending to — any externally supplied copy of that exact header name, so
|
||||
a caller can never choose its own "resolved" identity by sending it directly. Better Auth is
|
||||
configured to trust only this one header (`advanced.ipAddress.ipAddressHeaders`), never its own
|
||||
default (`x-forwarded-for`) and never `trustedProxies` (that pinned-release option only inspects
|
||||
header *content*, never who actually connected — see [ADR 0017](../architecture/decisions/0017-public-request-perimeter-hardening.md)
|
||||
for why that is insufficient on its own).
|
||||
|
||||
**A reverse proxy in front of this API must be configured to *overwrite*, not append to, the
|
||||
configured forwarded header**, and the origin must not be reachable except through that proxy —
|
||||
neither is something application-level validation can enforce; both are the deployment's own
|
||||
network/firewall responsibility. `pnpm doctor`'s `identity.client-address` check distinguishes
|
||||
three states: safe direct mode, an explicitly configured (and structurally valid) trusted-proxy
|
||||
mode, and an invalid/contradictory configuration (which fails Doctor, not merely warns).
|
||||
|
||||
## Rate limiting
|
||||
|
||||
Better Auth's built-in rate limiting is always enabled, including by the executable entry point —
|
||||
it is never disabled. Its built-in per-path rules (a strict default of 3 requests per 10 seconds on
|
||||
sign-in/sign-up-prefixed paths) are preserved unmodified in production composition; only the
|
||||
integration test suite injects a narrow, explicit override so it is not itself rate-limited by its
|
||||
own many requests. Storage remains the default in-memory backend, which has two honest limitations:
|
||||
it is **per-process** (no protection across multiple API instances or restarts), and because no
|
||||
trusted-proxy IP policy is configured, Better Auth may fall back to a single shared bucket per path
|
||||
rather than a genuine per-client bucket. This is not yet the final, distributed, production-grade
|
||||
abuse-control design.
|
||||
own many requests. Storage remains the default in-memory backend, which is **per-process**: no
|
||||
protection across multiple API instances or restarts. Client identity for this limiter is now
|
||||
always the resolved, server-owned address described above — never a caller-controlled header — so
|
||||
rotating a spoofed `X-Forwarded-For` on every request no longer lets a caller choose a fresh
|
||||
bucket, and unrelated real peers are never collapsed into one shared bucket merely because no
|
||||
header was present. This closes the rate-limit-identity gap; it does not add distributed storage —
|
||||
a genuinely multi-instance deployment still needs a shared or edge limiter, reserved for pass 5.
|
||||
|
||||
## Module discovery, permission composition, and module runtime startup
|
||||
|
||||
@@ -369,8 +440,9 @@ runtime handle to stop in that case, since `startDiscoveredModuleRuntime` never
|
||||
## A real signup → sign-in → principal → sign-out transcript
|
||||
|
||||
Captured from this PR's own integration test against a real, migrated, disposable PostgreSQL
|
||||
database. Session tokens, cookie values, and generated ids below are sanitized/truncated examples,
|
||||
not real credentials.
|
||||
database, with `EKKLESIA_PUBLIC_SIGNUP_ENABLED=true` explicitly set (public sign-up is disabled by
|
||||
default — see [Public registration policy](#public-registration-policy)). Session tokens, cookie
|
||||
values, and generated ids below are sanitized/truncated examples, not real credentials.
|
||||
|
||||
```text
|
||||
POST /api/auth/sign-up/email
|
||||
|
||||
Reference in New Issue
Block a user