@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 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 [Case-Sensitive Routing](#case-sensitive-routing) — **disabling this re-opens a security hole**. |
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
- ### Case-Sensitive Routing
88
+ ### Route Matching Strictness
87
89
 
88
- Routes match **case-sensitively by default**. `GET /users` reaches a route
89
- mounted at `/users`; `GET /Users` does not reach that mount, and
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
- Read [What this does not do](#what-this-does-not-do) before you rely on that
93
- sentence. Two things it does not say: "does not reach the handler" is not the
94
- same as "404", and casing is only one of the two ways express matches more
95
- loosely than the authorization predicates written against it.
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
- This is deliberate and security-relevant. Express matches case-insensitively by
98
- default, which means any authorization written against the request URL can be
99
- walked past by changing the case of the request:
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 -> 404 (correctly filtered)
103
- GET /OwNeRs/angela -> 200 (full record)
104
- DELETE /ANIMALS/22 -> 204 (record destroyed)
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. Case-sensitive matching closes the **casing** half of that asymmetry:
110
- the path a handler sees can only ever be the exact registered casing.
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
- It does not close the asymmetry itself. Express exposes `case sensitive
113
- routing` and `strict routing` as a pair of loose-by-default router settings and
114
- this change sets only the first, so the identical bypass is still reachable by
115
- appending a slash. Measured on this release against this repo's own fixture:
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 /private/failure -> 505 (auth hook fires, request blocked)
119
- GET /private/failure/ -> 200 (auth hook never fires, handler runs)
144
+ GET /public -> req.path '/' req.originalUrl '/public'
145
+ GET /public/ -> req.path '/' req.originalUrl '/public/'
120
146
  ```
121
147
 
122
- That is the same defect, one character instead of a case shift — translated to
123
- the example above, `DELETE /animals/22` is filtered and `DELETE /animals/22/`
124
- destroys the record. It is tracked as
125
- [#50](https://github.com/abofs/stonyx-rest-server/issues/50) and is not fixed
126
- here; it is a second consumer-visible behaviour change that needs its own flag
127
- and its own release note.
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
- **So do not drop a URL-normalizing defence you already have on the strength of
130
- this section.** If your authorization compares `req.path` or `req.originalUrl`,
131
- keep whatever normalization you have until #50 ships.
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
- **It does not cover trailing slashes.** See
152
- [#50](https://github.com/abofs/stonyx-rest-server/issues/50) above.
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
- **It does not redirect or rewrite** mixed-case requests to their canonical
155
- casing. Whether `/Users` is a typo to forgive or an attack to reject is an
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
- **This restores the vulnerability described above** — any URL-based
166
- authorization in your application becomes bypassable by changing case. It
167
- exists as a one-line remediation for an existing deployment, not as a
168
- configuration to run on.
169
-
170
- You need it if clients call your endpoints with casing that does not match the
171
- mount path. Mount paths come from filenames, so this is not hypothetical:
172
-
173
- - with `camelCaseRoutes` truthy, `phone-number.ts` mounts at `/phoneNumber`, and
174
- `GET /phonenumber` now returns 404
175
- - with `camelCaseRoutes` falsy, filenames are used verbatim, so
176
- `Users.ts` mounts at `/Users` and `GET /users` now returns 404
177
-
178
- A request that stops matching returns express's default `404 Cannot GET /x` with
179
- no log line and no stack, so it looks like a deploy that dropped a route. Set
180
- the flag to restore service, then fix the client's casing and remove the flag.
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
 
@@ -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 28 pass / 0 fail. A naive pin makes
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` => 25 pass / 3 fail), but it
30
- // fails LOUDLY, so there is no false green. Closing #43 for this key needs
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',
@@ -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;;IAgBhB,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"}
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
- // Closes the mount segment (/PUBLIC/...) for abofs/stonyx-rest-server#47.
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. The matching call in Request's constructor is what closes
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":"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"}
1
+ {"version":3,"file":"main.js","sourceRoot":"","sources":["../src/main.ts"],"names":[],"mappings":"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"}
@@ -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,EAAE,KAAK,OAAO,EAAE,MAAM,SAAS,CAAC;AAOlH,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;;IAe3B,aAAa,IAAI,IAAI;CAqDtB"}
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
- // Closes sub-paths (/public/SUCCESS) for abofs/stonyx-rest-server#47.
32
- // Must stay in the constructor: registerCalls() materializes this router,
33
- // and a set applied afterwards has no effect. The parent app's setting
34
- // does not reach here -- see src/route-matching.ts.
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));
@@ -1 +1 @@
1
- {"version":3,"file":"request.js","sourceRoot":"","sources":["../src/request.ts"],"names":[],"mappings":"AAAA,OAAO,OAA2F,MAAM,SAAS,CAAC;AAClH,OAAO,MAAM,MAAM,eAAe,CAAC;AACnC,OAAO,EAAE,SAAS,EAAE,MAAM,sBAAsB,CAAC;AACjD,OAAO,kBAAkB,MAAM,qBAAqB,CAAC;AAErD,MAAM,OAAO,GAAG,IAAI,GAAG,CAAC,CAAC,KAAK,EAAE,MAAM,EAAE,KAAK,EAAE,QAAQ,EAAE,OAAO,CAAC,CAAC,CAAC;AAOnE,MAAM,CAAC,OAAO,OAAO,OAAO;IAC1B,MAAM,CAAC,SAAS,GAAG,eAAe,CAAC;IAEnC,MAAM,CAAC,QAAQ,CAAC,GAAmB;QACjC,MAAM,EAAE,SAAS,EAAE,GAAG,OAAO,CAAC;QAC9B,MAAM,MAAM,GAAG,GAAyC,CAAC;QACzD,IAAI,MAAM,CAAC,SAAS,CAAC,KAAK,SAAS;YAAE,OAAO,MAAM,CAAC,SAAS,CAAiB,CAAC;QAE9E,MAAM,CAAC,SAAS,CAAC,GAAG,EAAE,CAAC;QACvB,OAAO,MAAM,CAAC,SAAS,CAAiB,CAAC;IAC3C,CAAC;IAED,MAAM,CAAC,kBAAkB,CAAC,GAAoB,EAAE,MAAc;QAC5D,MAAM,SAAS,GAAG,MAAM,CAAC,UAAU,EAAE,SAAS,IAAI,EAAE,CAAC;QACrD,MAAM,OAAO,GAAG,SAAS,CAAC,MAAM,CAAC,IAAI,EAAE,CAAC;QAExC,IAAI,OAAO,EAAE,CAAC;YACZ,GAAG,CAAC,MAAM,CAAC,MAAM,CAAC,CAAC,IAAI,CAAC,OAAO,CAAC,CAAC;QACnC,CAAC;aAAM,CAAC;YACN,GAAG,CAAC,UAAU,CAAC,MAAM,CAAC,CAAC;QACzB,CAAC;IACH,CAAC;IAED,eAAe,CAAU;IACzB,QAAQ,CAAiB;IAGzB;QACE,MAAM,GAAG,GAAG,OAAO,EAAE,CAAC;QACtB,GAAG,CAAC,OAAO,CAAC,cAAc,CAAC,CAAC;QAE5B,sEAAsE;QACtE,0EAA0E;QAC1E,uEAAuE;QACvE,oDAAoD;QACpD,kBAAkB,CAAC,GAAG,CAAC,CAAC;QAExB,IAAI,CAAC,eAAe,GAAG,GAAG,CAAC;IAC7B,CAAC;IAED,aAAa;QACX,MAAM,EAAE,eAAe,EAAE,GAAG,IAAI,CAAC;QACjC,MAAM,EAAE,QAAQ,EAAE,kBAAkB,EAAE,GAAG,OAAO,CAAC;QAEjD,KAAK,MAAM,CAAC,MAAM,EAAE,QAAQ,CAAC,IAAI,MAAM,CAAC,OAAO,CAAC,IAAI,CAAC,QAAQ,CAAC,EAAE,CAAC;YAC/D,IAAI,CAAC,OAAO,CAAC,GAAG,CAAC,MAAM,CAAC,EAAE,CAAC;gBACzB,OAAO,CAAC,IAAI,CAAC,WAAW,MAAM,2CAA2C,CAAC,CAAC;gBAC3E,SAAS;YACX,CAAC;YAED,KAAK,MAAM,CAAC,KAAK,EAAE,OAAO,CAAC,IAAI,MAAM,CAAC,OAAO,CAAC,QAAQ,CAAC,EAAE,CAAC;gBACvD,eAA6I,CAAC,MAAM,CAAC,CAAC,KAAK,EAAE,KAAK,EAAE,GAAmB,EAAE,GAAoB,EAAE,EAAE;oBAChN,+DAA+D;oBAC/D,IAAI,IAAI,CAAC,IAAI,EAAE,CAAC;wBACd,MAAM,MAAM,GAAG,IAAI,CAAC,IAAI,CAAC,GAAG,EAAE,QAAQ,CAAC,GAAG,CAAC,CAAC,CAAC;wBAC7C,IAAI,MAAM;4BAAE,OAAO,kBAAkB,CAAC,GAAG,EAAE,MAAM,CAAC,CAAC;oBACrD,CAAC;oBAED,MAAM,SAAS,GAAG,CAAC,GAAG,SAAS,CAAC,OAAO,CAAC,CAAqB,CAAC;oBAC9D,MAAM,QAAQ,GAAG,SAAS,CAAC,GAAG,EAAG,CAAC;oBAClC,IAAI,QAAiB,CAAC;oBAEtB,iBAAiB;oBACjB,OAAM,SAAS,CAAC,MAAM,EAAE,CAAC;wBACvB,QAAQ,GAAG,MAAM,SAAS,CAAC,KAAK,EAAG,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC,GAAG,EAAE,QAAQ,CAAC,GAAG,CAAC,CAAC,CAAC;wBACnE,IAAI,QAAQ,KAAK,SAAS;4BAAE,MAAM;oBACpC,CAAC;oBAED,IAAI,QAAQ,KAAK,SAAS;wBAAE,QAAQ,GAAG,MAAM,QAAQ,CAAC,GAAG,EAAE,QAAQ,CAAC,GAAG,CAAC,CAAC,CAAC;oBAC1E,IAAI,MAAM,CAAC,SAAS,CAAC,QAAQ,CAAC;wBAAE,OAAO,kBAAkB,CAAC,GAAG,EAAE,QAAkB,CAAC,CAAC;oBAEnF,+CAA+C;oBAC/C,MAAM,KAAK,GAAG,QAAQ,CAAC,GAAG,CAAC,CAAC;oBAC5B,MAAM,EAAE,QAAQ,EAAE,GAAG,KAAK,CAAC;oBAC3B,IAAI,QAAQ;wBAAE,OAAO,GAAG,CAAC,QAAQ,CAAC,QAAkB,CAAC,CAAC;oBAEtD,2CAA2C;oBAC3C,MAAM,EAAE,IAAI,EAAE,GAAG,KAAK,CAAC;oBACvB,IAAI,IAAI,EAAE,CAAC;wBACT,MAAM,EAAE,OAAO,EAAE,MAAM,EAAE,GAAG,IAA0F,CAAC;wBAEvH,IAAI,OAAO;4BAAE,KAAK,MAAM,CAAC,GAAG,EAAE,KAAK,CAAC,IAAI,MAAM,CAAC,OAAO,CAAC,OAAO,CAAC;gCAAE,GAAG,CAAC,GAAG,CAAC,GAAG,EAAE,KAAK,CAAC,CAAC;wBACrF,OAAO,MAAM,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC;oBAC1B,CAAC;oBAED,IAAI,QAAQ,KAAK,SAAS,EAAE,CAAC;wBAAC,GAAG,CAAC,UAAU,CAAC,GAAG,CAAC,CAAC;wBAAC,OAAO;oBAAC,CAAC;oBAC5D,IAAI,OAAO,QAAQ,KAAK,QAAQ;wBAAE,OAAO,kBAAkB,CAAC,GAAG,EAAE,GAAG,CAAC,CAAC;oBAEtE,GAAG,CAAC,IAAI,CAAC,QAAmC,CAAC,CAAC;gBAChD,CAAC,CAAC,CAAC;YACL,CAAC;QACH,CAAC;IACH,CAAC"}
1
+ {"version":3,"file":"request.js","sourceRoot":"","sources":["../src/request.ts"],"names":[],"mappings":"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"}
@@ -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;AAGvC;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA+BG;AACH,MAAM,CAAC,OAAO,UAAU,kBAAkB,CAAC,GAAG,EAAE,OAAO,GAAG,IAAI,CAE7D"}
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"}
@@ -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;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA+BG;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;AAChG,CAAC"}
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"}
package/package.json CHANGED
@@ -4,7 +4,7 @@
4
4
  "stonyx-async",
5
5
  "stonyx-module"
6
6
  ],
7
- "version": "0.2.1-beta.90",
7
+ "version": "0.2.1-beta.92",
8
8
  "description": "Rest Server Module for Stonyx Framework",
9
9
  "repository": {
10
10
  "type": "git",