@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 +76 -8
- package/dist/auth-request.d.ts +63 -2
- package/dist/auth-request.js +194 -31
- package/dist/constants.d.ts +12 -0
- package/dist/constants.js +12 -0
- package/dist/main.d.ts +9 -5
- package/dist/main.js +10 -6
- package/dist/state-store.d.ts +56 -2
- package/dist/state-store.js +71 -10
- package/package.json +1 -1
- package/src/auth-request.ts +209 -31
- package/src/constants.ts +13 -0
- package/src/main.ts +10 -6
- package/src/state-store.ts +78 -9
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
|
|
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
|
|
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
|
|
102
|
-
|
|
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.
|
|
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
|
|
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
|
|
package/dist/auth-request.d.ts
CHANGED
|
@@ -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,
|
|
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
|
-
|
|
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 {};
|
package/dist/auth-request.js
CHANGED
|
@@ -1,11 +1,52 @@
|
|
|
1
1
|
import { Request } from '@stonyx/rest-server';
|
|
2
2
|
import log from 'stonyx/log';
|
|
3
|
-
import {
|
|
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
|
|
6
|
-
* `
|
|
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', '
|
|
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
|
|
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,
|
|
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.
|
|
78
|
-
//
|
|
79
|
-
|
|
80
|
-
|
|
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
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
330
|
+
values.push(part.slice(separator + 1).trim());
|
|
168
331
|
}
|
|
169
|
-
return
|
|
332
|
+
return values;
|
|
170
333
|
}
|
|
171
334
|
clearBindingCookie(req) {
|
|
172
335
|
const { res } = req;
|
package/dist/constants.d.ts
CHANGED
|
@@ -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
|
-
* `
|
|
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.
|
|
34
|
-
*
|
|
35
|
-
*
|
|
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,
|
|
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
|
-
* `
|
|
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.
|
|
59
|
-
*
|
|
60
|
-
*
|
|
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,
|
|
63
|
-
this.stateStore.consume(stateToken, providerName,
|
|
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);
|
package/dist/state-store.d.ts
CHANGED
|
@@ -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
|
-
|
|
113
|
+
anyCandidateMatches(candidates: readonly string[], record: PendingState): boolean;
|
|
60
114
|
}
|
package/dist/state-store.js
CHANGED
|
@@ -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,
|
|
100
|
+
consume(stateToken, provider, bindingValues) {
|
|
65
101
|
if (!stateToken)
|
|
66
|
-
throw new
|
|
102
|
+
throw new StateRejection(STATE_REJECTION.unknownState, false);
|
|
67
103
|
const record = this.pending.get(stateToken);
|
|
68
104
|
if (!record)
|
|
69
|
-
throw new
|
|
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
|
|
108
|
+
throw new StateRejection(STATE_REJECTION.expired, true);
|
|
73
109
|
if (record.provider !== provider)
|
|
74
|
-
throw new
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
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
package/src/auth-request.ts
CHANGED
|
@@ -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
|
|
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`.
|
|
18
37
|
*/
|
|
19
|
-
const
|
|
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
|
-
|
|
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
|
|
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,
|
|
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.
|
|
147
|
-
//
|
|
148
|
-
|
|
149
|
-
|
|
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
|
-
|
|
201
|
-
|
|
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
|
-
|
|
207
|
-
|
|
208
|
-
if (hostname === 'localhost' || hostname.endsWith('.localhost')) return false;
|
|
297
|
+
return !AuthRequest.isLoopbackHost(hostname);
|
|
298
|
+
}
|
|
209
299
|
|
|
210
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
421
|
+
values.push(part.slice(separator + 1).trim());
|
|
244
422
|
}
|
|
245
423
|
|
|
246
|
-
return
|
|
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
|
-
* `
|
|
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.
|
|
93
|
-
*
|
|
94
|
-
*
|
|
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
|
-
|
|
104
|
+
bindingValues: readonly string[],
|
|
101
105
|
) {
|
|
102
|
-
this.stateStore.consume(stateToken, providerName,
|
|
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);
|
package/src/state-store.ts
CHANGED
|
@@ -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,
|
|
94
|
-
if (!stateToken) throw new
|
|
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
|
|
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
|
|
101
|
-
if (record.provider !== provider) throw new
|
|
102
|
-
|
|
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 (!
|
|
105
|
-
throw new
|
|
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
|
}
|