@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.
- package/dist/auth-request.d.ts +6 -2
- package/dist/auth-request.js +7 -5
- package/dist/constants.d.ts +23 -9
- package/dist/constants.js +23 -9
- package/dist/state-store.d.ts +5 -3
- package/dist/state-store.js +5 -3
- package/package.json +3 -3
- package/src/auth-request.ts +6 -5
- package/src/constants.ts +23 -9
- package/src/state-store.ts +5 -3
package/dist/auth-request.d.ts
CHANGED
|
@@ -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
|
-
*
|
|
138
|
-
*
|
|
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;
|
package/dist/auth-request.js
CHANGED
|
@@ -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 {
|
|
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
|
-
*
|
|
310
|
-
*
|
|
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;
|
package/dist/constants.d.ts
CHANGED
|
@@ -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
|
-
*
|
|
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
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
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
|
-
*
|
|
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
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
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;
|
package/dist/state-store.d.ts
CHANGED
|
@@ -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
|
-
*
|
|
107
|
-
*
|
|
108
|
-
*
|
|
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.
|
package/dist/state-store.js
CHANGED
|
@@ -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
|
-
*
|
|
133
|
-
*
|
|
134
|
-
*
|
|
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.
|
|
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.
|
|
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.
|
|
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",
|
package/src/auth-request.ts
CHANGED
|
@@ -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
|
-
*
|
|
401
|
-
*
|
|
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
|
-
*
|
|
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
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
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;
|
package/src/state-store.ts
CHANGED
|
@@ -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
|
-
*
|
|
165
|
-
*
|
|
166
|
-
*
|
|
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.
|