@stonyx/oauth 0.1.1-alpha.16 → 0.1.1-alpha.18

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.
@@ -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;
@@ -39,9 +66,49 @@ export default class StateStore {
39
66
  /**
40
67
  * Validates and consumes a pending state. Throws on every rejection path.
41
68
  *
42
- * The record is removed as soon as the state is recognised — before the
43
- * binding is checked — so a state cannot survive a failed attempt and be
44
- * used as a target for guessing the binding value.
69
+ * The record is removed as soon as the state is recognised — before the TTL,
70
+ * provider and binding checks — so every state gets exactly one attempt
71
+ * whatever the outcome.
72
+ *
73
+ * That uniformity is the justification, not brute-force resistance:
74
+ * guessing `BINDING_VALUE_BYTES` of CSPRNG output is infeasible whether or
75
+ * not the record survives. What retaining it would buy an attacker is a
76
+ * repeatable, unauthenticated oracle on this endpoint for the state's full
77
+ * lifetime — and the safety of that would then rest entirely on an entropy
78
+ * constant a future change can lower. One attempt per state is a structural
79
+ * property; entropy arithmetic is not.
80
+ *
81
+ * The trade is real: an attacker who already knows a victim's state can burn
82
+ * it, and the victim must restart at `/auth/login/:provider`. That vector is
83
+ * accepted deliberately — it requires the victim's `randomUUID` state, and
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.
45
112
  */
46
- consume(stateToken: string | undefined, provider: string, bindingValue: string | undefined): void;
113
+ anyCandidateMatches(candidates: readonly string[], record: PendingState): boolean;
47
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).
@@ -44,25 +75,68 @@ export default class StateStore {
44
75
  /**
45
76
  * Validates and consumes a pending state. Throws on every rejection path.
46
77
  *
47
- * The record is removed as soon as the state is recognised — before the
48
- * binding is checked — so a state cannot survive a failed attempt and be
49
- * used as a target for guessing the binding value.
78
+ * The record is removed as soon as the state is recognised — before the TTL,
79
+ * provider and binding checks — so every state gets exactly one attempt
80
+ * whatever the outcome.
81
+ *
82
+ * That uniformity is the justification, not brute-force resistance:
83
+ * guessing `BINDING_VALUE_BYTES` of CSPRNG output is infeasible whether or
84
+ * not the record survives. What retaining it would buy an attacker is a
85
+ * repeatable, unauthenticated oracle on this endpoint for the state's full
86
+ * lifetime — and the safety of that would then rest entirely on an entropy
87
+ * constant a future change can lower. One attempt per state is a structural
88
+ * property; entropy arithmetic is not.
89
+ *
90
+ * The trade is real: an attacker who already knows a victim's state can burn
91
+ * it, and the victim must restart at `/auth/login/:provider`. That vector is
92
+ * accepted deliberately — it requires the victim's `randomUUID` state, and
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`.
50
99
  */
51
- consume(stateToken, provider, bindingValue) {
100
+ consume(stateToken, provider, bindingValues) {
52
101
  if (!stateToken)
53
- throw new Error('Invalid or missing state token');
102
+ throw new StateRejection(STATE_REJECTION.unknownState, false);
54
103
  const record = this.pending.get(stateToken);
55
104
  if (!record)
56
- throw new Error('Invalid or missing state token');
105
+ throw new StateRejection(STATE_REJECTION.unknownState, false);
57
106
  this.pending.delete(stateToken);
58
107
  if (Date.now() - record.createdAt > this.ttl)
59
- throw new Error('State token has expired');
108
+ throw new StateRejection(STATE_REJECTION.expired, true);
60
109
  if (record.provider !== provider)
61
- throw new Error('State token was not issued for this provider');
62
- if (!bindingValue)
63
- throw new Error('Missing state binding value');
64
- if (!StateStore.digestsMatch(StateStore.hash(bindingValue), record.bindingHash)) {
65
- 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);
66
116
  }
67
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
+ }
68
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.16",
7
+ "version": "0.1.1-alpha.18",
8
8
  "description": "OAuth2 authentication module for the Stonyx framework",
9
9
  "repository": {
10
10
  "type": "git",
@@ -17,10 +17,6 @@
17
17
  "types": "./dist/main.d.ts",
18
18
  "default": "./dist/main.js"
19
19
  },
20
- "./constants": {
21
- "types": "./dist/constants.d.ts",
22
- "default": "./dist/constants.js"
23
- },
24
20
  "./oauth-flow": {
25
21
  "types": "./dist/oauth-flow.d.ts",
26
22
  "default": "./dist/oauth-flow.js"
@@ -65,7 +61,7 @@
65
61
  "@stonyx/rest-server": ">=0.2.1-beta.11"
66
62
  },
67
63
  "devDependencies": {
68
- "@stonyx/rest-server": "0.2.1-beta.80",
64
+ "@stonyx/rest-server": "0.2.1-beta.81",
69
65
  "@stonyx/utils": "0.2.3-beta.26",
70
66
  "@stonyx/logs": "1.0.1-beta.19",
71
67
  "@types/qunit": "^2.19.13",
@@ -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,
@@ -12,6 +14,55 @@ interface AuthorizationRequest {
12
14
  bindingValue: string;
13
15
  }
14
16
 
17
+ /**
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`.
37
+ */
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
+ }
65
+
15
66
  interface OAuthInstance {
16
67
  frontendCallbackUrl?: string;
17
68
  getSession(sessionId: string): unknown;
@@ -20,7 +71,7 @@ interface OAuthInstance {
20
71
  providerName: string,
21
72
  code: string,
22
73
  stateToken: string,
23
- bindingValue?: string,
74
+ bindingValues: readonly string[],
24
75
  ): Promise<{ sessionId: string; expiresAt: number }>;
25
76
  logout(sessionId: string): void;
26
77
  }
@@ -49,6 +100,13 @@ interface ResponseLike {
49
100
 
50
101
  interface RouteRequest {
51
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[];
52
110
  params: Record<string, string>;
53
111
  query: Record<string, string>;
54
112
  secure?: boolean;
@@ -100,10 +158,7 @@ export default class AuthRequest extends Request {
100
158
  const { provider: providerName } = req.params;
101
159
  const { code, state: stateToken, error } = req.query;
102
160
 
103
- // The binding value is single-use: whatever the outcome below, this
104
- // callback is the end of that cookie's life.
105
- const bindingValue = this.readBindingCookie(req);
106
- this.clearBindingCookie(req);
161
+ const bindingValues = this.readBindingCookies(req);
107
162
 
108
163
  if (error) {
109
164
  if (this.oauth.frontendCallbackUrl) {
@@ -116,7 +171,11 @@ export default class AuthRequest extends Request {
116
171
  if (!code) return 400;
117
172
 
118
173
  try {
119
- 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);
120
179
 
121
180
  if (this.oauth.frontendCallbackUrl) {
122
181
  const params = new URLSearchParams({
@@ -128,7 +187,53 @@ export default class AuthRequest extends Request {
128
187
  }
129
188
 
130
189
  return session;
131
- } catch {
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
+
209
+ // `StateStore.consume` distinguishes five rejection reasons that
210
+ // otherwise collapse into one opaque outcome with no server-side
211
+ // signal at all. The client-facing `auth_failed` stays opaque; the
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
+ }
236
+
132
237
  if (this.oauth.frontendCallbackUrl) {
133
238
  state.redirect = `${this.oauth.frontendCallbackUrl}?error=auth_failed`;
134
239
  return;
@@ -152,10 +257,119 @@ export default class AuthRequest extends Request {
152
257
  // request and breaks login outright.
153
258
  sameSite: STATE_COOKIE_SAME_SITE,
154
259
  path: STATE_COOKIE_PATH,
155
- secure: req.secure === true,
260
+ secure: this.isSecureContext(req),
156
261
  };
157
262
  }
158
263
 
264
+ /**
265
+ * Whether the binding cookie is issued with `Secure`.
266
+ *
267
+ * Not `req.secure`. Express derives that from the socket unless `trust proxy`
268
+ * is enabled, and `@stonyx/rest-server` leaves it off by default
269
+ * (`trustProxy: REST_TRUST_PROXY === 'true'`). In the standard production
270
+ * topology — TLS terminated at a proxy, plaintext to the origin — `req.secure`
271
+ * is therefore `false` on every request to an HTTPS site, and the binding
272
+ * cookie would ship without `Secure` while the deployment looks correct.
273
+ *
274
+ * So `Secure` is set unconditionally except on a loopback host. Guessing
275
+ * wrong there breaks a non-loopback plaintext development setup, which fails
276
+ * at the first login and is loud. The alternative fails silently, in
277
+ * production, on the one attribute protecting the value this whole mechanism
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.
286
+ */
287
+ isSecureContext(req: RouteRequest): boolean {
288
+ if (req.secure === true) return true;
289
+ if (AuthRequest.hasAmbiguousHost(req)) return true;
290
+
291
+ const host = req.headers.host;
292
+ if (!host) return true;
293
+
294
+ const hostname = AuthRequest.parseHostname(host);
295
+ if (hostname === undefined) return true;
296
+
297
+ return !AuthRequest.isLoopbackHost(hostname);
298
+ }
299
+
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);
371
+ }
372
+
159
373
  setBindingCookie(req: RouteRequest, bindingValue: string): boolean {
160
374
  const { res } = req;
161
375
 
@@ -172,19 +386,42 @@ export default class AuthRequest extends Request {
172
386
  return true;
173
387
  }
174
388
 
175
- 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[] {
176
404
  const header = req.headers.cookie;
177
- if (!header) return undefined;
405
+ if (!header) return [];
406
+
407
+ const values: string[] = [];
178
408
 
179
409
  for (const part of header.split(';')) {
410
+ if (values.length >= MAX_BINDING_COOKIE_CANDIDATES) break;
411
+
180
412
  const separator = part.indexOf('=');
181
413
  if (separator === -1) continue;
182
414
  if (part.slice(0, separator).trim() !== STATE_COOKIE_NAME) continue;
183
415
 
184
- return decodeURIComponent(part.slice(separator + 1).trim());
416
+ // Not decoded. The binding value is base64url, whose alphabet
417
+ // `encodeURIComponent` never escapes, so a decode buys nothing — and
418
+ // `decodeURIComponent` throws `URIError` on malformed input, which any
419
+ // unauthenticated caller can supply, turning the first line of the
420
+ // callback into a 500 with a stack trace.
421
+ values.push(part.slice(separator + 1).trim());
185
422
  }
186
423
 
187
- return undefined;
424
+ return values;
188
425
  }
189
426
 
190
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
@@ -85,8 +85,25 @@ export default class OAuth {
85
85
  return { url: flow.buildAuthorizationUrl(stateToken), bindingValue };
86
86
  }
87
87
 
88
- async handleCallback(providerName: string, code: string, stateToken: string, bindingValue?: string) {
89
- this.stateStore.consume(stateToken, providerName, bindingValue);
88
+ /**
89
+ * `bindingValues` is required, not optional (#36). An optional parameter lets
90
+ * an existing three-argument call site keep compiling and then fail at
91
+ * runtime on the first real login; a compile error is the loudest disclosure
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.
99
+ */
100
+ async handleCallback(
101
+ providerName: string,
102
+ code: string,
103
+ stateToken: string,
104
+ bindingValues: readonly string[],
105
+ ) {
106
+ this.stateStore.consume(stateToken, providerName, bindingValues);
90
107
 
91
108
  const { flow, tokenManager } = this.getProvider(providerName);
92
109
  const tokens = await tokenManager.getTokens(code);