@stonyx/oauth 0.1.1-alpha.20 → 0.1.1-alpha.21

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.
@@ -134,8 +134,12 @@ export default class AuthRequest extends Request {
134
134
  * on. `Secure`, `HttpOnly` and `SameSite` do not constrain that: the attacker
135
135
  * is writing, not reading.
136
136
  *
137
- * Bounded at `MAX_BINDING_COOKIE_CANDIDATES`, so the work an unauthenticated
138
- * caller can ask for is capped whatever the header contains.
137
+ * Every value is returned, with no cap. A cap here does not bound an attack,
138
+ * it *is* one: truncating the list reinstates exactly the denial above its
139
+ * own threshold, because the planted cookies are the ones that sort first.
140
+ * The work is already bounded by Node's 16 KB header limit — at most 779
141
+ * hashable candidates, 0.32 ms to parse and hash all of them. See
142
+ * `constants.ts` for the measurement.
139
143
  */
140
144
  readBindingCookies(req: RouteRequest): string[];
141
145
  clearBindingCookie(req: RouteRequest): void;
@@ -1,7 +1,7 @@
1
1
  import { Request } from '@stonyx/rest-server';
2
2
  import log from 'stonyx/log';
3
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
+ import { STATE_COOKIE_NAME, STATE_COOKIE_PATH, STATE_COOKIE_SAME_SITE, STATE_TTL_MS, } from './constants.js';
5
5
  /**
6
6
  * Hosts treated as a development origin by exact match, and — together with
7
7
  * `127.0.0.0/8` and the IPv4-mapped IPv6 spellings of it — the only ones exempt
@@ -306,8 +306,12 @@ export default class AuthRequest extends Request {
306
306
  * on. `Secure`, `HttpOnly` and `SameSite` do not constrain that: the attacker
307
307
  * is writing, not reading.
308
308
  *
309
- * Bounded at `MAX_BINDING_COOKIE_CANDIDATES`, so the work an unauthenticated
310
- * caller can ask for is capped whatever the header contains.
309
+ * Every value is returned, with no cap. A cap here does not bound an attack,
310
+ * it *is* one: truncating the list reinstates exactly the denial above its
311
+ * own threshold, because the planted cookies are the ones that sort first.
312
+ * The work is already bounded by Node's 16 KB header limit — at most 779
313
+ * hashable candidates, 0.32 ms to parse and hash all of them. See
314
+ * `constants.ts` for the measurement.
311
315
  */
312
316
  readBindingCookies(req) {
313
317
  const header = req.headers.cookie;
@@ -315,8 +319,6 @@ export default class AuthRequest extends Request {
315
319
  return [];
316
320
  const values = [];
317
321
  for (const part of header.split(';')) {
318
- if (values.length >= MAX_BINDING_COOKIE_CANDIDATES)
319
- break;
320
322
  const separator = part.indexOf('=');
321
323
  if (separator === -1)
322
324
  continue;
@@ -6,14 +6,28 @@ 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
8
  /**
9
- * Most values carrying `STATE_COOKIE_NAME` that a single callback will try.
9
+ * There is deliberately no cap on how many values carrying `STATE_COOKIE_NAME`
10
+ * a callback will try.
10
11
  *
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.
12
+ * A client can hold more than one cookie of the same name — a sibling subdomain
13
+ * can set one on the parent domain and the browser sends every applicable
14
+ * cookie in one header, so all of them must be tried or a planted cookie denies
15
+ * login by sorting ahead of the real one (RFC 6265 section 5.4).
16
+ *
17
+ * A cap of 8 was tried and withdrawn: it *reinstated* that denial above its own
18
+ * threshold. Measured on the pre-change tree, 7 shadow cookies still minted a
19
+ * session and 8 failed permanently — the same outcome as the original defect,
20
+ * with the attacker's cost raised from one planted cookie to eight. That is
21
+ * reachable: RFC 6265 section 5.4 orders by path length then creation time, so
22
+ * a 4-label API host with a foothold beneath it gets 3 settable parent domains
23
+ * x 3 usable paths = 9 candidates ahead of the real one.
24
+ *
25
+ * What the cap was defending is already bounded, structurally and for free.
26
+ * Node caps the whole header block at `http.maxHeaderSize`, 16 KB by default,
27
+ * and the shortest segment that can reach the hash is `stonyx_oauth_state=x` at
28
+ * 20 bytes, so a request cannot present more than 779 hashable candidates.
29
+ * Parsing and SHA-256-hashing all 779 costs 0.32 ms median / 0.81 ms worst of 9
30
+ * runs (Node 24.13.0, Apple silicon). Paying a permanent, unauthenticated
31
+ * denial of login to avoid a third of a millisecond is the wrong trade, so the
32
+ * bound is left where it already was: the header size limit.
18
33
  */
19
- export declare const MAX_BINDING_COOKIE_CANDIDATES = 8;
package/dist/constants.js CHANGED
@@ -15,14 +15,28 @@ 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
17
  /**
18
- * Most values carrying `STATE_COOKIE_NAME` that a single callback will try.
18
+ * There is deliberately no cap on how many values carrying `STATE_COOKIE_NAME`
19
+ * a callback will try.
19
20
  *
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.
21
+ * A client can hold more than one cookie of the same name — a sibling subdomain
22
+ * can set one on the parent domain and the browser sends every applicable
23
+ * cookie in one header, so all of them must be tried or a planted cookie denies
24
+ * login by sorting ahead of the real one (RFC 6265 section 5.4).
25
+ *
26
+ * A cap of 8 was tried and withdrawn: it *reinstated* that denial above its own
27
+ * threshold. Measured on the pre-change tree, 7 shadow cookies still minted a
28
+ * session and 8 failed permanently — the same outcome as the original defect,
29
+ * with the attacker's cost raised from one planted cookie to eight. That is
30
+ * reachable: RFC 6265 section 5.4 orders by path length then creation time, so
31
+ * a 4-label API host with a foothold beneath it gets 3 settable parent domains
32
+ * x 3 usable paths = 9 candidates ahead of the real one.
33
+ *
34
+ * What the cap was defending is already bounded, structurally and for free.
35
+ * Node caps the whole header block at `http.maxHeaderSize`, 16 KB by default,
36
+ * and the shortest segment that can reach the hash is `stonyx_oauth_state=x` at
37
+ * 20 bytes, so a request cannot present more than 779 hashable candidates.
38
+ * Parsing and SHA-256-hashing all 779 costs 0.32 ms median / 0.81 ms worst of 9
39
+ * runs (Node 24.13.0, Apple silicon). Paying a permanent, unauthenticated
40
+ * denial of login to avoid a third of a millisecond is the wrong trade, so the
41
+ * bound is left where it already was: the header size limit.
27
42
  */
28
- export const MAX_BINDING_COOKIE_CANDIDATES = 8;
@@ -103,9 +103,11 @@ export default class StateStore {
103
103
  *
104
104
  * Accepting any match gives an attacker nothing: they would have to present
105
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.
106
+ * record is consumed on recognition, so a state still gets exactly one
107
+ * attempt however many candidates were presented, and the candidate count is
108
+ * bounded by Node's header size limit rather than by a cap here — a cap
109
+ * truncates the list from the wrong end and reinstates the denial this method
110
+ * exists to close. See `constants.ts`.
109
111
  *
110
112
  * The loop does not short-circuit, so the work is a function of how many
111
113
  * values were presented and not of which one matched.
@@ -129,9 +129,11 @@ export default class StateStore {
129
129
  *
130
130
  * Accepting any match gives an attacker nothing: they would have to present
131
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.
132
+ * record is consumed on recognition, so a state still gets exactly one
133
+ * attempt however many candidates were presented, and the candidate count is
134
+ * bounded by Node's header size limit rather than by a cap here — a cap
135
+ * truncates the list from the wrong end and reinstates the denial this method
136
+ * exists to close. See `constants.ts`.
135
137
  *
136
138
  * The loop does not short-circuit, so the work is a function of how many
137
139
  * values were presented and not of which one matched.
package/package.json CHANGED
@@ -4,7 +4,7 @@
4
4
  "stonyx-async",
5
5
  "stonyx-module"
6
6
  ],
7
- "version": "0.1.1-alpha.20",
7
+ "version": "0.1.1-alpha.21",
8
8
  "description": "OAuth2 authentication module for the Stonyx framework",
9
9
  "repository": {
10
10
  "type": "git",
@@ -55,13 +55,13 @@
55
55
  },
56
56
  "dependencies": {
57
57
  "@stonyx/events": "0.1.1-beta.52",
58
- "stonyx": "0.2.3-beta.76"
58
+ "stonyx": "0.2.3-beta.77"
59
59
  },
60
60
  "peerDependencies": {
61
61
  "@stonyx/rest-server": ">=0.2.1-beta.11"
62
62
  },
63
63
  "devDependencies": {
64
- "@stonyx/rest-server": "0.2.1-beta.82",
64
+ "@stonyx/rest-server": "0.2.1-beta.83",
65
65
  "@stonyx/utils": "0.2.3-beta.26",
66
66
  "@stonyx/logs": "1.0.1-beta.19",
67
67
  "@types/qunit": "^2.19.13",
@@ -2,7 +2,6 @@ import { Request } from '@stonyx/rest-server';
2
2
  import log from 'stonyx/log';
3
3
  import { StateRejection } from './state-store.js';
4
4
  import {
5
- MAX_BINDING_COOKIE_CANDIDATES,
6
5
  STATE_COOKIE_NAME,
7
6
  STATE_COOKIE_PATH,
8
7
  STATE_COOKIE_SAME_SITE,
@@ -397,8 +396,12 @@ export default class AuthRequest extends Request {
397
396
  * on. `Secure`, `HttpOnly` and `SameSite` do not constrain that: the attacker
398
397
  * is writing, not reading.
399
398
  *
400
- * Bounded at `MAX_BINDING_COOKIE_CANDIDATES`, so the work an unauthenticated
401
- * caller can ask for is capped whatever the header contains.
399
+ * Every value is returned, with no cap. A cap here does not bound an attack,
400
+ * it *is* one: truncating the list reinstates exactly the denial above its
401
+ * own threshold, because the planted cookies are the ones that sort first.
402
+ * The work is already bounded by Node's 16 KB header limit — at most 779
403
+ * hashable candidates, 0.32 ms to parse and hash all of them. See
404
+ * `constants.ts` for the measurement.
402
405
  */
403
406
  readBindingCookies(req: RouteRequest): string[] {
404
407
  const header = req.headers.cookie;
@@ -407,8 +410,6 @@ export default class AuthRequest extends Request {
407
410
  const values: string[] = [];
408
411
 
409
412
  for (const part of header.split(';')) {
410
- if (values.length >= MAX_BINDING_COOKIE_CANDIDATES) break;
411
-
412
413
  const separator = part.indexOf('=');
413
414
  if (separator === -1) continue;
414
415
  if (part.slice(0, separator).trim() !== STATE_COOKIE_NAME) continue;
package/src/constants.ts CHANGED
@@ -19,14 +19,28 @@ export const STATE_TTL_MS = 10 * 60 * 1000;
19
19
  export const BINDING_VALUE_BYTES = 32;
20
20
 
21
21
  /**
22
- * Most values carrying `STATE_COOKIE_NAME` that a single callback will try.
22
+ * There is deliberately no cap on how many values carrying `STATE_COOKIE_NAME`
23
+ * a callback will try.
23
24
  *
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.
25
+ * A client can hold more than one cookie of the same name — a sibling subdomain
26
+ * can set one on the parent domain and the browser sends every applicable
27
+ * cookie in one header, so all of them must be tried or a planted cookie denies
28
+ * login by sorting ahead of the real one (RFC 6265 section 5.4).
29
+ *
30
+ * A cap of 8 was tried and withdrawn: it *reinstated* that denial above its own
31
+ * threshold. Measured on the pre-change tree, 7 shadow cookies still minted a
32
+ * session and 8 failed permanently — the same outcome as the original defect,
33
+ * with the attacker's cost raised from one planted cookie to eight. That is
34
+ * reachable: RFC 6265 section 5.4 orders by path length then creation time, so
35
+ * a 4-label API host with a foothold beneath it gets 3 settable parent domains
36
+ * x 3 usable paths = 9 candidates ahead of the real one.
37
+ *
38
+ * What the cap was defending is already bounded, structurally and for free.
39
+ * Node caps the whole header block at `http.maxHeaderSize`, 16 KB by default,
40
+ * and the shortest segment that can reach the hash is `stonyx_oauth_state=x` at
41
+ * 20 bytes, so a request cannot present more than 779 hashable candidates.
42
+ * Parsing and SHA-256-hashing all 779 costs 0.32 ms median / 0.81 ms worst of 9
43
+ * runs (Node 24.13.0, Apple silicon). Paying a permanent, unauthenticated
44
+ * denial of login to avoid a third of a millisecond is the wrong trade, so the
45
+ * bound is left where it already was: the header size limit.
31
46
  */
32
- export const MAX_BINDING_COOKIE_CANDIDATES = 8;
@@ -161,9 +161,11 @@ export default class StateStore {
161
161
  *
162
162
  * Accepting any match gives an attacker nothing: they would have to present
163
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.
164
+ * record is consumed on recognition, so a state still gets exactly one
165
+ * attempt however many candidates were presented, and the candidate count is
166
+ * bounded by Node's header size limit rather than by a cap here — a cap
167
+ * truncates the list from the wrong end and reinstates the denial this method
168
+ * exists to close. See `constants.ts`.
167
169
  *
168
170
  * The loop does not short-circuit, so the work is a function of how many
169
171
  * values were presented and not of which one matched.