Skip to content

Commit fbd92ad

Browse files
committed
refactor(plugin-auth): the forwarding-header list and its one copy live in an internal module
FORWARDED_CLIENT_ADDRESS_HEADERS moves off the published ./client-address entry into client-address-forwarding.ts, which is on no exports map and re-exported from neither the package root nor ./client-address. resolveClientAddress imports the list from it and decides as before. The three module-local copies of carryForwardedClientAddress are replaced by the one shared function beside the list. The changeset no longer names a subpath export. Claude-Session: https://claude.ai/code/session_01UmqzD97e14iws6hmVhgZ5y Co-authored-by: Claude <noreply@anthropic.com>
1 parent 486e46d commit fbd92ad

6 files changed

Lines changed: 52 additions & 61 deletions

File tree

‎.changeset/24877-bridge-transport.md‎

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,5 +9,4 @@ Clause-②: no
99
Eight auth routes answer by re-dispatching a request to better-auth in-process: `POST /api/v1/auth/admin/remove-user`, `/admin/has-permission`, `/admin/sso/register`, `/admin/sso/register-saml`, `/admin/sso/request-domain-verification`, `/admin/sso/verify-domain`, `/send-verification-email` and `/sys-session/revoke`. `AuthManager.handleRequest` establishes the re-dispatched request's client address by the client-address trust rule: `X-Forwarded-For` (its first entry), then `X-Real-IP`, count only when the stack declares `server.trustProxy`, and otherwise the address is the connection's peer address. Each route handed `handleRequest` the request alone, without the connection it arrived on. Six of them also rebuild the request before re-dispatching it, and the rebuilt request did not carry the forwarding headers.
1010

1111
- **Now.** Each route hands `handleRequest` the connection it was called on, as the main auth mount does. Each rebuilt request carries the incoming request's `X-Forwarded-For` and `X-Real-IP`. Without a declared trusted proxy, the re-dispatched request is judged on the connection's peer address, whatever those headers say. With `server.trustProxy` declared, it is judged on the forwarded client address, the same address as the request the route serves.
12-
- **For hosts.** `@objectstack/plugin-auth/client-address` exports `FORWARDED_CLIENT_ADDRESS_HEADERS`, the forwarding headers `resolveClientAddress` reads, in the order it reads them. A seam that builds a request of its own carries these headers by reading this list.
1312
- **Unchanged.** What each route accepts or refuses, and every answer it gives. `resolveClientAddress` decides exactly as before. The main auth mount, and the `allowed_ip_ranges` allow-list in front of every auth route, already judged on the connection.
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* The forwarding facts the client-address trust rule reads
5+
* (`./client-address.ts`, ADR-0069 D5/D7), and the one way a request this
6+
* plugin re-dispatches in-process carries them (#24877).
7+
*
8+
* INTERNAL. This module is not on the package's `exports` map, and neither the
9+
* package root (`index.ts`) nor the published `./client-address` entry
10+
* re-exports it: nothing outside this package reads the list.
11+
*
12+
* ⛔ Keep this module free of imports. The published `./client-address` entry
13+
* — the lean entry the runtime loads without the better-auth family — imports
14+
* the list from here, so a value import here would become a load-time
15+
* dependency of every consumer of that entry.
16+
*/
17+
18+
/**
19+
* The forwarded headers the rule reads, in the order it believes them, and
20+
* only when the deployment declares `server.trustProxy`: `X-Forwarded-For`
21+
* (its leftmost entry), then `X-Real-IP`. Lower-cased, as every adapter
22+
* delivers them. `resolveClientAddress` reads exactly this list.
23+
*/
24+
export const FORWARDED_CLIENT_ADDRESS_HEADERS: readonly string[] = Object.freeze(['x-forwarded-for', 'x-real-ip']);
25+
26+
/**
27+
* Carry the forwarding facts the rule reads ({@link FORWARDED_CLIENT_ADDRESS_HEADERS})
28+
* from the request a bridge serves onto the request it re-dispatches, as the
29+
* incoming request had them. `AuthManager.handleRequest` judges the
30+
* re-dispatched request by the rule, so it is judged on the same facts as the
31+
* request the bridge serves: the forwarded client address behind a declared
32+
* `server.trustProxy`, the connection peer otherwise, when the rule ignores
33+
* these headers. The bridges' header builders (`register-sso-provider.ts`,
34+
* `send-verification-email.ts`, `sys-session-revoke.ts`) call this; none of
35+
* them names a header itself.
36+
*/
37+
export function carryForwardedClientAddress(from: Headers, to: Headers): void {
38+
for (const name of FORWARDED_CLIENT_ADDRESS_HEADERS) {
39+
const value = from.get(name);
40+
if (value !== null) to.set(name, value);
41+
}
42+
}

‎packages/plugins/plugin-auth/src/client-address.ts‎

Lines changed: 7 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -38,19 +38,15 @@
3838
* `./client-address` subpath entry, like `./rate-limit-storage`, so the runtime
3939
* reaches it without loading the better-auth family (see `tsup.config.ts`).
4040
* ⛔ Keep this module free of imports: a value import here becomes a load-time
41-
* dependency of every consumer of the entry.
41+
* dependency of every consumer of the entry. The one exception is the list of
42+
* forwarded headers the rule reads, from `./client-address-forwarding.ts`,
43+
* which is internal and itself import-free (#24877).
4244
*/
4345

44-
/**
45-
* The forwarded headers the rule reads, in the order it believes them, and
46-
* only when the deployment declares `server.trustProxy`: `X-Forwarded-For`
47-
* (its leftmost entry), then `X-Real-IP`. Lower-cased, as every adapter
48-
* delivers them. {@link resolveClientAddress} reads exactly this list, so a
49-
* seam that builds a request of its own from one it was handed — an
50-
* in-process re-dispatch — carries the facts the rule reads by iterating it,
51-
* never a hand-copied set of names (#24877).
52-
*/
53-
export const FORWARDED_CLIENT_ADDRESS_HEADERS: readonly string[] = Object.freeze(['x-forwarded-for', 'x-real-ip']);
46+
// [#24877] The forwarded headers this rule reads, in the order it believes
47+
// them. The list lives in an internal module so the in-process re-dispatches
48+
// carry exactly these facts without the list being published on this entry.
49+
import { FORWARDED_CLIENT_ADDRESS_HEADERS } from './client-address-forwarding.js';
5450

5551
/** The three facts the rule decides from. */
5652
export interface ClientAddressInput {

‎packages/plugins/plugin-auth/src/register-sso-provider.ts‎

Lines changed: 1 addition & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@
2222
* (per-environment runtime) — mirroring `runSetInitialPassword`.
2323
*/
2424

25-
import { FORWARDED_CLIENT_ADDRESS_HEADERS } from './client-address.js';
25+
import { carryForwardedClientAddress } from './client-address-forwarding.js';
2626
import { inProcessRedispatchUrl } from './in-process-redispatch.js';
2727
import { readJsonRequestBody } from './request-json-body.js';
2828

@@ -422,22 +422,6 @@ function rewriteSsoAdminUrl(request: Request, fromSuffix: RegExp, toPath: string
422422
}
423423
}
424424

425-
/**
426-
* [#24877] Carry the forwarding facts the client-address trust rule reads
427-
* (`FORWARDED_CLIENT_ADDRESS_HEADERS`) from the request a bridge serves onto
428-
* the request it re-dispatches, as the incoming request had them.
429-
* `AuthManager.handleRequest` judges the re-dispatched request by that rule,
430-
* so behind a declared `server.trustProxy` it reads the forwarded client
431-
* address the incoming request carried; without one the rule ignores these
432-
* headers and judges on the connection peer.
433-
*/
434-
function carryForwardedClientAddress(from: Headers, to: Headers): void {
435-
for (const name of FORWARDED_CLIENT_ADDRESS_HEADERS) {
436-
const value = from.get(name);
437-
if (value !== null) to.set(name, value);
438-
}
439-
}
440-
441425
/**
442426
* The headers every re-dispatch below carries: the caller's credential
443427
* (cookie / bearer), its `Origin` (or the request's own), and its forwarding

‎packages/plugins/plugin-auth/src/send-verification-email.ts‎

Lines changed: 1 addition & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@
3030
*/
3131

3232
import type { AuthRequestHandler } from './register-sso-provider.js';
33-
import { FORWARDED_CLIENT_ADDRESS_HEADERS } from './client-address.js';
33+
import { carryForwardedClientAddress } from './client-address-forwarding.js';
3434
import { inProcessRedispatchUrl } from './in-process-redispatch.js';
3535
import { readJsonRequestBody } from './request-json-body.js';
3636

@@ -43,21 +43,6 @@ export interface ResendVerificationEmailResult {
4343

4444
const trimStr = (v: unknown): string => (typeof v === 'string' ? v.trim() : '');
4545

46-
/**
47-
* [#24877] Carry the forwarding facts the client-address trust rule reads
48-
* (`FORWARDED_CLIENT_ADDRESS_HEADERS`) from the request this route serves onto
49-
* a request it re-dispatches, as the incoming request had them, so
50-
* `AuthManager.handleRequest` judges both on the same facts: the forwarded
51-
* client address behind a declared `server.trustProxy`, the connection peer
52-
* otherwise.
53-
*/
54-
function carryForwardedClientAddress(from: Headers, to: Headers): void {
55-
for (const name of FORWARDED_CLIENT_ADDRESS_HEADERS) {
56-
const value = from.get(name);
57-
if (value !== null) to.set(name, value);
58-
}
59-
}
60-
6146
/**
6247
* Resolve the caller's own email by re-dispatching a `/get-session` through the
6348
* same better-auth handler (best-effort). Returns `undefined` when there is no

‎packages/plugins/plugin-auth/src/sys-session-revoke.ts‎

Lines changed: 1 addition & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,7 @@
7070
import { SystemObjectName } from '@objectstack/spec/system';
7171
import type { AuthSessionApi } from '@objectstack/spec/contracts';
7272
import { inProcessSessionReadInput } from '@objectstack/types';
73-
import { FORWARDED_CLIENT_ADDRESS_HEADERS } from './client-address.js';
73+
import { carryForwardedClientAddress } from './client-address-forwarding.js';
7474
import { inProcessRedispatchUrl } from './in-process-redispatch.js';
7575
import {
7676
PLATFORM_ADMIN_REFUSAL_MESSAGES,
@@ -209,21 +209,6 @@ export function sysSessionRevokeDepsFor(manager: SysSessionRevokeManager, basePa
209209
};
210210
}
211211

212-
/**
213-
* [#24877] Carry the forwarding facts the client-address trust rule reads
214-
* (`FORWARDED_CLIENT_ADDRESS_HEADERS`) from the request this route serves onto
215-
* the request the owner lane re-dispatches, as the incoming request had them,
216-
* so `AuthManager.handleRequest` judges both on the same facts: the forwarded
217-
* client address behind a declared `server.trustProxy`, the connection peer
218-
* otherwise.
219-
*/
220-
function carryForwardedClientAddress(from: Headers, to: Headers): void {
221-
for (const name of FORWARDED_CLIENT_ADDRESS_HEADERS) {
222-
const value = from.get(name);
223-
if (value !== null) to.set(name, value);
224-
}
225-
}
226-
227212
/**
228213
* Owner lane: better-auth's `/revoke-session`, with the resolved token and the
229214
* caller's own credentials and forwarding facts. Only status and body come

0 commit comments

Comments
 (0)