@stonyx/rest-server 0.2.1-beta.90 → 0.2.1-beta.92
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 +213 -55
- package/config/environment.js +103 -3
- package/dist/main.d.ts.map +1 -1
- package/dist/main.js +11 -3
- package/dist/main.js.map +1 -1
- package/dist/request.d.ts.map +1 -1
- package/dist/request.js +58 -6
- package/dist/request.js.map +1 -1
- package/dist/route-matching.d.ts +112 -2
- package/dist/route-matching.d.ts.map +1 -1
- package/dist/route-matching.js +134 -1
- package/dist/route-matching.js.map +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -79,56 +79,131 @@ Configuration is read from `stonyx/config` under `restServer`:
|
|
|
79
79
|
| `origin` | **String \| Array** | `'*'` | CORS origin(s) allowed |
|
|
80
80
|
| `methods` | **String** | `'GET,POST,PATCH,PUT,DELETE'` | CORS allowed methods |
|
|
81
81
|
| `enableHealthCheck` | **Boolean** | `true` | Register `GET /health` endpoint (disable via `REST_HEALTH_CHECK_DISABLE=true`) |
|
|
82
|
-
| `caseSensitiveRoutes` | **Boolean** | `true` | Match route paths case-sensitively. Disable via `REST_CASE_SENSITIVE_ROUTES=false`. See [
|
|
82
|
+
| `caseSensitiveRoutes` | **Boolean** | `true` | Match route paths case-sensitively. Disable via `REST_CASE_SENSITIVE_ROUTES=false`. See [Route Matching Strictness](#route-matching-strictness) — **disabling this re-opens a security hole**. |
|
|
83
|
+
| `strictRoutes` | **Boolean** | `true` | Match route paths strictly, so a trailing slash does not match a route registered without one. Disable via `REST_STRICT_ROUTES=false`. See [Route Matching Strictness](#route-matching-strictness) — **disabling this re-opens a security hole**, and note `GET /health/` now 404s. |
|
|
84
|
+
| `canonicalRoutes` | **Boolean** | `true` | Reject a request whose raw target is not the canonical path express matched, before your `auth` hook runs. Disable via `REST_CANONICAL_ROUTES=false`. See [Route Matching Strictness](#route-matching-strictness) — **disabling this re-opens a security hole**, and note `GET /route/` at a mount root and every absolute-form request target now 404 on the routes this module registers (see the scope limit under [Upgrading](#upgrading-behaviour-changes)). |
|
|
83
85
|
| `trustProxy` | **Boolean** | `false` | Trust reverse proxy headers (e.g. `X-Forwarded-Proto`). Enable via `REST_TRUST_PROXY=true` when running behind a load balancer such as AWS ALB/ELB to ensure correct protocol detection. |
|
|
84
86
|
| `statusMap` | **Object** | `{}` | Optional mapping of HTTP status codes to custom messages |
|
|
85
87
|
|
|
86
|
-
###
|
|
88
|
+
### Route Matching Strictness
|
|
87
89
|
|
|
88
|
-
Routes match **case-sensitively
|
|
89
|
-
|
|
90
|
-
`GET /users/Success` does not reach a `/success` handler registered inside it.
|
|
90
|
+
Routes match **case-sensitively, strictly, and only at their canonical target
|
|
91
|
+
by default**. Three controls, all on:
|
|
91
92
|
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
93
|
+
| axis | control | example that no longer matches |
|
|
94
|
+
|---|---|---|
|
|
95
|
+
| casing | `case sensitive routing` (setting) | `GET /users/Success` -> does not reach `/success` |
|
|
96
|
+
| trailing slash | `strict routing` (setting) | `GET /users/success/` -> does not reach `/success` |
|
|
97
|
+
| canonical target | `canonicalRoutes` (per-request check) | `GET /users/` and `GET http://host/users` -> do not reach the mounted `/users` class |
|
|
96
98
|
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
99
|
+
The first two are express settings applied at both construction sites. The
|
|
100
|
+
third is **not a setting** — no express setting can express it — it is a
|
|
101
|
+
per-request comparison of the raw request target against the path express
|
|
102
|
+
matched, run ahead of your `auth` hook. See
|
|
103
|
+
[`src/route-matching.ts`](src/route-matching.ts).
|
|
104
|
+
|
|
105
|
+
Read [What this does not do](#what-this-does-not-do) and
|
|
106
|
+
[Upgrading](#upgrading-behaviour-changes) before you rely on that. One thing the
|
|
107
|
+
table does not say: "does not reach the handler" is not the same as "404".
|
|
108
|
+
|
|
109
|
+
This is deliberate and security-relevant. Express matches both case-insensitively
|
|
110
|
+
and slash-insensitively by default, which means any authorization written
|
|
111
|
+
against the request URL can be walked past by changing the case of the request,
|
|
112
|
+
or by appending one character:
|
|
100
113
|
|
|
101
114
|
```
|
|
102
|
-
GET /owners/angela
|
|
103
|
-
GET /OwNeRs/angela
|
|
104
|
-
|
|
115
|
+
GET /owners/angela -> 404 (correctly filtered)
|
|
116
|
+
GET /OwNeRs/angela -> 200 (full record) <- closed by case sensitive routing
|
|
117
|
+
GET /owners/angela/ -> 200 (full record) <- closed by strict routing
|
|
118
|
+
DELETE /ANIMALS/22 -> 204 (record destroyed)
|
|
119
|
+
DELETE /animals/22/ -> 204 (record destroyed)
|
|
105
120
|
```
|
|
106
121
|
|
|
107
122
|
The consumer's predicate is stricter than the router that dispatched the
|
|
108
123
|
request, so the router hands the handler a request the predicate would have
|
|
109
|
-
rejected.
|
|
110
|
-
|
|
124
|
+
rejected. Measured against this repo's own fixture, before and after:
|
|
125
|
+
|
|
126
|
+
```
|
|
127
|
+
before after
|
|
128
|
+
GET /private/failure 505 505 (auth hook fires, request blocked)
|
|
129
|
+
GET /private/failure/ 200 404 (auth hook never fired; now a miss)
|
|
130
|
+
GET /private/FAILURE 200 200 (absorbed by /:id — see below)
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
For a handler that authorizes on `req.path`, the path it sees can now only ever
|
|
134
|
+
be the exact registered spelling, in the exact registered casing, with no
|
|
135
|
+
trailing slash. That closes [#47](https://github.com/abofs/stonyx-rest-server/issues/47)
|
|
136
|
+
and [#50](https://github.com/abofs/stonyx-rest-server/issues/50).
|
|
111
137
|
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
138
|
+
#### The canonical-target check (`canonicalRoutes`)
|
|
139
|
+
|
|
140
|
+
**No express *setting* closes the trailing slash on a mount root**, and that has
|
|
141
|
+
not changed:
|
|
116
142
|
|
|
117
143
|
```
|
|
118
|
-
GET /
|
|
119
|
-
GET /
|
|
144
|
+
GET /public -> req.path '/' req.originalUrl '/public'
|
|
145
|
+
GET /public/ -> req.path '/' req.originalUrl '/public/'
|
|
120
146
|
```
|
|
121
147
|
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
and
|
|
148
|
+
Express's router applies mount-prefix matching with `strict: false`
|
|
149
|
+
unconditionally (`router@2.2.0`; the file-and-line citation is in
|
|
150
|
+
[`docs/project-structure.md`](docs/project-structure.md) § *Strict routing
|
|
151
|
+
(#50)*), so both forms reach the mounted route class and both arrive with
|
|
152
|
+
`req.path === '/'`. A hook authorizing on `req.path` cannot tell them apart, so
|
|
153
|
+
for that hook there is no asymmetry to exploit — and there is nothing for
|
|
154
|
+
`strict routing` to reject. **A hook comparing `req.originalUrl` sees two
|
|
155
|
+
different strings**, and that was a live authorization bypass.
|
|
156
|
+
|
|
157
|
+
`canonicalRoutes` closes it, as a per-request check rather than a setting
|
|
158
|
+
([#54](https://github.com/abofs/stonyx-rest-server/issues/54)). Before your
|
|
159
|
+
`auth` hook runs, the raw request target is compared against the path express
|
|
160
|
+
matched, and a mismatch is rejected as a plain 404. It closes **two** vectors
|
|
161
|
+
against `req.originalUrl` — the field express does not normalize:
|
|
162
|
+
|
|
163
|
+
```
|
|
164
|
+
before after
|
|
165
|
+
GET /admin 401 401 (hook fires, request blocked)
|
|
166
|
+
GET /admin/ 200 404 (hook never fired; now a miss)
|
|
167
|
+
GET http://host/admin 200 404 (absolute-form; hook never fired)
|
|
168
|
+
GET http://host/admin/settings 200 404 (absolute-form; every route from a request class)
|
|
169
|
+
```
|
|
128
170
|
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
171
|
+
The second vector is the one to check first. [RFC 9112
|
|
172
|
+
§3.2.2](https://www.rfc-editor.org/rfc/rfc9112#section-3.2.2) permits an
|
|
173
|
+
**absolute-form** request target, express routes it, and it hands your hook the
|
|
174
|
+
whole URI — `req.originalUrl === "http://host/admin"`. Unlike the mount-root
|
|
175
|
+
slash, that affects **every route mounted from a request class**, not one edge.
|
|
176
|
+
The qualifier is a registration-site limit, not a special case for one URL: the
|
|
177
|
+
check runs inside the handlers this module registers, so anything you register
|
|
178
|
+
directly on `RestServer.instance.api` is outside it. In this repo that is
|
|
179
|
+
`/health` alone, and `GET http://host/health` still returns 200 — measured.
|
|
180
|
+
|
|
181
|
+
The target is compared **raw**. It is not parsed, normalized or resolved first:
|
|
182
|
+
normalizing it would launder exactly the string your hook is exposed to and
|
|
183
|
+
re-open the absolute-form vector by construction. Only the query string is
|
|
184
|
+
removed before comparison, so `GET /admin?x=1` still reaches your hook — **strip
|
|
185
|
+
the query yourself** if your hook compares `req.originalUrl` against a fixed
|
|
186
|
+
path, or it will not match (see [Consumer
|
|
187
|
+
Contracts](#consumer-contracts)). Rejections are indistinguishable from a
|
|
188
|
+
genuine miss by design; see [Upgrading](#upgrading-behaviour-changes).
|
|
189
|
+
|
|
190
|
+
Routes registered *with* a literal trailing slash are unaffected — their
|
|
191
|
+
canonical target carries the slash. So are index-mounted route classes and
|
|
192
|
+
query strings on canonical paths.
|
|
193
|
+
|
|
194
|
+
**Param routes: "unaffected" means "no regression", not "no residual".**
|
|
195
|
+
`/resource/:id` keeps matching exactly as it did. But percent-encoding is not
|
|
196
|
+
normalized on either side of the comparison — express decodes only
|
|
197
|
+
`req.params`, not `req.path` and not `req.originalUrl` — so `target` and
|
|
198
|
+
`canonical` are *both* the encoded string and this check passes the request
|
|
199
|
+
through by construction. Measured on a hook that authorizes on
|
|
200
|
+
`req.originalUrl` with the query correctly stripped, on a `/:id` route:
|
|
201
|
+
`GET /enc/secret` → 401, `GET /enc/%73ecret` → **200 with the guarded handler
|
|
202
|
+
running unauthenticated**, identically before and after this change. It is a
|
|
203
|
+
residual, not something `canonicalRoutes` introduced, and it is **wider** than
|
|
204
|
+
the two vectors above: `%73` defeats a `req.path` hook as well. Tracked as
|
|
205
|
+
[#56](https://github.com/abofs/stonyx-rest-server/issues/56) — until it ships,
|
|
206
|
+
do not read a param-segment route class as covered on this axis.
|
|
132
207
|
|
|
133
208
|
#### What this does not do
|
|
134
209
|
|
|
@@ -148,36 +223,119 @@ repo's AC5 asserts exactly that. A class exposing `/orders/summary` alongside
|
|
|
148
223
|
database lookup. The param route's own `auth()` hook still runs, so this is an
|
|
149
224
|
expectation defect rather than a bypass — but plan for a reroute, not a 404.
|
|
150
225
|
|
|
151
|
-
|
|
152
|
-
|
|
226
|
+
Note the two axes differ here. A *trailing slash* is not absorbed by `/:id`,
|
|
227
|
+
because `/:id` is equally strict: `GET /private/failure/` misses `/failure` and
|
|
228
|
+
misses `/:id`, and is a true 404.
|
|
229
|
+
|
|
230
|
+
**It does not redirect or rewrite** mixed-case or trailing-slash requests to
|
|
231
|
+
their canonical form. Whether `/Users` is a typo to forgive or an attack to
|
|
232
|
+
reject is an application policy decision, and encoding it here would mint
|
|
233
|
+
another variant of the bug above.
|
|
234
|
+
|
|
235
|
+
#### Upgrading: behaviour changes
|
|
236
|
+
|
|
237
|
+
All three controls change which requests match, so all three are
|
|
238
|
+
consumer-visible.
|
|
239
|
+
|
|
240
|
+
**Clients or forward proxies sending absolute-form request targets now get 404
|
|
241
|
+
on every route mounted from a request class.** `GET http://host/admin HTTP/1.1`
|
|
242
|
+
is a legal request target
|
|
243
|
+
([RFC 9112 §3.2.2](https://www.rfc-editor.org/rfc/rfc9112#section-3.2.2)), and
|
|
244
|
+
express used to route it. It is now rejected on every route this module
|
|
245
|
+
registers — for a client that emits it, this is a total outage, not a partial
|
|
246
|
+
one, and it is the largest blast radius in this change. The one carve-out is a
|
|
247
|
+
**registration site**, not a route: the check lives in the handlers mounted from
|
|
248
|
+
your request classes, so anything registered directly on
|
|
249
|
+
`RestServer.instance.api` never reaches it. `GET /health` is the only such route
|
|
250
|
+
in this repo, and `GET http://host/health` still returns 200 — so do not use it
|
|
251
|
+
to confirm the new rejection is live, and do not assume an authorized route you
|
|
252
|
+
registered on `api` yourself is covered. Reverse proxies in normal use (nginx,
|
|
253
|
+
HAProxy, AWS ALB) send origin-form and are unaffected; **forward** proxies and
|
|
254
|
+
hand-rolled HTTP clients are the exposure. Remediation is
|
|
255
|
+
`REST_CANONICAL_ROUTES=false`, or fix the client.
|
|
256
|
+
|
|
257
|
+
**`GET /route/` at a mounted route class's root now returns 404.** Previously
|
|
258
|
+
200. If a client appends a trailing slash to a mount root, it stops working.
|
|
259
|
+
|
|
260
|
+
Both rejections are **indistinguishable from a route that was never
|
|
261
|
+
registered** — same status, same `Content-Type`, same headers — which is the
|
|
262
|
+
intended security property: a distinguishable rejection is an oracle telling an
|
|
263
|
+
attacker the route exists and was merely spelled wrong. Combined with this
|
|
264
|
+
module emitting **no request logging**, a broken client shows up as a bare
|
|
265
|
+
`Cannot GET …` with nothing at all on the server side. **Check this first if
|
|
266
|
+
routes start 404ing after upgrade.**
|
|
267
|
+
|
|
268
|
+
**`GET /health/` now returns 404.** `GET /health` is unaffected. This is the
|
|
269
|
+
change most likely to page someone, and it is an **availability** problem rather
|
|
270
|
+
than a 404 you will read about in a log: if a Kubernetes liveness probe, an ELB
|
|
271
|
+
target-group health check or an uptime monitor is pointed at the trailing-slash
|
|
272
|
+
form, it starts failing and the deployment gets marked unhealthy and cycled.
|
|
273
|
+
This module emits no request logging, so the only symptom is the probe going
|
|
274
|
+
red. **Check your probe URLs before upgrading.**
|
|
275
|
+
|
|
276
|
+
Also affected:
|
|
277
|
+
|
|
278
|
+
- **Param routes.** `/resource/:id/` no longer matches. Any client calling
|
|
279
|
+
`/private/restricted/` gets a 404 where it previously got the param route.
|
|
280
|
+
- **Trailing-slash-normalizing proxies.** nginx `try_files`/`rewrite`, Apache
|
|
281
|
+
`DirectorySlash On` and some CDN edge rules append a slash; behind one of
|
|
282
|
+
those, every route stops matching at once.
|
|
283
|
+
- **Mount paths from filenames.** With `camelCaseRoutes` truthy, `phone-number.ts`
|
|
284
|
+
mounts at `/phoneNumber`, so `GET /phonenumber` returns 404; with it falsy,
|
|
285
|
+
`Users.ts` mounts at `/Users`, so `GET /users` returns 404.
|
|
153
286
|
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
application policy decision, and encoding it here would mint another variant of
|
|
157
|
-
the bug above.
|
|
287
|
+
A request that stops matching returns express's default `404 Cannot GET /x` with
|
|
288
|
+
no log line and no stack, so it looks like a deploy that dropped a route.
|
|
158
289
|
|
|
159
290
|
#### Opting out
|
|
160
291
|
|
|
292
|
+
Three separate flags, one per axis:
|
|
293
|
+
|
|
161
294
|
```bash
|
|
162
|
-
REST_CASE_SENSITIVE_ROUTES=false
|
|
295
|
+
REST_CASE_SENSITIVE_ROUTES=false # restores case-insensitive matching (#47)
|
|
296
|
+
REST_STRICT_ROUTES=false # restores trailing-slash tolerance (#50)
|
|
297
|
+
REST_CANONICAL_ROUTES=false # restores non-canonical request targets (#54)
|
|
163
298
|
```
|
|
164
299
|
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
300
|
+
or equivalently
|
|
301
|
+
`restServer: { caseSensitiveRoutes: false, strictRoutes: false, canonicalRoutes: false }`.
|
|
302
|
+
|
|
303
|
+
**They are deliberately separate keys, and none implies the others.** Slash
|
|
304
|
+
tolerance is a legitimate need — a health-check URL you cannot change today is
|
|
305
|
+
the common case. Casing tolerance almost never is. Folding them into one flag
|
|
306
|
+
would force anyone who needs the first to accept the second, which is why a
|
|
307
|
+
consumer who took the `#47` opt-out still has to set `REST_STRICT_ROUTES=false`
|
|
308
|
+
separately to keep trailing slashes working. `REST_CANONICAL_ROUTES=false` is
|
|
309
|
+
separate for the same reason: a consumer who needs mount-root slash tolerance
|
|
310
|
+
should not have to re-open `#50`'s sub-path bypass to get it.
|
|
311
|
+
|
|
312
|
+
`REST_CANONICAL_ROUTES=false` is a **temporary remediation**, not a
|
|
313
|
+
configuration to run on. It re-opens both `#54` vectors at once — the mount-root
|
|
314
|
+
slash *and* the absolute-form target — against any hook authorizing on
|
|
315
|
+
`req.originalUrl`. It is env-only, so restoring service does not need a
|
|
316
|
+
redeploy; use it to stop the bleeding, then fix the client and remove it.
|
|
317
|
+
|
|
318
|
+
**Each flag restores the corresponding vulnerability described above** — the
|
|
319
|
+
URL-based authorization in your application becomes bypassable along that axis
|
|
320
|
+
again. They exist as one-line remediations for an existing deployment, not as a
|
|
321
|
+
configuration to run on. Set the flag to restore service, then fix the client
|
|
322
|
+
and remove the flag.
|
|
323
|
+
|
|
324
|
+
### Consumer Contracts
|
|
325
|
+
|
|
326
|
+
Three things this module deliberately does **not** do for you. Each is a state
|
|
327
|
+
the framework permits, only your own discipline prevents, and that produces **no
|
|
328
|
+
error and no log** when that discipline lapses — so they are collected here
|
|
329
|
+
rather than left implied by the sections above.
|
|
330
|
+
|
|
331
|
+
| you must | because | symptom if you don't |
|
|
332
|
+
|---|---|---|
|
|
333
|
+
| **Strip the query string** before comparing `req.originalUrl` to a fixed path in an `auth` hook | `canonicalRoutes` compares the query-*stripped* target — a query string is a legitimately variable part of a request target, and rejecting on it would 404 every `?`-carrying request | `GET /admin?x=1` reaches your guarded handler **unauthenticated**, 200, no error, no log. Measured identical before and after `canonicalRoutes` |
|
|
334
|
+
| **Compare param values with the same decoding and casing you look them up with** | express decodes only `req.params`; `req.path` and `req.originalUrl` both stay percent-encoded, and param *values* are never case-normalized | `GET /orders/%73ecret` runs the handler with `id === "secret"` while your hook compared `%73ecret` and did not match — unauthenticated 200. Tracked as [#56](https://github.com/abofs/stonyx-rest-server/issues/56) |
|
|
335
|
+
| **Return `undefined` from `auth()` to mean "authorized"** — never `0` | any integer return is sent as the HTTP status | returning `0` sends a `0` status rather than allowing the request |
|
|
336
|
+
|
|
337
|
+
`test/sample/requests/admin.ts` in this repo is the worked example of a
|
|
338
|
+
correctly-written `originalUrl` hook for the first row.
|
|
181
339
|
|
|
182
340
|
### Running Behind a Load Balancer
|
|
183
341
|
|
package/config/environment.js
CHANGED
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
const {
|
|
2
|
+
REST_CANONICAL_ROUTES,
|
|
2
3
|
REST_CASE_SENSITIVE_ROUTES,
|
|
3
4
|
REST_CORS_ORIGIN,
|
|
4
5
|
REST_CORS_METHODS,
|
|
5
6
|
REST_HEALTH_CHECK_DISABLE,
|
|
6
7
|
REST_PORT,
|
|
7
8
|
REST_REQUEST_PATH,
|
|
9
|
+
REST_STRICT_ROUTES,
|
|
8
10
|
REST_TRUST_PROXY
|
|
9
11
|
} = process.env;
|
|
10
12
|
|
|
@@ -21,16 +23,114 @@ const config = {
|
|
|
21
23
|
// stubs `caseSensitiveRoutes` to `undefined` and src/route-matching.ts reads
|
|
22
24
|
// `!== false`, so AC6 guards the source's read and not this default.
|
|
23
25
|
// Measured: pin `caseSensitiveRoutes: true` in test/config/environment.ts AND
|
|
24
|
-
// invert this line, and the suite reports
|
|
26
|
+
// invert this line, and the suite reports 34 pass / 0 fail. A naive pin makes
|
|
25
27
|
// an insecure published default completely invisible to a green suite --
|
|
26
28
|
// quieter and weaker, which is the outcome pinning was supposed to prevent.
|
|
29
|
+
// Inverting this line ALONE, unpinned, reports 31 pass / 3 fail (#47's AC3,
|
|
30
|
+
// AC4 and AC5; AC6 green).
|
|
27
31
|
//
|
|
28
32
|
// The cost of leaving it unpinned is that the suite is ambient-sensitive here
|
|
29
|
-
// (`REST_CASE_SENSITIVE_ROUTES=false pnpm test` =>
|
|
30
|
-
// fails LOUDLY, so there is no false green.
|
|
33
|
+
// (`REST_CASE_SENSITIVE_ROUTES=false pnpm test` => 31 pass / 3 fail), but it
|
|
34
|
+
// fails LOUDLY, so there is no false green.
|
|
35
|
+
//
|
|
36
|
+
// Every count in this block was re-measured against the 34-test suite at
|
|
37
|
+
// #54's head. PASS totals here move whenever a test is ADDED anywhere in the
|
|
38
|
+
// repo -- the FAIL counts are the load-bearing half. Re-measure, do not
|
|
39
|
+
// adjust by arithmetic, when this block next looks stale. Closing #43 for this key needs
|
|
31
40
|
// the subprocess-based env isolation this repo does not yet have; any fix
|
|
32
41
|
// must keep a live assertion on this default.
|
|
33
42
|
caseSensitiveRoutes: REST_CASE_SENSITIVE_ROUTES !== 'false',
|
|
43
|
+
|
|
44
|
+
// Secure by default, same polarity and same reasoning as caseSensitiveRoutes
|
|
45
|
+
// above: routes match strictly, so a trailing slash cannot walk past a
|
|
46
|
+
// consumer's URL-based authorization (abofs/stonyx-rest-server#50).
|
|
47
|
+
//
|
|
48
|
+
// BEHAVIOUR CHANGE for consumers upgrading: `/health/` now returns 404, and
|
|
49
|
+
// param routes like `/resource/:id/` no longer match. Opt out with
|
|
50
|
+
// REST_STRICT_ROUTES=false only as a temporary remediation. It is a separate
|
|
51
|
+
// key from REST_CASE_SENSITIVE_ROUTES on purpose -- opting out of slash
|
|
52
|
+
// strictness must not silently re-open #47's case bypass.
|
|
53
|
+
//
|
|
54
|
+
// DELIBERATELY NOT PINNED in test/config/environment.ts -- do not "fix" this
|
|
55
|
+
// as part of abofs/stonyx-rest-server#43. Same trap as the key above, and now
|
|
56
|
+
// measured for both: pin `strictRoutes: true` in test/config/environment.ts
|
|
57
|
+
// AND invert this line to `=== 'true'`, and the suite reports 34 pass /
|
|
58
|
+
// 0 fail. A naive pin makes an insecure published default completely
|
|
59
|
+
// invisible to a green suite.
|
|
60
|
+
//
|
|
61
|
+
// Unpinned, inverting this line alone turns #50's AC1 and AC2 red (32/2).
|
|
62
|
+
// AC3 stays GREEN under that mutation, because AC3 sets `strictRoutes` on the
|
|
63
|
+
// config object directly and so guards src/route-matching.ts's READ rather
|
|
64
|
+
// than this default -- the two assertions cover different halves and neither
|
|
65
|
+
// subsumes the other.
|
|
66
|
+
//
|
|
67
|
+
// The cost is that the suite is ambient-sensitive here
|
|
68
|
+
// (`REST_STRICT_ROUTES=false pnpm test` => 32 pass / 2 fail), but it fails
|
|
69
|
+
// LOUDLY, so there is no false green. All counts in this block re-measured
|
|
70
|
+
// against the 34-test suite at #54's head; the FAIL count is the load-bearing
|
|
71
|
+
// half, since PASS totals move whenever a test is added anywhere. Closing #43 for either key needs
|
|
72
|
+
// subprocess-based env isolation this repo does not have; any fix must keep a
|
|
73
|
+
// live assertion on this default.
|
|
74
|
+
strictRoutes: REST_STRICT_ROUTES !== 'false',
|
|
75
|
+
|
|
76
|
+
// Secure by default, same polarity and same reasoning as the two keys above:
|
|
77
|
+
// a request whose RAW target is not the canonical path express matched is
|
|
78
|
+
// rejected with a plain 404 before the consumer's `auth` hook runs
|
|
79
|
+
// (abofs/stonyx-rest-server#54). This is NOT an express setting -- it is a
|
|
80
|
+
// per-request check in src/route-matching.ts (`shouldRejectTarget`), called
|
|
81
|
+
// from the handler closure in src/request.ts.
|
|
82
|
+
//
|
|
83
|
+
// BEHAVIOUR CHANGE for consumers upgrading, on TWO axes:
|
|
84
|
+
// 1. `GET /route/` at a mounted route class's ROOT now returns 404.
|
|
85
|
+
// 2. Clients or forward proxies emitting an ABSOLUTE-FORM request target
|
|
86
|
+
// (`GET http://host/admin HTTP/1.1`, RFC 9112 3.2.2) now receive 404 on
|
|
87
|
+
// every route registered through Request.registerCalls(). That is a
|
|
88
|
+
// REGISTRATION-SITE limit, not a carve-out for one URL: /health is
|
|
89
|
+
// registered directly on the parent app (src/main.ts) and still answers
|
|
90
|
+
// 200 to an absolute-form target -- and so would any route a consumer
|
|
91
|
+
// registers on the public RestServer.instance.api itself. This is the
|
|
92
|
+
// larger blast radius: for such a client it is a total outage, not a
|
|
93
|
+
// partial one. Reverse proxies in normal use
|
|
94
|
+
// (nginx, HAProxy, ALB) send origin-form and are unaffected.
|
|
95
|
+
// This module emits no request log, so both look like a dropped route.
|
|
96
|
+
//
|
|
97
|
+
// Opt out with REST_CANONICAL_ROUTES=false only as a temporary remediation --
|
|
98
|
+
// it RE-OPENS the bypass. Separate key from REST_STRICT_ROUTES and
|
|
99
|
+
// REST_CASE_SENSITIVE_ROUTES on purpose: coupling it to strictness would
|
|
100
|
+
// force a consumer who needs mount-root slash tolerance to also re-open #50's
|
|
101
|
+
// sub-path bypass.
|
|
102
|
+
//
|
|
103
|
+
// Note trustProxy below deliberately uses `=== 'true'` instead. That is not
|
|
104
|
+
// an inconsistency to "fix": its safe default is FALSY, so a truthy check
|
|
105
|
+
// already fails closed for it. The rule is "the guard must fail toward the
|
|
106
|
+
// safe value", not "all guards look alike".
|
|
107
|
+
//
|
|
108
|
+
// DELIBERATELY NOT PINNED in test/config/environment.ts -- do not "fix" this
|
|
109
|
+
// as part of abofs/stonyx-rest-server#43. Same trap as the two keys above,
|
|
110
|
+
// and now measured for all three: pin `canonicalRoutes: true` in
|
|
111
|
+
// test/config/environment.ts AND invert this line to `=== 'true'`, and the
|
|
112
|
+
// suite reports 34 pass / 0 fail while an insecure default ships. The pin is
|
|
113
|
+
// quieter AND weaker than no pin, which is the outcome pinning was supposed
|
|
114
|
+
// to prevent.
|
|
115
|
+
//
|
|
116
|
+
// Unpinned, inverting this line alone reports 32 pass / 2 fail: #54's
|
|
117
|
+
// integration AC1 and #50's AC2. AC2 (unit) stays GREEN under that mutation,
|
|
118
|
+
// because it sets `canonicalRoutes` on the config object directly and so
|
|
119
|
+
// guards src/route-matching.ts's READ rather than this default -- the two
|
|
120
|
+
// assertions cover different halves and neither subsumes the other.
|
|
121
|
+
// Conversely, weakening the READ to `=== true` reports 33 pass / 1 fail with
|
|
122
|
+
// AC2 as the only failure and AC1 fully green. All four counts in this block
|
|
123
|
+
// were re-measured on the #54 branch head after the AC1.11/AC1.12 assertions
|
|
124
|
+
// were added; the suite is 34 tests, and the FAIL count is the load-bearing
|
|
125
|
+
// half.
|
|
126
|
+
//
|
|
127
|
+
// The cost is that the suite is ambient-sensitive here
|
|
128
|
+
// (`REST_CANONICAL_ROUTES=false pnpm test` => 32 pass / 2 fail), but it fails
|
|
129
|
+
// LOUDLY, so there is no false green. Closing #43 for any of the three keys
|
|
130
|
+
// needs subprocess-based env isolation this repo does not have; any fix must
|
|
131
|
+
// keep a live assertion on this default.
|
|
132
|
+
canonicalRoutes: REST_CANONICAL_ROUTES !== 'false',
|
|
133
|
+
|
|
34
134
|
enableHealthCheck: REST_HEALTH_CHECK_DISABLE !== 'true',
|
|
35
135
|
origin: REST_CORS_ORIGIN ?? '*',
|
|
36
136
|
methods: REST_CORS_METHODS ?? 'GET,POST,PATCH,PUT,DELETE',
|
package/dist/main.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"main.d.ts","sourceRoot":"","sources":["../src/main.ts"],"names":[],"mappings":"AAiBA,OAAgB,EAAE,KAAK,OAAO,EAAoE,MAAM,SAAS,CAAC;AAKlH,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,MAAM,CAAC;AAEnC,OAAO,EAAE,OAAO,IAAI,OAAO,EAAE,MAAM,cAAc,CAAC;AAElD,MAAM,CAAC,OAAO,OAAO,UAAU;IAC7B,MAAM,CAAC,QAAQ,EAAE,UAAU,CAAC;IAE5B,GAAG,EAAG,OAAO,CAAC;IACd,MAAM,EAAG,MAAM,CAAC;;
|
|
1
|
+
{"version":3,"file":"main.d.ts","sourceRoot":"","sources":["../src/main.ts"],"names":[],"mappings":"AAiBA,OAAgB,EAAE,KAAK,OAAO,EAAoE,MAAM,SAAS,CAAC;AAKlH,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,MAAM,CAAC;AAEnC,OAAO,EAAE,OAAO,IAAI,OAAO,EAAE,MAAM,cAAc,CAAC;AAElD,MAAM,CAAC,OAAO,OAAO,UAAU;IAC7B,MAAM,CAAC,QAAQ,EAAE,UAAU,CAAC;IAE5B,GAAG,EAAG,OAAO,CAAC;IACd,MAAM,EAAG,MAAM,CAAC;;IAwBhB,MAAM,CAAC,KAAK,IAAI,IAAI;IAQd,IAAI,IAAI,OAAO,CAAC,IAAI,CAAC;IAerB,WAAW,IAAI,OAAO,CAAC,IAAI,CAAC;IAelC,qBAAqB,IAAI,IAAI;IAW7B,UAAU,CAAC,iBAAiB,EAAE,OAAO,EAAE,EAAE,IAAI,EAAE,OAAO,EAAE,EAAE;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,OAAO,CAAC,EAAE,OAAO,CAAA;KAAE,GAAG,IAAI;CAarG"}
|
package/dist/main.js
CHANGED
|
@@ -29,11 +29,19 @@ export default class RestServer {
|
|
|
29
29
|
return RestServer.instance;
|
|
30
30
|
RestServer.instance = this;
|
|
31
31
|
this.api = express();
|
|
32
|
-
//
|
|
32
|
+
// Applies BOTH route-matching settings: case sensitive routing
|
|
33
|
+
// (abofs/stonyx-rest-server#47) and strict routing (#50). The two do not
|
|
34
|
+
// have the same role at this site:
|
|
35
|
+
// - #47: this call closes the mount segment (/PUBLIC/...). The matching
|
|
36
|
+
// call in Request's constructor closes sub-paths; both are required.
|
|
37
|
+
// - #50: this call closes exactly /health/, the only route registered
|
|
38
|
+
// directly on this app. It has NO security role for #50 -- do not
|
|
39
|
+
// describe it as having one. Router.prototype.use hardcodes
|
|
40
|
+
// `strict: false`, so mount segments are strict-immune, and the call in
|
|
41
|
+
// Request's constructor closes the trailing-slash bypass on its own.
|
|
33
42
|
// Must stay in the constructor: the router is materialized lazily on first
|
|
34
43
|
// route registration, so applying this after setupRouter() is silently
|
|
35
|
-
// ineffective.
|
|
36
|
-
// sub-paths -- see src/route-matching.ts for why both are required.
|
|
44
|
+
// ineffective. See src/route-matching.ts for the per-site split.
|
|
37
45
|
applyRouteMatching(this.api);
|
|
38
46
|
}
|
|
39
47
|
static close() {
|
package/dist/main.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"main.js","sourceRoot":"","sources":["../src/main.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;GAcG;AAEH,OAAO,IAAI,MAAM,MAAM,CAAC;AACxB,OAAO,OAA2F,MAAM,SAAS,CAAC;AAClH,OAAO,MAAM,MAAM,eAAe,CAAC;AACnC,OAAO,GAAG,MAAM,YAAY,CAAC;AAC7B,OAAO,EAAE,iBAAiB,EAAE,MAAM,oBAAoB,CAAC;AACvD,OAAO,kBAAkB,MAAM,qBAAqB,CAAC;AAGrD,OAAO,EAAE,OAAO,IAAI,OAAO,EAAE,MAAM,cAAc,CAAC;AAElD,MAAM,CAAC,OAAO,OAAO,UAAU;IAC7B,MAAM,CAAC,QAAQ,CAAa;IAE5B,GAAG,CAAW;IACd,MAAM,CAAU;IAEhB;QACE,IAAI,UAAU,CAAC,QAAQ;YAAE,OAAO,UAAU,CAAC,QAAQ,CAAC;QACpD,UAAU,CAAC,QAAQ,GAAG,IAAI,CAAC;QAE3B,IAAI,CAAC,GAAG,GAAG,OAAO,EAAE,CAAC;QAErB,0EAA0E;QAC1E,2EAA2E;QAC3E,uEAAuE;QACvE,
|
|
1
|
+
{"version":3,"file":"main.js","sourceRoot":"","sources":["../src/main.ts"],"names":[],"mappings":"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"}
|
package/dist/request.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"request.d.ts","sourceRoot":"","sources":["../src/request.ts"],"names":[],"mappings":"AAAA,OAAgB,EAAE,KAAK,OAAO,IAAI,cAAc,EAAE,KAAK,QAAQ,IAAI,eAAe,
|
|
1
|
+
{"version":3,"file":"request.d.ts","sourceRoot":"","sources":["../src/request.ts"],"names":[],"mappings":"AAAA,OAAgB,EAAE,KAAK,OAAO,IAAI,cAAc,EAAE,KAAK,QAAQ,IAAI,eAAe,EAAqB,KAAK,OAAO,EAAE,MAAM,SAAS,CAAC;AAOrI,MAAM,MAAM,YAAY,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;AACnD,MAAM,MAAM,cAAc,GAAG,CAAC,GAAG,EAAE,cAAc,EAAE,KAAK,EAAE,YAAY,KAAK,OAAO,GAAG,OAAO,CAAC,OAAO,CAAC,CAAC;AACtG,MAAM,MAAM,WAAW,GAAG,CAAC,GAAG,EAAE,cAAc,EAAE,KAAK,EAAE,YAAY,KAAK,MAAM,GAAG,SAAS,CAAC;AAC3F,MAAM,MAAM,aAAa,GAAG,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,cAAc,GAAG,cAAc,EAAE,CAAC,CAAC,CAAC;AAE9F,MAAM,CAAC,OAAO,OAAO,OAAO;IAC1B,MAAM,CAAC,SAAS,SAAmB;IAEnC,MAAM,CAAC,QAAQ,CAAC,GAAG,EAAE,cAAc,GAAG,YAAY;IASlD,MAAM,CAAC,kBAAkB,CAAC,GAAG,EAAE,eAAe,EAAE,MAAM,EAAE,MAAM,GAAG,IAAI;IAWrE,eAAe,EAAE,OAAO,CAAC;IACzB,QAAQ,EAAG,aAAa,CAAC;IACjB,IAAI,CAAC,EAAE,WAAW,CAAC;;IA4B3B,aAAa,IAAI,IAAI;CA4FtB"}
|
package/dist/request.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import express from 'express';
|
|
2
2
|
import config from 'stonyx/config';
|
|
3
3
|
import { makeArray } from '@stonyx/utils/object';
|
|
4
|
-
import applyRouteMatching from './route-matching.js';
|
|
4
|
+
import applyRouteMatching, { shouldRejectTarget } from './route-matching.js';
|
|
5
5
|
const METHODS = new Set(['get', 'post', 'put', 'delete', 'patch']);
|
|
6
6
|
export default class Request {
|
|
7
7
|
static stateProp = '__stonyxState';
|
|
@@ -28,10 +28,23 @@ export default class Request {
|
|
|
28
28
|
constructor() {
|
|
29
29
|
const api = express();
|
|
30
30
|
api.disable('x-powered-by');
|
|
31
|
-
//
|
|
32
|
-
//
|
|
33
|
-
//
|
|
34
|
-
//
|
|
31
|
+
// Applies BOTH route-matching SETTINGS: case sensitive routing
|
|
32
|
+
// (abofs/stonyx-rest-server#47) and strict routing (#50). For #47 this
|
|
33
|
+
// call closes sub-paths (/public/SUCCESS) and the parent's call closes the
|
|
34
|
+
// mount segment; for #50 THIS call closes the entire trailing-slash
|
|
35
|
+
// authorization bypass on its own, and the parent's call has no security
|
|
36
|
+
// role. Must stay in the constructor: registerCalls() materializes this
|
|
37
|
+
// router, and a set applied afterwards has no effect. The parent app's
|
|
38
|
+
// setting does not reach here -- see src/route-matching.ts.
|
|
39
|
+
//
|
|
40
|
+
// The third route-matching control, `canonicalRoutes` (#54), is NOT applied
|
|
41
|
+
// here and must not be moved here. It is not an express setting, and its
|
|
42
|
+
// timing contract is the opposite of these two: it is read PER REQUEST
|
|
43
|
+
// inside the handler closure in registerCalls() below, via
|
|
44
|
+
// shouldRejectTarget(). These two are constructor-timed because a late
|
|
45
|
+
// `set` is silently ineffective; that hazard does not exist for #54, and
|
|
46
|
+
// reading it per request is what lets the unit AC flip the flag between
|
|
47
|
+
// probes.
|
|
35
48
|
applyRouteMatching(api);
|
|
36
49
|
this.expressInstance = api;
|
|
37
50
|
}
|
|
@@ -44,7 +57,46 @@ export default class Request {
|
|
|
44
57
|
continue;
|
|
45
58
|
}
|
|
46
59
|
for (const [route, handler] of Object.entries(handlers)) {
|
|
47
|
-
expressInstance[method](route, async (req, res) => {
|
|
60
|
+
expressInstance[method](route, async (req, res, next) => {
|
|
61
|
+
// abofs/stonyx-rest-server#54 -- reject a request whose RAW target is
|
|
62
|
+
// not the canonical path express matched, closing two authorization
|
|
63
|
+
// bypasses against hooks that authorize on `req.originalUrl`: the
|
|
64
|
+
// mount-root trailing slash and the absolute-form request target.
|
|
65
|
+
//
|
|
66
|
+
// Three properties of this line are load-bearing. Each is named with
|
|
67
|
+
// the ONE assertion that turns red when it is removed -- assertion
|
|
68
|
+
// numbers in `test/integration/rest-server-test.ts` AC1, so the claim
|
|
69
|
+
// is checkable rather than a promise that coverage exists somewhere:
|
|
70
|
+
//
|
|
71
|
+
// 1. It runs OUTSIDE `if (this.auth)`. Gating it on the hook would
|
|
72
|
+
// leave `GET /public/` at 200 and make a security control depend
|
|
73
|
+
// on an unrelated consumer choice. Killed by AC1.6, which probes
|
|
74
|
+
// a route class with no hook.
|
|
75
|
+
// 2. It runs BEFORE that block, not merely outside it. This is a
|
|
76
|
+
// SEPARATE property from 1 and needs its own probe: AC1.6 is on
|
|
77
|
+
// a hookless class, so it stays green if this line is merely
|
|
78
|
+
// moved BELOW the block. Measured with it moved: the suite was
|
|
79
|
+
// 34 pass / 0 fail while `GET http://HOST/private/failure`
|
|
80
|
+
// answered 505 -- the consumer's hook status, which is the same
|
|
81
|
+
// oracle class as 3, and the hook itself ran on a request this
|
|
82
|
+
// module was about to reject. Killed by AC1.11.
|
|
83
|
+
// 3. It rejects with `next('router')`, NOT sendStatusResponse() or
|
|
84
|
+
// res.sendStatus(404). Measured: sendStatus returns
|
|
85
|
+
// `text/plain "Not Found"` while a genuine miss returns
|
|
86
|
+
// `text/html <pre>Cannot GET ...</pre>` -- a working ORACLE
|
|
87
|
+
// telling an attacker the route exists but was spelled wrong.
|
|
88
|
+
// next('router') exits this sub-app's router into finalhandler
|
|
89
|
+
// and is shape-identical to a real miss (same status, same
|
|
90
|
+
// Content-Type, same CSP header). Routing it through
|
|
91
|
+
// sendStatusResponse() would additionally re-introduce the
|
|
92
|
+
// oracle for any consumer who sets a 404 in `statusMap`.
|
|
93
|
+
// Killed by AC1.5.
|
|
94
|
+
//
|
|
95
|
+
// Properties 1 and 2 were previously stated here as a single item
|
|
96
|
+
// asserted to have "its own red-able assertion". It did not: only
|
|
97
|
+
// half of it was covered. Do not re-merge them.
|
|
98
|
+
if (shouldRejectTarget(req))
|
|
99
|
+
return next('router');
|
|
48
100
|
// Run auth after route matching so request.params is populated
|
|
49
101
|
if (this.auth) {
|
|
50
102
|
const status = this.auth(req, getState(req));
|
package/dist/request.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"request.js","sourceRoot":"","sources":["../src/request.ts"],"names":[],"mappings":"AAAA,OAAO,
|
|
1
|
+
{"version":3,"file":"request.js","sourceRoot":"","sources":["../src/request.ts"],"names":[],"mappings":"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"}
|
package/dist/route-matching.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { Express } from 'express';
|
|
1
|
+
import type { Express, Request as ExpressRequest } from 'express';
|
|
2
2
|
/**
|
|
3
3
|
* Applies this module's route-matching settings to an express app.
|
|
4
4
|
*
|
|
@@ -29,7 +29,117 @@ import type { Express } from 'express';
|
|
|
29
29
|
*
|
|
30
30
|
* Note `express({ caseSensitive: true })` does NOT work: express 5's
|
|
31
31
|
* `createApplication()` takes zero arguments and forwards nothing. The app
|
|
32
|
-
* setting is the only mechanism.
|
|
32
|
+
* setting is the only mechanism. The same is true of `strict`.
|
|
33
|
+
*
|
|
34
|
+
* ---
|
|
35
|
+
*
|
|
36
|
+
* `strict routing` (abofs/stonyx-rest-server#50) closes the same class of
|
|
37
|
+
* authorization bypass for a TRAILING SLASH: `GET /private/failure` was denied
|
|
38
|
+
* by a consumer's auth hook while `GET /private/failure/` reached the guarded
|
|
39
|
+
* handler, because the hook compares `req.path` against `/failure`.
|
|
40
|
+
*
|
|
41
|
+
* It is a SEPARATE key from `caseSensitiveRoutes`, not a rename and not a
|
|
42
|
+
* reuse. Coupling them would force any consumer who legitimately needs
|
|
43
|
+
* trailing-slash tolerance -- a load balancer probing `/health/`, a
|
|
44
|
+
* slash-normalizing proxy -- to re-open #47's case bypass to get it. Case
|
|
45
|
+
* insensitivity is almost never intentional; slash tolerance frequently is.
|
|
46
|
+
*
|
|
47
|
+
* The two settings do NOT share the #47 split above, and this is the one thing
|
|
48
|
+
* not to carry across from that fix. For `strict routing`:
|
|
49
|
+
*
|
|
50
|
+
* - The CHILD site (Request's constructor) closes the entire security
|
|
51
|
+
* defect on its own. The parent site does nothing for it.
|
|
52
|
+
* - The PARENT site (RestServer's constructor) closes exactly one thing in
|
|
53
|
+
* this repo: `/health/`, the only route registered directly on the parent
|
|
54
|
+
* app. It has no security role here; do not describe it as having one.
|
|
55
|
+
*
|
|
56
|
+
* The cause is concrete: `Router.prototype.use` hardcodes `strict: false` (and
|
|
57
|
+
* `end: false`), so mount segments are structurally strict-immune. That is the
|
|
58
|
+
* OPPOSITE of `sensitive`, which `use()` DOES forward, and which is why #47's
|
|
59
|
+
* parent site closed `/PUBLIC/...`. The version-pinned file-and-line citation
|
|
60
|
+
* for that upstream behaviour is deliberately kept in ONE place --
|
|
61
|
+
* `docs/project-structure.md`, section "Strict routing (#50)" -- so a router
|
|
62
|
+
* upgrade invalidates one line rather than five. Both sites still get both
|
|
63
|
+
* settings (they share this function), but the justification differs and the
|
|
64
|
+
* tests are built on the measured split, not on the analogy.
|
|
65
|
+
*
|
|
66
|
+
* Consequence worth stating so nobody expects this SETTING to cover it: no
|
|
67
|
+
* express setting rejects the mount-segment trailing slash (`/public/`). For
|
|
68
|
+
* both `/public` and `/public/` the mounted sub-app receives `req.path === '/'`,
|
|
69
|
+
* so a `req.path` auth hook sees no difference and there is nothing for the
|
|
70
|
+
* setting to reject. That statement is still true, and it is still the reason
|
|
71
|
+
* `applyRouteMatching()` is not the fix site for that edge.
|
|
72
|
+
*
|
|
73
|
+
* The edge itself is CLOSED, by `shouldRejectTarget()` below rather than by a
|
|
74
|
+
* setting (abofs/stonyx-rest-server#54). `req.originalUrl` does differ between
|
|
75
|
+
* the two spellings, and a hook authorizing on it was bypassed by one
|
|
76
|
+
* character -- measured: `GET /admin` -> 401, `GET /admin/` -> 200 with the
|
|
77
|
+
* guarded handler running unauthenticated. `GET /public/` now returns 404, and
|
|
78
|
+
* the integration AC asserts exactly that.
|
|
79
|
+
*
|
|
80
|
+
* Both guards are `!== false` for the same reason: these flags default to the
|
|
81
|
+
* truthy direction, so a truthy check fails OPEN for a consumer whose shipped
|
|
82
|
+
* config predates the key. Both are also asserted at the unit tier for BOTH
|
|
83
|
+
* failure shapes -- key present-and-`undefined` and key absent as an own
|
|
84
|
+
* property -- in `test/unit/request-test.ts` (#47's AC6, #50's AC3). The
|
|
85
|
+
* integration tier cannot see either: with the shipped default `true`, a
|
|
86
|
+
* fail-open guard leaves every integration assertion green.
|
|
33
87
|
*/
|
|
34
88
|
export default function applyRouteMatching(api: Express): void;
|
|
89
|
+
/**
|
|
90
|
+
* Decides whether a request must be rejected because its RAW request target is
|
|
91
|
+
* not the canonical path express matched (abofs/stonyx-rest-server#54).
|
|
92
|
+
*
|
|
93
|
+
* Closes two live authorization bypasses against a consumer hook that
|
|
94
|
+
* authorizes on `req.originalUrl` -- the field express does NOT normalize:
|
|
95
|
+
*
|
|
96
|
+
* 1. mount-root trailing slash GET /admin/ -> was 200
|
|
97
|
+
* 2. absolute-form request target GET http://host/admin -> was 200
|
|
98
|
+
* (RFC 9112 3.2.2; hits EVERY route, not just the mount root)
|
|
99
|
+
*
|
|
100
|
+
* Both were measured reaching the guarded handler unauthenticated while
|
|
101
|
+
* `GET /admin` was denied 401.
|
|
102
|
+
*
|
|
103
|
+
* COMPARE THE RAW TARGET; DO NOT PARSE IT. Any implementation reaching for
|
|
104
|
+
* `new URL(req.originalUrl, base).pathname` to "get the path" re-opens vector 2
|
|
105
|
+
* BY CONSTRUCTION: parsing normalizes the exact string the consumer's hook is
|
|
106
|
+
* exposed to, so the check would compare a laundered value while the hook still
|
|
107
|
+
* sees the raw one. Measured: the narrow `endsWith('/')` form closes 1 and
|
|
108
|
+
* leaves 2 at 200.
|
|
109
|
+
*
|
|
110
|
+
* `req.baseUrl + req.path` is likewise NOT usable as the left-hand side. It is
|
|
111
|
+
* `/admin/` for BOTH spellings of vector 1 -- `originalUrl` is the only field
|
|
112
|
+
* that differs, which is precisely why the bypass exists. A check built on
|
|
113
|
+
* `baseUrl + path` cannot see its own defect.
|
|
114
|
+
*
|
|
115
|
+
* TIMING CONTRACT -- DELIBERATELY DIFFERENT FROM ITS TWO SIBLINGS. This lives
|
|
116
|
+
* beside `applyRouteMatching()` for the same "one place anchors it" reason, but
|
|
117
|
+
* deliberately OUTSIDE it: that function's contract is *apply express settings
|
|
118
|
+
* to an app*, it is called from two constructors, and this is neither a setting
|
|
119
|
+
* nor constructor-timed. `caseSensitiveRoutes`/`strictRoutes` are read once in a
|
|
120
|
+
* constructor and are silently ineffective if applied late; this flag is read
|
|
121
|
+
* PER REQUEST, inside the handler closure. There is no lazy-materialisation
|
|
122
|
+
* hazard here, so do not carry that constraint across.
|
|
123
|
+
*
|
|
124
|
+
* The caller must reject with `next('router')`, NOT `res.sendStatus(404)`, and
|
|
125
|
+
* must run this BEFORE `this.auth` as well as outside `if (this.auth)` -- those
|
|
126
|
+
* are two separate properties with two separate assertions (AC1.11 and AC1.6);
|
|
127
|
+
* see src/request.ts.
|
|
128
|
+
*
|
|
129
|
+
* Guard polarity is `!== false`, matching both siblings, for the same measured
|
|
130
|
+
* reason: the secure value is the TRUTHY one, so a plain truthy check fails
|
|
131
|
+
* OPEN for any consumer whose shipped `restServer` block predates the key --
|
|
132
|
+
* the state every existing consumer is in, and reachable in practice because
|
|
133
|
+
* the stonyx loader only merges a module's `config/environment.js` for modules
|
|
134
|
+
* in devDependencies. `trustProxy` deliberately differs (`=== 'true'`): its safe
|
|
135
|
+
* default is FALSY, so a truthy check already fails closed for it. The rule is
|
|
136
|
+
* "the guard must fail toward the safe value", not "all guards look alike" --
|
|
137
|
+
* preserve the asymmetry.
|
|
138
|
+
*
|
|
139
|
+
* The integration tier cannot see a fail-open guard here: with the shipped
|
|
140
|
+
* default `true`, `=== true` leaves every integration assertion green. Only
|
|
141
|
+
* `test/unit/request-test.ts` AC2 can, and it probes BOTH failure shapes --
|
|
142
|
+
* key present-and-`undefined` and key absent as an own property.
|
|
143
|
+
*/
|
|
144
|
+
export declare function shouldRejectTarget(req: ExpressRequest): boolean;
|
|
35
145
|
//# sourceMappingURL=route-matching.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"route-matching.d.ts","sourceRoot":"","sources":["../src/route-matching.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,SAAS,CAAC;
|
|
1
|
+
{"version":3,"file":"route-matching.d.ts","sourceRoot":"","sources":["../src/route-matching.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,OAAO,EAAE,OAAO,IAAI,cAAc,EAAE,MAAM,SAAS,CAAC;AAGlE;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAqFG;AACH,MAAM,CAAC,OAAO,UAAU,kBAAkB,CAAC,GAAG,EAAE,OAAO,GAAG,IAAI,CAG7D;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAsDG;AACH,wBAAgB,kBAAkB,CAAC,GAAG,EAAE,cAAc,GAAG,OAAO,CAuB/D"}
|
package/dist/route-matching.js
CHANGED
|
@@ -29,10 +29,143 @@ import config from 'stonyx/config';
|
|
|
29
29
|
*
|
|
30
30
|
* Note `express({ caseSensitive: true })` does NOT work: express 5's
|
|
31
31
|
* `createApplication()` takes zero arguments and forwards nothing. The app
|
|
32
|
-
* setting is the only mechanism.
|
|
32
|
+
* setting is the only mechanism. The same is true of `strict`.
|
|
33
|
+
*
|
|
34
|
+
* ---
|
|
35
|
+
*
|
|
36
|
+
* `strict routing` (abofs/stonyx-rest-server#50) closes the same class of
|
|
37
|
+
* authorization bypass for a TRAILING SLASH: `GET /private/failure` was denied
|
|
38
|
+
* by a consumer's auth hook while `GET /private/failure/` reached the guarded
|
|
39
|
+
* handler, because the hook compares `req.path` against `/failure`.
|
|
40
|
+
*
|
|
41
|
+
* It is a SEPARATE key from `caseSensitiveRoutes`, not a rename and not a
|
|
42
|
+
* reuse. Coupling them would force any consumer who legitimately needs
|
|
43
|
+
* trailing-slash tolerance -- a load balancer probing `/health/`, a
|
|
44
|
+
* slash-normalizing proxy -- to re-open #47's case bypass to get it. Case
|
|
45
|
+
* insensitivity is almost never intentional; slash tolerance frequently is.
|
|
46
|
+
*
|
|
47
|
+
* The two settings do NOT share the #47 split above, and this is the one thing
|
|
48
|
+
* not to carry across from that fix. For `strict routing`:
|
|
49
|
+
*
|
|
50
|
+
* - The CHILD site (Request's constructor) closes the entire security
|
|
51
|
+
* defect on its own. The parent site does nothing for it.
|
|
52
|
+
* - The PARENT site (RestServer's constructor) closes exactly one thing in
|
|
53
|
+
* this repo: `/health/`, the only route registered directly on the parent
|
|
54
|
+
* app. It has no security role here; do not describe it as having one.
|
|
55
|
+
*
|
|
56
|
+
* The cause is concrete: `Router.prototype.use` hardcodes `strict: false` (and
|
|
57
|
+
* `end: false`), so mount segments are structurally strict-immune. That is the
|
|
58
|
+
* OPPOSITE of `sensitive`, which `use()` DOES forward, and which is why #47's
|
|
59
|
+
* parent site closed `/PUBLIC/...`. The version-pinned file-and-line citation
|
|
60
|
+
* for that upstream behaviour is deliberately kept in ONE place --
|
|
61
|
+
* `docs/project-structure.md`, section "Strict routing (#50)" -- so a router
|
|
62
|
+
* upgrade invalidates one line rather than five. Both sites still get both
|
|
63
|
+
* settings (they share this function), but the justification differs and the
|
|
64
|
+
* tests are built on the measured split, not on the analogy.
|
|
65
|
+
*
|
|
66
|
+
* Consequence worth stating so nobody expects this SETTING to cover it: no
|
|
67
|
+
* express setting rejects the mount-segment trailing slash (`/public/`). For
|
|
68
|
+
* both `/public` and `/public/` the mounted sub-app receives `req.path === '/'`,
|
|
69
|
+
* so a `req.path` auth hook sees no difference and there is nothing for the
|
|
70
|
+
* setting to reject. That statement is still true, and it is still the reason
|
|
71
|
+
* `applyRouteMatching()` is not the fix site for that edge.
|
|
72
|
+
*
|
|
73
|
+
* The edge itself is CLOSED, by `shouldRejectTarget()` below rather than by a
|
|
74
|
+
* setting (abofs/stonyx-rest-server#54). `req.originalUrl` does differ between
|
|
75
|
+
* the two spellings, and a hook authorizing on it was bypassed by one
|
|
76
|
+
* character -- measured: `GET /admin` -> 401, `GET /admin/` -> 200 with the
|
|
77
|
+
* guarded handler running unauthenticated. `GET /public/` now returns 404, and
|
|
78
|
+
* the integration AC asserts exactly that.
|
|
79
|
+
*
|
|
80
|
+
* Both guards are `!== false` for the same reason: these flags default to the
|
|
81
|
+
* truthy direction, so a truthy check fails OPEN for a consumer whose shipped
|
|
82
|
+
* config predates the key. Both are also asserted at the unit tier for BOTH
|
|
83
|
+
* failure shapes -- key present-and-`undefined` and key absent as an own
|
|
84
|
+
* property -- in `test/unit/request-test.ts` (#47's AC6, #50's AC3). The
|
|
85
|
+
* integration tier cannot see either: with the shipped default `true`, a
|
|
86
|
+
* fail-open guard leaves every integration assertion green.
|
|
33
87
|
*/
|
|
34
88
|
export default function applyRouteMatching(api) {
|
|
35
89
|
if (config.restServer?.caseSensitiveRoutes !== false)
|
|
36
90
|
api.set('case sensitive routing', true);
|
|
91
|
+
if (config.restServer?.strictRoutes !== false)
|
|
92
|
+
api.set('strict routing', true);
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* Decides whether a request must be rejected because its RAW request target is
|
|
96
|
+
* not the canonical path express matched (abofs/stonyx-rest-server#54).
|
|
97
|
+
*
|
|
98
|
+
* Closes two live authorization bypasses against a consumer hook that
|
|
99
|
+
* authorizes on `req.originalUrl` -- the field express does NOT normalize:
|
|
100
|
+
*
|
|
101
|
+
* 1. mount-root trailing slash GET /admin/ -> was 200
|
|
102
|
+
* 2. absolute-form request target GET http://host/admin -> was 200
|
|
103
|
+
* (RFC 9112 3.2.2; hits EVERY route, not just the mount root)
|
|
104
|
+
*
|
|
105
|
+
* Both were measured reaching the guarded handler unauthenticated while
|
|
106
|
+
* `GET /admin` was denied 401.
|
|
107
|
+
*
|
|
108
|
+
* COMPARE THE RAW TARGET; DO NOT PARSE IT. Any implementation reaching for
|
|
109
|
+
* `new URL(req.originalUrl, base).pathname` to "get the path" re-opens vector 2
|
|
110
|
+
* BY CONSTRUCTION: parsing normalizes the exact string the consumer's hook is
|
|
111
|
+
* exposed to, so the check would compare a laundered value while the hook still
|
|
112
|
+
* sees the raw one. Measured: the narrow `endsWith('/')` form closes 1 and
|
|
113
|
+
* leaves 2 at 200.
|
|
114
|
+
*
|
|
115
|
+
* `req.baseUrl + req.path` is likewise NOT usable as the left-hand side. It is
|
|
116
|
+
* `/admin/` for BOTH spellings of vector 1 -- `originalUrl` is the only field
|
|
117
|
+
* that differs, which is precisely why the bypass exists. A check built on
|
|
118
|
+
* `baseUrl + path` cannot see its own defect.
|
|
119
|
+
*
|
|
120
|
+
* TIMING CONTRACT -- DELIBERATELY DIFFERENT FROM ITS TWO SIBLINGS. This lives
|
|
121
|
+
* beside `applyRouteMatching()` for the same "one place anchors it" reason, but
|
|
122
|
+
* deliberately OUTSIDE it: that function's contract is *apply express settings
|
|
123
|
+
* to an app*, it is called from two constructors, and this is neither a setting
|
|
124
|
+
* nor constructor-timed. `caseSensitiveRoutes`/`strictRoutes` are read once in a
|
|
125
|
+
* constructor and are silently ineffective if applied late; this flag is read
|
|
126
|
+
* PER REQUEST, inside the handler closure. There is no lazy-materialisation
|
|
127
|
+
* hazard here, so do not carry that constraint across.
|
|
128
|
+
*
|
|
129
|
+
* The caller must reject with `next('router')`, NOT `res.sendStatus(404)`, and
|
|
130
|
+
* must run this BEFORE `this.auth` as well as outside `if (this.auth)` -- those
|
|
131
|
+
* are two separate properties with two separate assertions (AC1.11 and AC1.6);
|
|
132
|
+
* see src/request.ts.
|
|
133
|
+
*
|
|
134
|
+
* Guard polarity is `!== false`, matching both siblings, for the same measured
|
|
135
|
+
* reason: the secure value is the TRUTHY one, so a plain truthy check fails
|
|
136
|
+
* OPEN for any consumer whose shipped `restServer` block predates the key --
|
|
137
|
+
* the state every existing consumer is in, and reachable in practice because
|
|
138
|
+
* the stonyx loader only merges a module's `config/environment.js` for modules
|
|
139
|
+
* in devDependencies. `trustProxy` deliberately differs (`=== 'true'`): its safe
|
|
140
|
+
* default is FALSY, so a truthy check already fails closed for it. The rule is
|
|
141
|
+
* "the guard must fail toward the safe value", not "all guards look alike" --
|
|
142
|
+
* preserve the asymmetry.
|
|
143
|
+
*
|
|
144
|
+
* The integration tier cannot see a fail-open guard here: with the shipped
|
|
145
|
+
* default `true`, `=== true` leaves every integration assertion green. Only
|
|
146
|
+
* `test/unit/request-test.ts` AC2 can, and it probes BOTH failure shapes --
|
|
147
|
+
* key present-and-`undefined` and key absent as an own property.
|
|
148
|
+
*/
|
|
149
|
+
export function shouldRejectTarget(req) {
|
|
150
|
+
// Written as `!== false` rather than `=== false` on purpose: the polarity is
|
|
151
|
+
// the load-bearing part and it should read identically to the two guards in
|
|
152
|
+
// applyRouteMatching() above.
|
|
153
|
+
const enforced = config.restServer?.canonicalRoutes !== false;
|
|
154
|
+
if (!enforced)
|
|
155
|
+
return false;
|
|
156
|
+
// Raw, unparsed. Only the query string is removed, by string split.
|
|
157
|
+
const target = req.originalUrl.split('?')[0];
|
|
158
|
+
// At a mount root express reports `req.path === '/'` while the canonical
|
|
159
|
+
// target is the bare mount segment, so the two are not simply concatenated.
|
|
160
|
+
//
|
|
161
|
+
// `&& req.baseUrl` is load-bearing and is NOT a redundant truthiness guard.
|
|
162
|
+
// A route class named `index` mounts at '/' (src/main.ts `mountRoute()`),
|
|
163
|
+
// and that is the one mount shape where `req.baseUrl` is ''. Without the
|
|
164
|
+
// conjunct, `GET /` compares the raw target '/' against a canonical of '' and
|
|
165
|
+
// the APPLICATION ROOT is rejected. Measured before it had a guard: shipped
|
|
166
|
+
// `GET /` -> 200, conjunct dropped -> 404, suite 34 pass / 0 fail BOTH ways.
|
|
167
|
+
// Killed now by AC1.12, against `test/sample/requests/index.ts`.
|
|
168
|
+
const canonical = req.path === '/' && req.baseUrl ? req.baseUrl : req.baseUrl + req.path;
|
|
169
|
+
return target !== canonical;
|
|
37
170
|
}
|
|
38
171
|
//# sourceMappingURL=route-matching.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"route-matching.js","sourceRoot":"","sources":["../src/route-matching.ts"],"names":[],"mappings":"AACA,OAAO,MAAM,MAAM,eAAe,CAAC;AAEnC
|
|
1
|
+
{"version":3,"file":"route-matching.js","sourceRoot":"","sources":["../src/route-matching.ts"],"names":[],"mappings":"AACA,OAAO,MAAM,MAAM,eAAe,CAAC;AAEnC;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAqFG;AACH,MAAM,CAAC,OAAO,UAAU,kBAAkB,CAAC,GAAY;IACrD,IAAI,MAAM,CAAC,UAAU,EAAE,mBAAmB,KAAK,KAAK;QAAE,GAAG,CAAC,GAAG,CAAC,wBAAwB,EAAE,IAAI,CAAC,CAAC;IAC9F,IAAI,MAAM,CAAC,UAAU,EAAE,YAAY,KAAK,KAAK;QAAE,GAAG,CAAC,GAAG,CAAC,gBAAgB,EAAE,IAAI,CAAC,CAAC;AACjF,CAAC;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAsDG;AACH,MAAM,UAAU,kBAAkB,CAAC,GAAmB;IACpD,6EAA6E;IAC7E,4EAA4E;IAC5E,8BAA8B;IAC9B,MAAM,QAAQ,GAAG,MAAM,CAAC,UAAU,EAAE,eAAe,KAAK,KAAK,CAAC;IAC9D,IAAI,CAAC,QAAQ;QAAE,OAAO,KAAK,CAAC;IAE5B,oEAAoE;IACpE,MAAM,MAAM,GAAG,GAAG,CAAC,WAAW,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,CAAC;IAE7C,yEAAyE;IACzE,4EAA4E;IAC5E,EAAE;IACF,4EAA4E;IAC5E,0EAA0E;IAC1E,yEAAyE;IACzE,8EAA8E;IAC9E,4EAA4E;IAC5E,6EAA6E;IAC7E,iEAAiE;IACjE,MAAM,SAAS,GAAG,GAAG,CAAC,IAAI,KAAK,GAAG,IAAI,GAAG,CAAC,OAAO,CAAC,CAAC,CAAC,GAAG,CAAC,OAAO,CAAC,CAAC,CAAC,GAAG,CAAC,OAAO,GAAG,GAAG,CAAC,IAAI,CAAC;IAEzF,OAAO,MAAM,KAAK,SAAS,CAAC;AAC9B,CAAC"}
|