@stonyx/oauth 0.1.1-alpha.17 → 0.1.1-alpha.19

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -86,20 +86,74 @@ issue time, and sends the plaintext to the client as a cookie:
86
86
  | `HttpOnly` | set | script must not be able to read or forge the binding value |
87
87
  | `SameSite` | `Lax` | **required.** The callback is a cross-site, top-level `GET` navigation from the provider. `SameSite=Strict` withholds the cookie on exactly that request and breaks login outright; `SameSite=None` requires `Secure` and widens exposure for no benefit |
88
88
  | `Path` | `/auth` | the cookie is only ever read by the callback route |
89
- | `Secure` | set on every host except loopback (`localhost`, `127.0.0.0/8`, `::1`, `0.0.0.0`) | deriving it from `req.secure` would omit it in the standard production topology: behind a TLS-terminating proxy Express reports the request as plaintext unless `trust proxy` is enabled, and `@stonyx/rest-server` leaves that off by default. A non-loopback plaintext deployment therefore cannot store this cookie — that failure is loud and deliberate, in preference to a silently insecure production cookie |
89
+ | `Secure` | set on every host except loopback | deriving it from `req.secure` would omit it in the standard production topology: behind a TLS-terminating proxy Express reports the request as plaintext unless `trust proxy` is enabled, and `@stonyx/rest-server` leaves that off by default. A non-loopback plaintext deployment therefore cannot store this cookie — that failure is loud and deliberate, in preference to a silently insecure production cookie. The exemption rule is exact; see below |
90
90
  | `Max-Age` | 600 (10 minutes) | matches the pending state's lifetime |
91
91
 
92
+ #### Which hosts are exempt from `Secure`
93
+
94
+ The exemption is decided by parsing the `Host` header and testing the result for
95
+ membership. It is never a prefix or suffix match on the raw header value,
96
+ because `Host` is attacker-controllable from any non-browser client.
97
+
98
+ Exempt, and nothing else:
99
+
100
+ - `localhost` (case-insensitive)
101
+ - any address in `127.0.0.0/8` — a dotted quad of **four** decimal octets whose
102
+ first is `127`, so `127.0.0.2` is exempt and `127.evil.com` is not. The
103
+ resolver shorthands `127.1` and `127.0.0` are *not* exempt: accepting a
104
+ variable number of octets is how a membership test decays back into a prefix
105
+ match. Use the full form
106
+ - `::1` (and its expanded form), and the IPv4-mapped loopback spellings
107
+ `::ffff:127.0.0.1` and `::ffff:7f00:1`
108
+ - the wildcard bind addresses `0.0.0.0` and `::`
109
+
110
+ Everything else gets `Secure`, including:
111
+
112
+ - **`*.localhost`.** A `.localhost` suffix was exempt in an earlier draft of
113
+ this module and is not any more: a split-horizon vhost under that zone would
114
+ have shipped the binding value in cleartext. Develop against `localhost` or
115
+ `127.0.0.1`, which reach the same server.
116
+ - any `Host` that is not a well-formed `host[:port]` — a non-numeric port, a
117
+ userinfo segment (`localhost:80@evil.com`), a comma-joined multi-value, or a
118
+ character a registered name may not contain
119
+ - a request carrying **more than one `Host` header**. Node collapses repeats
120
+ into the first value, so an upstream component that prepends rather than
121
+ replaces a `Host:` line could otherwise downgrade the cookie on a response to
122
+ someone else. RFC 9112 section 3.2 makes such a request invalid; this module
123
+ treats it as unattributable.
124
+ - a request carrying **no `Host` header** at all
125
+
126
+ `X-Forwarded-Host` is deliberately **not** consulted.
127
+
92
128
  `GET /auth/callback/:provider` accepts the callback only when all of the
93
129
  following hold, and mints no session otherwise:
94
130
 
95
131
  - the `state` is one this server issued and has not already been used
96
132
  - it was issued for **this** provider
97
133
  - it was issued less than 10 minutes ago
98
- - the request carries the binding cookie whose value hashes to the stored digest
134
+ - the request carries a binding cookie whose value hashes to the stored digest
135
+
136
+ A client can hold more than one cookie of that name — a sibling subdomain can
137
+ set one on the parent domain, and the browser sends every applicable cookie in
138
+ a single header. **Every** value carrying the name is tried, up to eight, and
139
+ the callback is accepted if any of them matches. Reading only the first would
140
+ let anyone who can plant a cookie on the victim's domain deny them login
141
+ permanently: RFC 6265 section 5.4 sorts a planted cookie ahead of the real one
142
+ on equal paths, and the state is burned on every recognised callback, so
143
+ retrying does not help. Trying all of them concedes nothing, because the
144
+ attacker would still have to present the victim's own binding value.
99
145
 
100
146
  The state and the cookie are both single-use: the pending record is consumed on
101
- any callback that presents a recognised `state` — successful or not — and the
102
- callback response clears the cookie.
147
+ any callback that presents a recognised `state` — successful or not — and that
148
+ same response clears the cookie, scoped to `Path=/auth` so the deletion actually
149
+ reaches the cookie that was set.
150
+
151
+ A callback that consumes **nothing** — an unrecognised or absent `state`, a
152
+ provider `error`, a missing `code` — leaves the cookie alone. That is not
153
+ tidiness: `/auth/callback/:provider?code=1` is a request any attacker can induce
154
+ as a top-level navigation while a victim is still at the provider's consent
155
+ screen, and clearing on it would delete the victim's binding cookie without
156
+ touching anything the server could later notice.
103
157
 
104
158
  Two distinct failure modes surface on two different routes. They are unrelated,
105
159
  and the route is the fastest way to tell them apart:
@@ -121,7 +175,17 @@ and the route is the fastest way to tell them apart:
121
175
  Every callback rejection is logged server-side with its reason
122
176
  (`OAuth: callback rejected — ...`), which distinguishes an unknown state, a
123
177
  wrong provider, an expired state, a missing binding value and a wrong binding
124
- value. The client-facing `auth_failed` stays deliberately opaque.
178
+ value. Those five strings are this module's own; nothing caller-controlled
179
+ appears in them. The client-facing `auth_failed` stays deliberately opaque.
180
+
181
+ A failure thrown **below** the state check — by `getProvider`, by a provider's
182
+ `exchangeCode`, `fetchUserInfo` or `normalizeUser`, by an `authenticate`
183
+ subscriber, or by session creation — logs the fixed line
184
+ `OAuth: callback failed after state validation` and **nothing from the error
185
+ itself**. Custom providers are consumer code, and a provider error that carries
186
+ request context would otherwise put a `clientSecret` or the caller-supplied
187
+ `code` into your logs verbatim. If you need that detail, log it inside your
188
+ provider, where you control what goes in it.
125
189
 
126
190
  A failed callback **cannot be retried**: the pending record is consumed on any
127
191
  callback presenting a recognised `state`, so refreshing the error page or going
@@ -139,14 +203,18 @@ carry the binding value between the two calls:
139
203
  ```javascript
140
204
  const { url, bindingValue } = oauth.getAuthorizationUrl('discord');
141
205
  // hand bindingValue to the client, then on the callback:
142
- const session = await oauth.handleCallback('discord', code, state, bindingValue);
206
+ const session = await oauth.handleCallback('discord', code, state, [bindingValue]);
143
207
  ```
144
208
 
209
+ The fourth argument is an **array** — every value the client presented under the
210
+ binding cookie's name. A driver that holds exactly one value passes
211
+ `[bindingValue]`; one that holds none passes `[]`.
212
+
145
213
  > **Changed in the release that fixes [#36](https://github.com/abofs/stonyx-oauth/issues/36):**
146
214
  > `getAuthorizationUrl(provider)` returned a URL string and now returns
147
215
  > `{ url, bindingValue }`; `handleCallback(provider, code, state)` takes a
148
- > fourth argument, the client's binding value. Applications using the
149
- > self-registering `/auth` routes need no changes.
216
+ > required fourth argument, the client's binding values, as an array.
217
+ > Applications using the self-registering `/auth` routes need no changes.
150
218
 
151
219
  ## Officially Supported Providers
152
220
 
@@ -7,7 +7,7 @@ interface OAuthInstance {
7
7
  frontendCallbackUrl?: string;
8
8
  getSession(sessionId: string): unknown;
9
9
  getAuthorizationUrl(providerName: string): AuthorizationRequest;
10
- handleCallback(providerName: string, code: string, stateToken: string, bindingValue: string | undefined): Promise<{
10
+ handleCallback(providerName: string, code: string, stateToken: string, bindingValues: readonly string[]): Promise<{
11
11
  sessionId: string;
12
12
  expiresAt: number;
13
13
  }>;
@@ -35,6 +35,13 @@ interface ResponseLike {
35
35
  }
36
36
  interface RouteRequest {
37
37
  headers: Record<string, string | undefined>;
38
+ /**
39
+ * Node's flat `[name, value, name, value, ...]` header list, when the runtime
40
+ * supplies it. Read only to detect a *duplicate* `Host`: Node collapses
41
+ * repeats into the first value, so `req.headers.host` alone cannot tell an
42
+ * unambiguous origin from a smuggled one.
43
+ */
44
+ rawHeaders?: string[];
38
45
  params: Record<string, string>;
39
46
  query: Record<string, string>;
40
47
  secure?: boolean;
@@ -73,10 +80,64 @@ export default class AuthRequest extends Request {
73
80
  * at the first login and is loud. The alternative fails silently, in
74
81
  * production, on the one attribute protecting the value this whole mechanism
75
82
  * is built around.
83
+ *
84
+ * The exemption is decided by *parsing* the `Host` header and testing the
85
+ * result for membership, never by matching a prefix or a suffix on the raw
86
+ * value — `Host` is attacker-controllable on any non-browser client, and a
87
+ * security predicate written as a substring match drifts. Every shape that
88
+ * cannot be parsed as a bare `host[:port]`, and every request with more than
89
+ * one `Host`, fails secure.
76
90
  */
77
91
  isSecureContext(req: RouteRequest): boolean;
92
+ /**
93
+ * True when the request carried more than one `Host` header.
94
+ *
95
+ * Node keeps the first and discards the rest, so a component that *prepends*
96
+ * a `Host:` line — request smuggling, or a proxy that appends rather than
97
+ * replaces — can make `req.headers.host` read `localhost` on a request whose
98
+ * real origin is public. RFC 9112 section 3.2 makes such a request invalid;
99
+ * this treats it as unattributable and fails secure rather than trusting it.
100
+ */
101
+ static hasAmbiguousHost(req: RouteRequest): boolean;
102
+ /**
103
+ * The hostname component of a `Host` header, lowercased, or `undefined` when
104
+ * the value is not a well-formed `host[:port]`.
105
+ *
106
+ * `host.split(':')[0]` is not enough: it truncates at the *first* colon, so
107
+ * `localhost:80@evil.com` reduces to `localhost`. The port is therefore
108
+ * required to be decimal, and the hostname to contain only characters a
109
+ * registered name may contain.
110
+ */
111
+ static parseHostname(host: string): string | undefined;
112
+ /**
113
+ * Whether a parsed hostname is a loopback development origin.
114
+ *
115
+ * Membership tests, never prefix or suffix tests. `startsWith('127.')`
116
+ * matched `127.evil.com`, a perfectly registerable name (RFC 1123 permits a
117
+ * leading digit in a label), and `endsWith('.localhost')` exempted an entire
118
+ * suffix — so a `.localhost` split-horizon vhost shipped the binding value in
119
+ * cleartext. The `.localhost` exemption is withdrawn rather than tightened:
120
+ * the README documented `127.0.0.0/8`, `localhost`, `::1` and `0.0.0.0` and
121
+ * never documented it, and a developer on `app.localhost` reaches the same
122
+ * server on `localhost` or `127.0.0.1`.
123
+ */
124
+ static isLoopbackHost(hostname: string): boolean;
78
125
  setBindingCookie(req: RouteRequest, bindingValue: string): boolean;
79
- readBindingCookie(req: RouteRequest): string | undefined;
126
+ /**
127
+ * Every value the client presented under the binding cookie's name.
128
+ *
129
+ * Not the first one. A browser sends every applicable cookie in a single
130
+ * header, and a sibling subdomain can set a same-named cookie on the parent
131
+ * domain that RFC 6265 section 5.4 orders *ahead* of the real one — so
132
+ * returning on the first name match handed an attacker a permanent,
133
+ * unauthenticated denial of login for any victim they could plant a cookie
134
+ * on. `Secure`, `HttpOnly` and `SameSite` do not constrain that: the attacker
135
+ * is writing, not reading.
136
+ *
137
+ * Bounded at `MAX_BINDING_COOKIE_CANDIDATES`, so the work an unauthenticated
138
+ * caller can ask for is capped whatever the header contains.
139
+ */
140
+ readBindingCookies(req: RouteRequest): string[];
80
141
  clearBindingCookie(req: RouteRequest): void;
81
142
  }
82
143
  export {};
@@ -1,11 +1,52 @@
1
1
  import { Request } from '@stonyx/rest-server';
2
2
  import log from 'stonyx/log';
3
- import { STATE_COOKIE_NAME, STATE_COOKIE_PATH, STATE_COOKIE_SAME_SITE, STATE_TTL_MS, } from './constants.js';
3
+ import { StateRejection } from './state-store.js';
4
+ import { MAX_BINDING_COOKIE_CANDIDATES, STATE_COOKIE_NAME, STATE_COOKIE_PATH, STATE_COOKIE_SAME_SITE, STATE_TTL_MS, } from './constants.js';
4
5
  /**
5
- * Hosts treated as a development origin, and the only ones exempt from
6
- * `Secure` on the binding cookie. See `AuthRequest.isSecureContext`.
6
+ * Hosts treated as a development origin by exact match, and together with
7
+ * `127.0.0.0/8` and the IPv4-mapped IPv6 spellings of it — the only ones exempt
8
+ * from `Secure` on the binding cookie. See `AuthRequest.isSecureContext`.
9
+ *
10
+ * `0.0.0.0` and `::` are the wildcard bind addresses a developer reaches a
11
+ * local server on; `127.0.0.1` is covered by the `127.0.0.0/8` test rather than
12
+ * listed here, so the two are not silently redundant.
7
13
  */
8
- const LOOPBACK_HOSTS = new Set(['localhost', '127.0.0.1', '::1', '0.0.0.0']);
14
+ const LOOPBACK_HOSTS = new Set(['localhost', '::1', '0:0:0:0:0:0:0:1', '0.0.0.0', '::']);
15
+ /** `host` values whose port component is anything but a decimal port are rejected. */
16
+ const PORT_PATTERN = /^\d{1,5}$/;
17
+ /**
18
+ * The characters RFC 1123 permits in a registered hostname, plus `.`.
19
+ *
20
+ * Anything else — `@`, `,`, whitespace, `/` — means the value is not a bare
21
+ * hostname, and the caller fails secure rather than guessing. This is what
22
+ * rejects `localhost:80@evil.com` and a comma-joined multi-value `Host`.
23
+ */
24
+ const HOSTNAME_PATTERN = /^[A-Za-z0-9._-]+$/;
25
+ /** A dotted-quad whose first octet is 127, i.e. real `127.0.0.0/8` membership. */
26
+ function isLoopbackIpv4(hostname) {
27
+ const octets = hostname.split('.');
28
+ if (octets.length !== 4)
29
+ return false;
30
+ if (!octets.every(octet => /^\d{1,3}$/.test(octet) && Number(octet) <= 255))
31
+ return false;
32
+ return Number(octets[0]) === 127;
33
+ }
34
+ /**
35
+ * IPv4-mapped IPv6 loopback, in both spellings a dual-stack listener produces:
36
+ * `::ffff:127.0.0.1` and `::ffff:7f00:1`.
37
+ */
38
+ function isLoopbackIpv6(hostname) {
39
+ const mapped = /^::ffff:(.+)$/.exec(hostname);
40
+ if (!mapped)
41
+ return false;
42
+ const rest = mapped[1];
43
+ if (isLoopbackIpv4(rest))
44
+ return true;
45
+ const hextets = /^([0-9a-f]{1,4}):([0-9a-f]{1,4})$/.exec(rest);
46
+ if (!hextets)
47
+ return false;
48
+ return parseInt(hextets[1], 16) >>> 8 === 127;
49
+ }
9
50
  export default class AuthRequest extends Request {
10
51
  oauth;
11
52
  constructor(oauth) {
@@ -41,7 +82,7 @@ export default class AuthRequest extends Request {
41
82
  '/callback/:provider': async (req, state) => {
42
83
  const { provider: providerName } = req.params;
43
84
  const { code, state: stateToken, error } = req.query;
44
- const bindingValue = this.readBindingCookie(req);
85
+ const bindingValues = this.readBindingCookies(req);
45
86
  if (error) {
46
87
  if (this.oauth.frontendCallbackUrl) {
47
88
  state.redirect = `${this.oauth.frontendCallbackUrl}?error=${encodeURIComponent(error)}`;
@@ -51,15 +92,11 @@ export default class AuthRequest extends Request {
51
92
  }
52
93
  if (!code)
53
94
  return 400;
54
- // The binding value is single-use, so this callback is the end of that
55
- // cookie's life — but only from here down, where the state is actually
56
- // consumed. Clearing above the two early returns denied login to a
57
- // client still at the provider's consent screen, via an
58
- // attacker-induced navigation to `?error=...` that needs no knowledge
59
- // of the victim's state at all.
60
- this.clearBindingCookie(req);
61
95
  try {
62
- const session = await this.oauth.handleCallback(providerName, code, stateToken, bindingValue);
96
+ const session = await this.oauth.handleCallback(providerName, code, stateToken, bindingValues);
97
+ // The binding value is single-use and the state has now been
98
+ // consumed, so this is the end of that cookie's life.
99
+ this.clearBindingCookie(req);
63
100
  if (this.oauth.frontendCallbackUrl) {
64
101
  const params = new URLSearchParams({
65
102
  sessionId: session.sessionId,
@@ -71,13 +108,51 @@ export default class AuthRequest extends Request {
71
108
  return session;
72
109
  }
73
110
  catch (rejection) {
111
+ // Clear only when this request actually spent the cookie.
112
+ //
113
+ // Moving the clear below the `error` and `!code` returns was not
114
+ // enough: it still ran unconditionally for any request carrying a
115
+ // `code`, and `code` is attacker-supplied and unvalidated. So
116
+ // `?code=1` — one query parameter, no knowledge of the victim's state
117
+ // — deleted the binding cookie of a client still at the provider's
118
+ // consent screen, leaving their pending state untouched so nothing
119
+ // was detectable server-side, and their real callback then failed.
120
+ //
121
+ // `StateRejection.consumed` is the only thing that distinguishes
122
+ // "nothing of this client's was touched" from "one attempt was
123
+ // spent". Anything that is not a `StateRejection` was thrown below
124
+ // the state check, which means the record was already burned.
125
+ if (!(rejection instanceof StateRejection) || rejection.consumed) {
126
+ this.clearBindingCookie(req);
127
+ }
74
128
  // `StateStore.consume` distinguishes five rejection reasons that
75
129
  // otherwise collapse into one opaque outcome with no server-side
76
130
  // signal at all. The client-facing `auth_failed` stays opaque; the
77
- // server has no reason to be. The messages are fixed strings, so
78
- // nothing caller-controlled reaches the log.
79
- const reason = rejection instanceof Error ? rejection.message : String(rejection);
80
- log.error(`OAuth: callback rejected ${reason}`);
131
+ // server has no reason to be.
132
+ //
133
+ // Only a `StateRejection`'s message is logged, and those are the
134
+ // fixed strings in `STATE_REJECTION`. The `try` above spans far more
135
+ // than `consume` — `getProvider`, `TokenManager.getTokens` ->
136
+ // `flow.exchangeCode`, `flow.fetchUserInfo`, `flow.normalizeUser`,
137
+ // `emit('authenticate')`, `sessionManager.create` — and three of
138
+ // those are consumer-overridable through the documented
139
+ // `providers.<name>.module` extension point. A provider that puts
140
+ // request context in its error, which is ordinary practice, would
141
+ // otherwise land its `clientSecret` and the caller-supplied `code` in
142
+ // the log verbatim; `@stonyx/logs` appends content raw when
143
+ // `logToFile` is enabled, so an echoed `code` is also a CRLF
144
+ // log-forging primitive for an unauthenticated caller. Before this
145
+ // module logged anything, all of that was swallowed.
146
+ //
147
+ // Anything below the state check therefore gets a fixed
148
+ // discriminator, and the detail is left to whatever the provider
149
+ // itself logs.
150
+ if (rejection instanceof StateRejection) {
151
+ log.error(`OAuth: callback rejected — ${rejection.message}`);
152
+ }
153
+ else {
154
+ log.error('OAuth: callback failed after state validation');
155
+ }
81
156
  if (this.oauth.frontendCallbackUrl) {
82
157
  state.redirect = `${this.oauth.frontendCallbackUrl}?error=auth_failed`;
83
158
  return;
@@ -118,24 +193,95 @@ export default class AuthRequest extends Request {
118
193
  * at the first login and is loud. The alternative fails silently, in
119
194
  * production, on the one attribute protecting the value this whole mechanism
120
195
  * is built around.
196
+ *
197
+ * The exemption is decided by *parsing* the `Host` header and testing the
198
+ * result for membership, never by matching a prefix or a suffix on the raw
199
+ * value — `Host` is attacker-controllable on any non-browser client, and a
200
+ * security predicate written as a substring match drifts. Every shape that
201
+ * cannot be parsed as a bare `host[:port]`, and every request with more than
202
+ * one `Host`, fails secure.
121
203
  */
122
204
  isSecureContext(req) {
123
205
  if (req.secure === true)
124
206
  return true;
207
+ if (AuthRequest.hasAmbiguousHost(req))
208
+ return true;
125
209
  const host = req.headers.host;
126
210
  if (!host)
127
211
  return true;
128
- // `[::1]:2666` -> `::1`; `localhost:2666` -> `localhost`.
129
- const hostname = (host.startsWith('[')
130
- ? host.slice(1, host.indexOf(']'))
131
- : host.split(':')[0]).toLowerCase();
132
- if (LOOPBACK_HOSTS.has(hostname))
133
- return false;
134
- if (hostname.startsWith('127.'))
135
- return false;
136
- if (hostname === 'localhost' || hostname.endsWith('.localhost'))
212
+ const hostname = AuthRequest.parseHostname(host);
213
+ if (hostname === undefined)
214
+ return true;
215
+ return !AuthRequest.isLoopbackHost(hostname);
216
+ }
217
+ /**
218
+ * True when the request carried more than one `Host` header.
219
+ *
220
+ * Node keeps the first and discards the rest, so a component that *prepends*
221
+ * a `Host:` line — request smuggling, or a proxy that appends rather than
222
+ * replaces — can make `req.headers.host` read `localhost` on a request whose
223
+ * real origin is public. RFC 9112 section 3.2 makes such a request invalid;
224
+ * this treats it as unattributable and fails secure rather than trusting it.
225
+ */
226
+ static hasAmbiguousHost(req) {
227
+ const raw = req.rawHeaders;
228
+ if (!Array.isArray(raw))
137
229
  return false;
138
- return true;
230
+ let seen = 0;
231
+ for (let index = 0; index < raw.length; index += 2) {
232
+ if (typeof raw[index] === 'string' && raw[index].toLowerCase() === 'host')
233
+ seen++;
234
+ }
235
+ return seen > 1;
236
+ }
237
+ /**
238
+ * The hostname component of a `Host` header, lowercased, or `undefined` when
239
+ * the value is not a well-formed `host[:port]`.
240
+ *
241
+ * `host.split(':')[0]` is not enough: it truncates at the *first* colon, so
242
+ * `localhost:80@evil.com` reduces to `localhost`. The port is therefore
243
+ * required to be decimal, and the hostname to contain only characters a
244
+ * registered name may contain.
245
+ */
246
+ static parseHostname(host) {
247
+ if (host.startsWith('[')) {
248
+ const close = host.indexOf(']');
249
+ if (close === -1)
250
+ return undefined;
251
+ const port = host.slice(close + 1);
252
+ if (port !== '' && !(port.startsWith(':') && PORT_PATTERN.test(port.slice(1))))
253
+ return undefined;
254
+ const literal = host.slice(1, close);
255
+ if (!/^[0-9A-Fa-f:.]+$/.test(literal))
256
+ return undefined;
257
+ return literal.toLowerCase();
258
+ }
259
+ const colon = host.indexOf(':');
260
+ if (colon === -1)
261
+ return HOSTNAME_PATTERN.test(host) ? host.toLowerCase() : undefined;
262
+ if (!PORT_PATTERN.test(host.slice(colon + 1)))
263
+ return undefined;
264
+ const name = host.slice(0, colon);
265
+ return HOSTNAME_PATTERN.test(name) ? name.toLowerCase() : undefined;
266
+ }
267
+ /**
268
+ * Whether a parsed hostname is a loopback development origin.
269
+ *
270
+ * Membership tests, never prefix or suffix tests. `startsWith('127.')`
271
+ * matched `127.evil.com`, a perfectly registerable name (RFC 1123 permits a
272
+ * leading digit in a label), and `endsWith('.localhost')` exempted an entire
273
+ * suffix — so a `.localhost` split-horizon vhost shipped the binding value in
274
+ * cleartext. The `.localhost` exemption is withdrawn rather than tightened:
275
+ * the README documented `127.0.0.0/8`, `localhost`, `::1` and `0.0.0.0` and
276
+ * never documented it, and a developer on `app.localhost` reaches the same
277
+ * server on `localhost` or `127.0.0.1`.
278
+ */
279
+ static isLoopbackHost(hostname) {
280
+ if (LOOPBACK_HOSTS.has(hostname))
281
+ return true;
282
+ if (isLoopbackIpv4(hostname))
283
+ return true;
284
+ return isLoopbackIpv6(hostname);
139
285
  }
140
286
  setBindingCookie(req, bindingValue) {
141
287
  const { res } = req;
@@ -149,11 +295,28 @@ export default class AuthRequest extends Request {
149
295
  });
150
296
  return true;
151
297
  }
152
- readBindingCookie(req) {
298
+ /**
299
+ * Every value the client presented under the binding cookie's name.
300
+ *
301
+ * Not the first one. A browser sends every applicable cookie in a single
302
+ * header, and a sibling subdomain can set a same-named cookie on the parent
303
+ * domain that RFC 6265 section 5.4 orders *ahead* of the real one — so
304
+ * returning on the first name match handed an attacker a permanent,
305
+ * unauthenticated denial of login for any victim they could plant a cookie
306
+ * on. `Secure`, `HttpOnly` and `SameSite` do not constrain that: the attacker
307
+ * is writing, not reading.
308
+ *
309
+ * Bounded at `MAX_BINDING_COOKIE_CANDIDATES`, so the work an unauthenticated
310
+ * caller can ask for is capped whatever the header contains.
311
+ */
312
+ readBindingCookies(req) {
153
313
  const header = req.headers.cookie;
154
314
  if (!header)
155
- return undefined;
315
+ return [];
316
+ const values = [];
156
317
  for (const part of header.split(';')) {
318
+ if (values.length >= MAX_BINDING_COOKIE_CANDIDATES)
319
+ break;
157
320
  const separator = part.indexOf('=');
158
321
  if (separator === -1)
159
322
  continue;
@@ -164,9 +327,9 @@ export default class AuthRequest extends Request {
164
327
  // `decodeURIComponent` throws `URIError` on malformed input, which any
165
328
  // unauthenticated caller can supply, turning the first line of the
166
329
  // callback into a 500 with a stack trace.
167
- return part.slice(separator + 1).trim();
330
+ values.push(part.slice(separator + 1).trim());
168
331
  }
169
- return undefined;
332
+ return values;
170
333
  }
171
334
  clearBindingCookie(req) {
172
335
  const { res } = req;
@@ -5,3 +5,15 @@ export declare const STATE_COOKIE_SAME_SITE = "lax";
5
5
  export declare const STATE_TTL_MS: number;
6
6
  /** Entropy of the client-held binding value, in bytes. */
7
7
  export declare const BINDING_VALUE_BYTES = 32;
8
+ /**
9
+ * Most values carrying `STATE_COOKIE_NAME` that a single callback will try.
10
+ *
11
+ * A client can hold more than one cookie of the same name — a sibling
12
+ * subdomain can set one on the parent domain, and the browser sends every
13
+ * applicable cookie in one header. All of them are tried, so a planted cookie
14
+ * cannot deny login by sorting ahead of the real one; the cap bounds the work
15
+ * an unauthenticated caller can ask for. It is not a brute-force control: the
16
+ * pending record is consumed on recognition, so a state gets one attempt
17
+ * whatever the cap.
18
+ */
19
+ export declare const MAX_BINDING_COOKIE_CANDIDATES = 8;
package/dist/constants.js CHANGED
@@ -14,3 +14,15 @@ export const STATE_COOKIE_SAME_SITE = 'lax';
14
14
  export const STATE_TTL_MS = 10 * 60 * 1000;
15
15
  /** Entropy of the client-held binding value, in bytes. */
16
16
  export const BINDING_VALUE_BYTES = 32;
17
+ /**
18
+ * Most values carrying `STATE_COOKIE_NAME` that a single callback will try.
19
+ *
20
+ * A client can hold more than one cookie of the same name — a sibling
21
+ * subdomain can set one on the parent domain, and the browser sends every
22
+ * applicable cookie in one header. All of them are tried, so a planted cookie
23
+ * cannot deny login by sorting ahead of the real one; the cap bounds the work
24
+ * an unauthenticated caller can ask for. It is not a brute-force control: the
25
+ * pending record is consumed on recognition, so a state gets one attempt
26
+ * whatever the cap.
27
+ */
28
+ export const MAX_BINDING_COOKIE_CANDIDATES = 8;
package/dist/main.d.ts CHANGED
@@ -27,14 +27,18 @@ export default class OAuth {
27
27
  getProvider(name: string): ProviderEntry;
28
28
  getAuthorizationUrl(providerName: string): AuthorizationRequest;
29
29
  /**
30
- * `bindingValue` is required, not optional (#36). An optional parameter lets
30
+ * `bindingValues` is required, not optional (#36). An optional parameter lets
31
31
  * an existing three-argument call site keep compiling and then fail at
32
32
  * runtime on the first real login; a compile error is the loudest disclosure
33
- * channel available for this break. It is typed as possibly-undefined
34
- * because the route handler passes through whatever the client presented,
35
- * and `StateStore.consume` rejects falsy explicitly.
33
+ * channel available for this break.
34
+ *
35
+ * It is an array, not a single value, because a client can hold more than one
36
+ * cookie of the binding cookie's name and every one of them has to be tried —
37
+ * see `StateStore.anyCandidateMatches`. A caller driving the flow itself
38
+ * passes `[bindingValue]`; the route handler passes through every value the
39
+ * client presented, which may be none.
36
40
  */
37
- handleCallback(providerName: string, code: string, stateToken: string, bindingValue: string | undefined): Promise<import("./session-manager.js").SessionResult>;
41
+ handleCallback(providerName: string, code: string, stateToken: string, bindingValues: readonly string[]): Promise<import("./session-manager.js").SessionResult>;
38
42
  getSession(sessionId: string): unknown;
39
43
  logout(sessionId: string): void;
40
44
  }
package/dist/main.js CHANGED
@@ -52,15 +52,19 @@ export default class OAuth {
52
52
  return { url: flow.buildAuthorizationUrl(stateToken), bindingValue };
53
53
  }
54
54
  /**
55
- * `bindingValue` is required, not optional (#36). An optional parameter lets
55
+ * `bindingValues` is required, not optional (#36). An optional parameter lets
56
56
  * an existing three-argument call site keep compiling and then fail at
57
57
  * runtime on the first real login; a compile error is the loudest disclosure
58
- * channel available for this break. It is typed as possibly-undefined
59
- * because the route handler passes through whatever the client presented,
60
- * and `StateStore.consume` rejects falsy explicitly.
58
+ * channel available for this break.
59
+ *
60
+ * It is an array, not a single value, because a client can hold more than one
61
+ * cookie of the binding cookie's name and every one of them has to be tried —
62
+ * see `StateStore.anyCandidateMatches`. A caller driving the flow itself
63
+ * passes `[bindingValue]`; the route handler passes through every value the
64
+ * client presented, which may be none.
61
65
  */
62
- async handleCallback(providerName, code, stateToken, bindingValue) {
63
- this.stateStore.consume(stateToken, providerName, bindingValue);
66
+ async handleCallback(providerName, code, stateToken, bindingValues) {
67
+ this.stateStore.consume(stateToken, providerName, bindingValues);
64
68
  const { flow, tokenManager } = this.getProvider(providerName);
65
69
  const tokens = await tokenManager.getTokens(code);
66
70
  const rawUser = await flow.fetchUserInfo(tokens.accessToken);
@@ -10,6 +10,33 @@ export interface PendingState {
10
10
  bindingHash: string;
11
11
  createdAt: number;
12
12
  }
13
+ /**
14
+ * The five reasons a callback is rejected, as fixed strings.
15
+ *
16
+ * Named rather than inlined so that collapsing two of them into one is a
17
+ * visible edit: distinguishing them in the server log is the whole point of
18
+ * logging a reason, and an operator telling an expired state from a
19
+ * cross-provider replay depends on them staying distinct.
20
+ */
21
+ export declare const STATE_REJECTION: {
22
+ readonly unknownState: "Invalid or missing state token";
23
+ readonly expired: "State token has expired";
24
+ readonly wrongProvider: "State token was not issued for this provider";
25
+ readonly missingBinding: "Missing state binding value";
26
+ readonly unboundClient: "State token is not bound to this client";
27
+ };
28
+ /**
29
+ * A callback rejected by `StateStore.consume`.
30
+ *
31
+ * Carries two things the route layer cannot otherwise recover: that the
32
+ * rejection came from state validation rather than from anything downstream of
33
+ * it, and whether a pending record was actually consumed.
34
+ */
35
+ export declare class StateRejection extends Error {
36
+ /** True when this attempt recognised a pending record and burned it. */
37
+ consumed: boolean;
38
+ constructor(reason: string, consumed: boolean);
39
+ }
13
40
  export interface IssuedState {
14
41
  /** Sent to the provider as the OAuth2 `state` parameter. */
15
42
  stateToken: string;
@@ -54,7 +81,34 @@ export default class StateStore {
54
81
  * The trade is real: an attacker who already knows a victim's state can burn
55
82
  * it, and the victim must restart at `/auth/login/:provider`. That vector is
56
83
  * accepted deliberately — it requires the victim's `randomUUID` state, and
57
- * it is self-healing on retry.
84
+ * it is self-healing on retry. `consumed` on the rejection says whether this
85
+ * call actually burned a record, so a caller can distinguish "nothing of the
86
+ * victim's was touched" from "one attempt was spent".
87
+ *
88
+ * `bindingValues` is every value the client presented under the binding
89
+ * cookie's name, not just the first — see `anyCandidateMatches`.
90
+ */
91
+ consume(stateToken: string | undefined, provider: string, bindingValues: readonly string[]): void;
92
+ /**
93
+ * Whether *any* presented value is the binding value for this record.
94
+ *
95
+ * Every candidate is tried, and the callback is accepted if one matches.
96
+ * Returning on the first value carrying the cookie name instead made a
97
+ * planted cookie a permanent, unauthenticated denial of login: RFC 6265
98
+ * section 5.4 orders the `Cookie` header by path length then creation time,
99
+ * so an attacker with content control on a sibling subdomain sets a
100
+ * same-named cookie once and every subsequent callback for that victim reads
101
+ * theirs, fails the binding check, and burns the state on the way out. The
102
+ * victim cannot recover by retrying.
103
+ *
104
+ * Accepting any match gives an attacker nothing: they would have to present
105
+ * the victim's own binding value, which is the property being checked. The
106
+ * candidate list is bounded by `MAX_BINDING_COOKIE_CANDIDATES` at the point
107
+ * it is parsed, and the record is consumed on recognition, so a state still
108
+ * gets exactly one attempt.
109
+ *
110
+ * The loop does not short-circuit, so the work is a function of how many
111
+ * values were presented and not of which one matched.
58
112
  */
59
- consume(stateToken: string | undefined, provider: string, bindingValue: string | undefined): void;
113
+ anyCandidateMatches(candidates: readonly string[], record: PendingState): boolean;
60
114
  }
@@ -1,5 +1,36 @@
1
1
  import { createHash, randomBytes, randomUUID } from 'node:crypto';
2
2
  import { BINDING_VALUE_BYTES, STATE_TTL_MS } from './constants.js';
3
+ /**
4
+ * The five reasons a callback is rejected, as fixed strings.
5
+ *
6
+ * Named rather than inlined so that collapsing two of them into one is a
7
+ * visible edit: distinguishing them in the server log is the whole point of
8
+ * logging a reason, and an operator telling an expired state from a
9
+ * cross-provider replay depends on them staying distinct.
10
+ */
11
+ export const STATE_REJECTION = {
12
+ unknownState: 'Invalid or missing state token',
13
+ expired: 'State token has expired',
14
+ wrongProvider: 'State token was not issued for this provider',
15
+ missingBinding: 'Missing state binding value',
16
+ unboundClient: 'State token is not bound to this client',
17
+ };
18
+ /**
19
+ * A callback rejected by `StateStore.consume`.
20
+ *
21
+ * Carries two things the route layer cannot otherwise recover: that the
22
+ * rejection came from state validation rather than from anything downstream of
23
+ * it, and whether a pending record was actually consumed.
24
+ */
25
+ export class StateRejection extends Error {
26
+ /** True when this attempt recognised a pending record and burned it. */
27
+ consumed;
28
+ constructor(reason, consumed) {
29
+ super(reason);
30
+ this.name = 'StateRejection';
31
+ this.consumed = consumed;
32
+ }
33
+ }
3
34
  /**
4
35
  * Issues and validates OAuth2 `state` tokens bound to the client that started
5
36
  * the flow (#36).
@@ -59,23 +90,53 @@ export default class StateStore {
59
90
  * The trade is real: an attacker who already knows a victim's state can burn
60
91
  * it, and the victim must restart at `/auth/login/:provider`. That vector is
61
92
  * accepted deliberately — it requires the victim's `randomUUID` state, and
62
- * it is self-healing on retry.
93
+ * it is self-healing on retry. `consumed` on the rejection says whether this
94
+ * call actually burned a record, so a caller can distinguish "nothing of the
95
+ * victim's was touched" from "one attempt was spent".
96
+ *
97
+ * `bindingValues` is every value the client presented under the binding
98
+ * cookie's name, not just the first — see `anyCandidateMatches`.
63
99
  */
64
- consume(stateToken, provider, bindingValue) {
100
+ consume(stateToken, provider, bindingValues) {
65
101
  if (!stateToken)
66
- throw new Error('Invalid or missing state token');
102
+ throw new StateRejection(STATE_REJECTION.unknownState, false);
67
103
  const record = this.pending.get(stateToken);
68
104
  if (!record)
69
- throw new Error('Invalid or missing state token');
105
+ throw new StateRejection(STATE_REJECTION.unknownState, false);
70
106
  this.pending.delete(stateToken);
71
107
  if (Date.now() - record.createdAt > this.ttl)
72
- throw new Error('State token has expired');
108
+ throw new StateRejection(STATE_REJECTION.expired, true);
73
109
  if (record.provider !== provider)
74
- throw new Error('State token was not issued for this provider');
75
- if (!bindingValue)
76
- throw new Error('Missing state binding value');
77
- if (!StateStore.digestsMatch(StateStore.hash(bindingValue), record.bindingHash)) {
78
- throw new Error('State token is not bound to this client');
110
+ throw new StateRejection(STATE_REJECTION.wrongProvider, true);
111
+ const candidates = bindingValues.filter(value => value.length > 0);
112
+ if (candidates.length === 0)
113
+ throw new StateRejection(STATE_REJECTION.missingBinding, true);
114
+ if (!this.anyCandidateMatches(candidates, record)) {
115
+ throw new StateRejection(STATE_REJECTION.unboundClient, true);
79
116
  }
80
117
  }
118
+ /**
119
+ * Whether *any* presented value is the binding value for this record.
120
+ *
121
+ * Every candidate is tried, and the callback is accepted if one matches.
122
+ * Returning on the first value carrying the cookie name instead made a
123
+ * planted cookie a permanent, unauthenticated denial of login: RFC 6265
124
+ * section 5.4 orders the `Cookie` header by path length then creation time,
125
+ * so an attacker with content control on a sibling subdomain sets a
126
+ * same-named cookie once and every subsequent callback for that victim reads
127
+ * theirs, fails the binding check, and burns the state on the way out. The
128
+ * victim cannot recover by retrying.
129
+ *
130
+ * Accepting any match gives an attacker nothing: they would have to present
131
+ * the victim's own binding value, which is the property being checked. The
132
+ * candidate list is bounded by `MAX_BINDING_COOKIE_CANDIDATES` at the point
133
+ * it is parsed, and the record is consumed on recognition, so a state still
134
+ * gets exactly one attempt.
135
+ *
136
+ * The loop does not short-circuit, so the work is a function of how many
137
+ * values were presented and not of which one matched.
138
+ */
139
+ anyCandidateMatches(candidates, record) {
140
+ return candidates.reduce((matched, candidate) => StateStore.digestsMatch(StateStore.hash(candidate), record.bindingHash) || matched, false);
141
+ }
81
142
  }
package/package.json CHANGED
@@ -4,7 +4,7 @@
4
4
  "stonyx-async",
5
5
  "stonyx-module"
6
6
  ],
7
- "version": "0.1.1-alpha.17",
7
+ "version": "0.1.1-alpha.19",
8
8
  "description": "OAuth2 authentication module for the Stonyx framework",
9
9
  "repository": {
10
10
  "type": "git",
@@ -1,6 +1,8 @@
1
1
  import { Request } from '@stonyx/rest-server';
2
2
  import log from 'stonyx/log';
3
+ import { StateRejection } from './state-store.js';
3
4
  import {
5
+ MAX_BINDING_COOKIE_CANDIDATES,
4
6
  STATE_COOKIE_NAME,
5
7
  STATE_COOKIE_PATH,
6
8
  STATE_COOKIE_SAME_SITE,
@@ -13,10 +15,53 @@ interface AuthorizationRequest {
13
15
  }
14
16
 
15
17
  /**
16
- * Hosts treated as a development origin, and the only ones exempt from
17
- * `Secure` on the binding cookie. See `AuthRequest.isSecureContext`.
18
+ * Hosts treated as a development origin by exact match, and together with
19
+ * `127.0.0.0/8` and the IPv4-mapped IPv6 spellings of it — the only ones exempt
20
+ * from `Secure` on the binding cookie. See `AuthRequest.isSecureContext`.
21
+ *
22
+ * `0.0.0.0` and `::` are the wildcard bind addresses a developer reaches a
23
+ * local server on; `127.0.0.1` is covered by the `127.0.0.0/8` test rather than
24
+ * listed here, so the two are not silently redundant.
25
+ */
26
+ const LOOPBACK_HOSTS = new Set(['localhost', '::1', '0:0:0:0:0:0:0:1', '0.0.0.0', '::']);
27
+
28
+ /** `host` values whose port component is anything but a decimal port are rejected. */
29
+ const PORT_PATTERN = /^\d{1,5}$/;
30
+
31
+ /**
32
+ * The characters RFC 1123 permits in a registered hostname, plus `.`.
33
+ *
34
+ * Anything else — `@`, `,`, whitespace, `/` — means the value is not a bare
35
+ * hostname, and the caller fails secure rather than guessing. This is what
36
+ * rejects `localhost:80@evil.com` and a comma-joined multi-value `Host`.
18
37
  */
19
- const LOOPBACK_HOSTS = new Set(['localhost', '127.0.0.1', '::1', '0.0.0.0']);
38
+ const HOSTNAME_PATTERN = /^[A-Za-z0-9._-]+$/;
39
+
40
+ /** A dotted-quad whose first octet is 127, i.e. real `127.0.0.0/8` membership. */
41
+ function isLoopbackIpv4(hostname: string): boolean {
42
+ const octets = hostname.split('.');
43
+ if (octets.length !== 4) return false;
44
+ if (!octets.every(octet => /^\d{1,3}$/.test(octet) && Number(octet) <= 255)) return false;
45
+
46
+ return Number(octets[0]) === 127;
47
+ }
48
+
49
+ /**
50
+ * IPv4-mapped IPv6 loopback, in both spellings a dual-stack listener produces:
51
+ * `::ffff:127.0.0.1` and `::ffff:7f00:1`.
52
+ */
53
+ function isLoopbackIpv6(hostname: string): boolean {
54
+ const mapped = /^::ffff:(.+)$/.exec(hostname);
55
+ if (!mapped) return false;
56
+
57
+ const rest = mapped[1];
58
+ if (isLoopbackIpv4(rest)) return true;
59
+
60
+ const hextets = /^([0-9a-f]{1,4}):([0-9a-f]{1,4})$/.exec(rest);
61
+ if (!hextets) return false;
62
+
63
+ return parseInt(hextets[1], 16) >>> 8 === 127;
64
+ }
20
65
 
21
66
  interface OAuthInstance {
22
67
  frontendCallbackUrl?: string;
@@ -26,7 +71,7 @@ interface OAuthInstance {
26
71
  providerName: string,
27
72
  code: string,
28
73
  stateToken: string,
29
- bindingValue: string | undefined,
74
+ bindingValues: readonly string[],
30
75
  ): Promise<{ sessionId: string; expiresAt: number }>;
31
76
  logout(sessionId: string): void;
32
77
  }
@@ -55,6 +100,13 @@ interface ResponseLike {
55
100
 
56
101
  interface RouteRequest {
57
102
  headers: Record<string, string | undefined>;
103
+ /**
104
+ * Node's flat `[name, value, name, value, ...]` header list, when the runtime
105
+ * supplies it. Read only to detect a *duplicate* `Host`: Node collapses
106
+ * repeats into the first value, so `req.headers.host` alone cannot tell an
107
+ * unambiguous origin from a smuggled one.
108
+ */
109
+ rawHeaders?: string[];
58
110
  params: Record<string, string>;
59
111
  query: Record<string, string>;
60
112
  secure?: boolean;
@@ -106,7 +158,7 @@ export default class AuthRequest extends Request {
106
158
  const { provider: providerName } = req.params;
107
159
  const { code, state: stateToken, error } = req.query;
108
160
 
109
- const bindingValue = this.readBindingCookie(req);
161
+ const bindingValues = this.readBindingCookies(req);
110
162
 
111
163
  if (error) {
112
164
  if (this.oauth.frontendCallbackUrl) {
@@ -118,16 +170,12 @@ export default class AuthRequest extends Request {
118
170
 
119
171
  if (!code) return 400;
120
172
 
121
- // The binding value is single-use, so this callback is the end of that
122
- // cookie's life — but only from here down, where the state is actually
123
- // consumed. Clearing above the two early returns denied login to a
124
- // client still at the provider's consent screen, via an
125
- // attacker-induced navigation to `?error=...` that needs no knowledge
126
- // of the victim's state at all.
127
- this.clearBindingCookie(req);
128
-
129
173
  try {
130
- const session = await this.oauth.handleCallback(providerName, code, stateToken, bindingValue);
174
+ const session = await this.oauth.handleCallback(providerName, code, stateToken, bindingValues);
175
+
176
+ // The binding value is single-use and the state has now been
177
+ // consumed, so this is the end of that cookie's life.
178
+ this.clearBindingCookie(req);
131
179
 
132
180
  if (this.oauth.frontendCallbackUrl) {
133
181
  const params = new URLSearchParams({
@@ -140,13 +188,51 @@ export default class AuthRequest extends Request {
140
188
 
141
189
  return session;
142
190
  } catch (rejection) {
191
+ // Clear only when this request actually spent the cookie.
192
+ //
193
+ // Moving the clear below the `error` and `!code` returns was not
194
+ // enough: it still ran unconditionally for any request carrying a
195
+ // `code`, and `code` is attacker-supplied and unvalidated. So
196
+ // `?code=1` — one query parameter, no knowledge of the victim's state
197
+ // — deleted the binding cookie of a client still at the provider's
198
+ // consent screen, leaving their pending state untouched so nothing
199
+ // was detectable server-side, and their real callback then failed.
200
+ //
201
+ // `StateRejection.consumed` is the only thing that distinguishes
202
+ // "nothing of this client's was touched" from "one attempt was
203
+ // spent". Anything that is not a `StateRejection` was thrown below
204
+ // the state check, which means the record was already burned.
205
+ if (!(rejection instanceof StateRejection) || rejection.consumed) {
206
+ this.clearBindingCookie(req);
207
+ }
208
+
143
209
  // `StateStore.consume` distinguishes five rejection reasons that
144
210
  // otherwise collapse into one opaque outcome with no server-side
145
211
  // signal at all. The client-facing `auth_failed` stays opaque; the
146
- // server has no reason to be. The messages are fixed strings, so
147
- // nothing caller-controlled reaches the log.
148
- const reason = rejection instanceof Error ? rejection.message : String(rejection);
149
- log.error(`OAuth: callback rejected ${reason}`);
212
+ // server has no reason to be.
213
+ //
214
+ // Only a `StateRejection`'s message is logged, and those are the
215
+ // fixed strings in `STATE_REJECTION`. The `try` above spans far more
216
+ // than `consume` — `getProvider`, `TokenManager.getTokens` ->
217
+ // `flow.exchangeCode`, `flow.fetchUserInfo`, `flow.normalizeUser`,
218
+ // `emit('authenticate')`, `sessionManager.create` — and three of
219
+ // those are consumer-overridable through the documented
220
+ // `providers.<name>.module` extension point. A provider that puts
221
+ // request context in its error, which is ordinary practice, would
222
+ // otherwise land its `clientSecret` and the caller-supplied `code` in
223
+ // the log verbatim; `@stonyx/logs` appends content raw when
224
+ // `logToFile` is enabled, so an echoed `code` is also a CRLF
225
+ // log-forging primitive for an unauthenticated caller. Before this
226
+ // module logged anything, all of that was swallowed.
227
+ //
228
+ // Anything below the state check therefore gets a fixed
229
+ // discriminator, and the detail is left to whatever the provider
230
+ // itself logs.
231
+ if (rejection instanceof StateRejection) {
232
+ log.error(`OAuth: callback rejected — ${rejection.message}`);
233
+ } else {
234
+ log.error('OAuth: callback failed after state validation');
235
+ }
150
236
 
151
237
  if (this.oauth.frontendCallbackUrl) {
152
238
  state.redirect = `${this.oauth.frontendCallbackUrl}?error=auth_failed`;
@@ -190,24 +276,98 @@ export default class AuthRequest extends Request {
190
276
  * at the first login and is loud. The alternative fails silently, in
191
277
  * production, on the one attribute protecting the value this whole mechanism
192
278
  * is built around.
279
+ *
280
+ * The exemption is decided by *parsing* the `Host` header and testing the
281
+ * result for membership, never by matching a prefix or a suffix on the raw
282
+ * value — `Host` is attacker-controllable on any non-browser client, and a
283
+ * security predicate written as a substring match drifts. Every shape that
284
+ * cannot be parsed as a bare `host[:port]`, and every request with more than
285
+ * one `Host`, fails secure.
193
286
  */
194
287
  isSecureContext(req: RouteRequest): boolean {
195
288
  if (req.secure === true) return true;
289
+ if (AuthRequest.hasAmbiguousHost(req)) return true;
196
290
 
197
291
  const host = req.headers.host;
198
292
  if (!host) return true;
199
293
 
200
- // `[::1]:2666` -> `::1`; `localhost:2666` -> `localhost`.
201
- const hostname = (host.startsWith('[')
202
- ? host.slice(1, host.indexOf(']'))
203
- : host.split(':')[0]
204
- ).toLowerCase();
294
+ const hostname = AuthRequest.parseHostname(host);
295
+ if (hostname === undefined) return true;
205
296
 
206
- if (LOOPBACK_HOSTS.has(hostname)) return false;
207
- if (hostname.startsWith('127.')) return false;
208
- if (hostname === 'localhost' || hostname.endsWith('.localhost')) return false;
297
+ return !AuthRequest.isLoopbackHost(hostname);
298
+ }
209
299
 
210
- return true;
300
+ /**
301
+ * True when the request carried more than one `Host` header.
302
+ *
303
+ * Node keeps the first and discards the rest, so a component that *prepends*
304
+ * a `Host:` line — request smuggling, or a proxy that appends rather than
305
+ * replaces — can make `req.headers.host` read `localhost` on a request whose
306
+ * real origin is public. RFC 9112 section 3.2 makes such a request invalid;
307
+ * this treats it as unattributable and fails secure rather than trusting it.
308
+ */
309
+ static hasAmbiguousHost(req: RouteRequest): boolean {
310
+ const raw = req.rawHeaders;
311
+ if (!Array.isArray(raw)) return false;
312
+
313
+ let seen = 0;
314
+ for (let index = 0; index < raw.length; index += 2) {
315
+ if (typeof raw[index] === 'string' && raw[index].toLowerCase() === 'host') seen++;
316
+ }
317
+
318
+ return seen > 1;
319
+ }
320
+
321
+ /**
322
+ * The hostname component of a `Host` header, lowercased, or `undefined` when
323
+ * the value is not a well-formed `host[:port]`.
324
+ *
325
+ * `host.split(':')[0]` is not enough: it truncates at the *first* colon, so
326
+ * `localhost:80@evil.com` reduces to `localhost`. The port is therefore
327
+ * required to be decimal, and the hostname to contain only characters a
328
+ * registered name may contain.
329
+ */
330
+ static parseHostname(host: string): string | undefined {
331
+ if (host.startsWith('[')) {
332
+ const close = host.indexOf(']');
333
+ if (close === -1) return undefined;
334
+
335
+ const port = host.slice(close + 1);
336
+ if (port !== '' && !(port.startsWith(':') && PORT_PATTERN.test(port.slice(1)))) return undefined;
337
+
338
+ const literal = host.slice(1, close);
339
+ if (!/^[0-9A-Fa-f:.]+$/.test(literal)) return undefined;
340
+
341
+ return literal.toLowerCase();
342
+ }
343
+
344
+ const colon = host.indexOf(':');
345
+ if (colon === -1) return HOSTNAME_PATTERN.test(host) ? host.toLowerCase() : undefined;
346
+
347
+ if (!PORT_PATTERN.test(host.slice(colon + 1))) return undefined;
348
+
349
+ const name = host.slice(0, colon);
350
+
351
+ return HOSTNAME_PATTERN.test(name) ? name.toLowerCase() : undefined;
352
+ }
353
+
354
+ /**
355
+ * Whether a parsed hostname is a loopback development origin.
356
+ *
357
+ * Membership tests, never prefix or suffix tests. `startsWith('127.')`
358
+ * matched `127.evil.com`, a perfectly registerable name (RFC 1123 permits a
359
+ * leading digit in a label), and `endsWith('.localhost')` exempted an entire
360
+ * suffix — so a `.localhost` split-horizon vhost shipped the binding value in
361
+ * cleartext. The `.localhost` exemption is withdrawn rather than tightened:
362
+ * the README documented `127.0.0.0/8`, `localhost`, `::1` and `0.0.0.0` and
363
+ * never documented it, and a developer on `app.localhost` reaches the same
364
+ * server on `localhost` or `127.0.0.1`.
365
+ */
366
+ static isLoopbackHost(hostname: string): boolean {
367
+ if (LOOPBACK_HOSTS.has(hostname)) return true;
368
+ if (isLoopbackIpv4(hostname)) return true;
369
+
370
+ return isLoopbackIpv6(hostname);
211
371
  }
212
372
 
213
373
  setBindingCookie(req: RouteRequest, bindingValue: string): boolean {
@@ -226,11 +386,29 @@ export default class AuthRequest extends Request {
226
386
  return true;
227
387
  }
228
388
 
229
- readBindingCookie(req: RouteRequest): string | undefined {
389
+ /**
390
+ * Every value the client presented under the binding cookie's name.
391
+ *
392
+ * Not the first one. A browser sends every applicable cookie in a single
393
+ * header, and a sibling subdomain can set a same-named cookie on the parent
394
+ * domain that RFC 6265 section 5.4 orders *ahead* of the real one — so
395
+ * returning on the first name match handed an attacker a permanent,
396
+ * unauthenticated denial of login for any victim they could plant a cookie
397
+ * on. `Secure`, `HttpOnly` and `SameSite` do not constrain that: the attacker
398
+ * is writing, not reading.
399
+ *
400
+ * Bounded at `MAX_BINDING_COOKIE_CANDIDATES`, so the work an unauthenticated
401
+ * caller can ask for is capped whatever the header contains.
402
+ */
403
+ readBindingCookies(req: RouteRequest): string[] {
230
404
  const header = req.headers.cookie;
231
- if (!header) return undefined;
405
+ if (!header) return [];
406
+
407
+ const values: string[] = [];
232
408
 
233
409
  for (const part of header.split(';')) {
410
+ if (values.length >= MAX_BINDING_COOKIE_CANDIDATES) break;
411
+
234
412
  const separator = part.indexOf('=');
235
413
  if (separator === -1) continue;
236
414
  if (part.slice(0, separator).trim() !== STATE_COOKIE_NAME) continue;
@@ -240,10 +418,10 @@ export default class AuthRequest extends Request {
240
418
  // `decodeURIComponent` throws `URIError` on malformed input, which any
241
419
  // unauthenticated caller can supply, turning the first line of the
242
420
  // callback into a 500 with a stack trace.
243
- return part.slice(separator + 1).trim();
421
+ values.push(part.slice(separator + 1).trim());
244
422
  }
245
423
 
246
- return undefined;
424
+ return values;
247
425
  }
248
426
 
249
427
  clearBindingCookie(req: RouteRequest): void {
package/src/constants.ts CHANGED
@@ -17,3 +17,16 @@ export const STATE_TTL_MS = 10 * 60 * 1000;
17
17
 
18
18
  /** Entropy of the client-held binding value, in bytes. */
19
19
  export const BINDING_VALUE_BYTES = 32;
20
+
21
+ /**
22
+ * Most values carrying `STATE_COOKIE_NAME` that a single callback will try.
23
+ *
24
+ * A client can hold more than one cookie of the same name — a sibling
25
+ * subdomain can set one on the parent domain, and the browser sends every
26
+ * applicable cookie in one header. All of them are tried, so a planted cookie
27
+ * cannot deny login by sorting ahead of the real one; the cap bounds the work
28
+ * an unauthenticated caller can ask for. It is not a brute-force control: the
29
+ * pending record is consumed on recognition, so a state gets one attempt
30
+ * whatever the cap.
31
+ */
32
+ export const MAX_BINDING_COOKIE_CANDIDATES = 8;
package/src/main.ts CHANGED
@@ -86,20 +86,24 @@ export default class OAuth {
86
86
  }
87
87
 
88
88
  /**
89
- * `bindingValue` is required, not optional (#36). An optional parameter lets
89
+ * `bindingValues` is required, not optional (#36). An optional parameter lets
90
90
  * an existing three-argument call site keep compiling and then fail at
91
91
  * runtime on the first real login; a compile error is the loudest disclosure
92
- * channel available for this break. It is typed as possibly-undefined
93
- * because the route handler passes through whatever the client presented,
94
- * and `StateStore.consume` rejects falsy explicitly.
92
+ * channel available for this break.
93
+ *
94
+ * It is an array, not a single value, because a client can hold more than one
95
+ * cookie of the binding cookie's name and every one of them has to be tried —
96
+ * see `StateStore.anyCandidateMatches`. A caller driving the flow itself
97
+ * passes `[bindingValue]`; the route handler passes through every value the
98
+ * client presented, which may be none.
95
99
  */
96
100
  async handleCallback(
97
101
  providerName: string,
98
102
  code: string,
99
103
  stateToken: string,
100
- bindingValue: string | undefined,
104
+ bindingValues: readonly string[],
101
105
  ) {
102
- this.stateStore.consume(stateToken, providerName, bindingValue);
106
+ this.stateStore.consume(stateToken, providerName, bindingValues);
103
107
 
104
108
  const { flow, tokenManager } = this.getProvider(providerName);
105
109
  const tokens = await tokenManager.getTokens(code);
@@ -14,6 +14,40 @@ export interface PendingState {
14
14
  createdAt: number;
15
15
  }
16
16
 
17
+ /**
18
+ * The five reasons a callback is rejected, as fixed strings.
19
+ *
20
+ * Named rather than inlined so that collapsing two of them into one is a
21
+ * visible edit: distinguishing them in the server log is the whole point of
22
+ * logging a reason, and an operator telling an expired state from a
23
+ * cross-provider replay depends on them staying distinct.
24
+ */
25
+ export const STATE_REJECTION = {
26
+ unknownState: 'Invalid or missing state token',
27
+ expired: 'State token has expired',
28
+ wrongProvider: 'State token was not issued for this provider',
29
+ missingBinding: 'Missing state binding value',
30
+ unboundClient: 'State token is not bound to this client',
31
+ } as const;
32
+
33
+ /**
34
+ * A callback rejected by `StateStore.consume`.
35
+ *
36
+ * Carries two things the route layer cannot otherwise recover: that the
37
+ * rejection came from state validation rather than from anything downstream of
38
+ * it, and whether a pending record was actually consumed.
39
+ */
40
+ export class StateRejection extends Error {
41
+ /** True when this attempt recognised a pending record and burned it. */
42
+ consumed: boolean;
43
+
44
+ constructor(reason: string, consumed: boolean) {
45
+ super(reason);
46
+ this.name = 'StateRejection';
47
+ this.consumed = consumed;
48
+ }
49
+ }
50
+
17
51
  export interface IssuedState {
18
52
  /** Sent to the provider as the OAuth2 `state` parameter. */
19
53
  stateToken: string;
@@ -88,21 +122,56 @@ export default class StateStore {
88
122
  * The trade is real: an attacker who already knows a victim's state can burn
89
123
  * it, and the victim must restart at `/auth/login/:provider`. That vector is
90
124
  * accepted deliberately — it requires the victim's `randomUUID` state, and
91
- * it is self-healing on retry.
125
+ * it is self-healing on retry. `consumed` on the rejection says whether this
126
+ * call actually burned a record, so a caller can distinguish "nothing of the
127
+ * victim's was touched" from "one attempt was spent".
128
+ *
129
+ * `bindingValues` is every value the client presented under the binding
130
+ * cookie's name, not just the first — see `anyCandidateMatches`.
92
131
  */
93
- consume(stateToken: string | undefined, provider: string, bindingValue: string | undefined): void {
94
- if (!stateToken) throw new Error('Invalid or missing state token');
132
+ consume(stateToken: string | undefined, provider: string, bindingValues: readonly string[]): void {
133
+ if (!stateToken) throw new StateRejection(STATE_REJECTION.unknownState, false);
95
134
 
96
135
  const record = this.pending.get(stateToken);
97
- if (!record) throw new Error('Invalid or missing state token');
136
+ if (!record) throw new StateRejection(STATE_REJECTION.unknownState, false);
98
137
  this.pending.delete(stateToken);
99
138
 
100
- if (Date.now() - record.createdAt > this.ttl) throw new Error('State token has expired');
101
- if (record.provider !== provider) throw new Error('State token was not issued for this provider');
102
- if (!bindingValue) throw new Error('Missing state binding value');
139
+ if (Date.now() - record.createdAt > this.ttl) throw new StateRejection(STATE_REJECTION.expired, true);
140
+ if (record.provider !== provider) throw new StateRejection(STATE_REJECTION.wrongProvider, true);
141
+
142
+ const candidates = bindingValues.filter(value => value.length > 0);
143
+ if (candidates.length === 0) throw new StateRejection(STATE_REJECTION.missingBinding, true);
103
144
 
104
- if (!StateStore.digestsMatch(StateStore.hash(bindingValue), record.bindingHash)) {
105
- throw new Error('State token is not bound to this client');
145
+ if (!this.anyCandidateMatches(candidates, record)) {
146
+ throw new StateRejection(STATE_REJECTION.unboundClient, true);
106
147
  }
107
148
  }
149
+
150
+ /**
151
+ * Whether *any* presented value is the binding value for this record.
152
+ *
153
+ * Every candidate is tried, and the callback is accepted if one matches.
154
+ * Returning on the first value carrying the cookie name instead made a
155
+ * planted cookie a permanent, unauthenticated denial of login: RFC 6265
156
+ * section 5.4 orders the `Cookie` header by path length then creation time,
157
+ * so an attacker with content control on a sibling subdomain sets a
158
+ * same-named cookie once and every subsequent callback for that victim reads
159
+ * theirs, fails the binding check, and burns the state on the way out. The
160
+ * victim cannot recover by retrying.
161
+ *
162
+ * Accepting any match gives an attacker nothing: they would have to present
163
+ * the victim's own binding value, which is the property being checked. The
164
+ * candidate list is bounded by `MAX_BINDING_COOKIE_CANDIDATES` at the point
165
+ * it is parsed, and the record is consumed on recognition, so a state still
166
+ * gets exactly one attempt.
167
+ *
168
+ * The loop does not short-circuit, so the work is a function of how many
169
+ * values were presented and not of which one matched.
170
+ */
171
+ anyCandidateMatches(candidates: readonly string[], record: PendingState): boolean {
172
+ return candidates.reduce(
173
+ (matched, candidate) => StateStore.digestsMatch(StateStore.hash(candidate), record.bindingHash) || matched,
174
+ false,
175
+ );
176
+ }
108
177
  }