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:
ekklesia
2026-07-26 12:20:51 -05:00
co-authored by Claude Sonnet 5
parent 5e09330001
commit cafd513019
22 changed files with 1772 additions and 38 deletions
+88 -16
View File
@@ -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