@stonyx/rest-server 0.2.1-beta.91 → 0.2.1-beta.93

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
@@ -81,23 +81,40 @@ Configuration is read from `stonyx/config` under `restServer`:
81
81
  | `enableHealthCheck` | **Boolean** | `true` | Register `GET /health` endpoint (disable via `REST_HEALTH_CHECK_DISABLE=true`) |
82
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
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)). |
85
+ | `canonicalEncoding` | **Boolean** | `true` | Reject a request whose raw target percent-encodes an RFC 3986 §2.3 *unreserved* character (`A-Z a-z 0-9 - . _ ~`), before your `auth` hook runs. Disable via `REST_CANONICAL_ENCODING=false`. See [Route Matching Strictness](#route-matching-strictness) — **disabling this re-opens a security hole**, and note that a client over-encoding an unreserved character in a path now gets 404 (see [Upgrading](#upgrading-behaviour-changes)). Like `canonicalRoutes` this is a **registration-site** control, not a global one: the check runs in the handlers mounted from your request classes, so a route registered directly on `RestServer.instance.api` gets none of it — measured, `GET /direct/%73ecret` → **200** with `id "secret"` while `GET /enc/%73ecret` → 404. |
84
86
  | `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. |
85
87
  | `statusMap` | **Object** | `{}` | Optional mapping of HTTP status codes to custom messages |
86
88
 
87
89
  ### Route Matching Strictness
88
90
 
89
- Routes match **case-sensitively and strictly by default**. Two settings, both
90
- on, both applied at both express construction sites:
91
+ Routes match **case-sensitively, strictly, and only at their canonical target**
92
+ by default, and a raw target that percent-encodes an RFC 3986 §2.3 *unreserved*
93
+ character is rejected. Four controls, all on.
91
94
 
92
- | axis | setting | example that no longer matches |
95
+ Note what that does **not** say. It is not "one accepted spelling per id", and
96
+ this module cannot give you that: reserved characters, non-ASCII bytes and
97
+ control octets all stay encodable, and every one of them whose hex carries a
98
+ letter digit aliases by hex-digit case. The residual is stated and measured
99
+ below, under [the residual](#the-residual-stated-plainly).
100
+
101
+ | axis | control | example that no longer matches |
93
102
  |---|---|---|
94
- | casing | `case sensitive routing` | `GET /users/Success` -> does not reach `/success` |
95
- | trailing slash | `strict routing` | `GET /users/success/` -> does not reach `/success` |
103
+ | casing | `case sensitive routing` (setting) | `GET /users/Success` -> does not reach `/success` |
104
+ | trailing slash | `strict routing` (setting) | `GET /users/success/` -> does not reach `/success` |
105
+ | canonical target | `canonicalRoutes` (per-request check) | `GET /users/` and `GET http://host/users` -> do not reach the mounted `/users` class |
106
+ | percent-encoding | `canonicalEncoding` (per-request check) | `GET /users/%73ecret` -> does not reach `/users/:id` with `id === "secret"` |
107
+
108
+ The first two are express settings applied at both construction sites. The last
109
+ two are **not settings** — no express setting can express either — they are
110
+ per-request checks run ahead of your `auth` hook: one compares the raw request
111
+ target against the path express matched, the other rejects a raw target that
112
+ percent-encodes a character which never needs encoding. See
113
+ [`src/route-matching.ts`](src/route-matching.ts).
96
114
 
97
115
  Read [What this does not do](#what-this-does-not-do) and
98
- [Upgrading](#upgrading-behaviour-changes) before you rely on that. Two things
99
- the table does not say: "does not reach the handler" is not the same as "404",
100
- and one edge of the trailing-slash axis is **not** closed and cannot be.
116
+ [Upgrading](#upgrading-behaviour-changes) before you rely on that. One thing the
117
+ table does not say: "does not reach the handler" is not the same as "404".
101
118
 
102
119
  This is deliberate and security-relevant. Express matches both case-insensitively
103
120
  and slash-insensitively by default, which means any authorization written
@@ -128,11 +145,10 @@ be the exact registered spelling, in the exact registered casing, with no
128
145
  trailing slash. That closes [#47](https://github.com/abofs/stonyx-rest-server/issues/47)
129
146
  and [#50](https://github.com/abofs/stonyx-rest-server/issues/50).
130
147
 
131
- #### What this does not do
148
+ #### The canonical-target check (`canonicalRoutes`)
132
149
 
133
- **It does not close the trailing slash on a mount root, and no setting can.**
134
- This is the one edge that remains open, so do not read the section above as
135
- closing the class outright:
150
+ **No express *setting* closes the trailing slash on a mount root**, and that has
151
+ not changed:
136
152
 
137
153
  ```
138
154
  GET /public -> req.path '/' req.originalUrl '/public'
@@ -144,16 +160,143 @@ unconditionally (`router@2.2.0`; the file-and-line citation is in
144
160
  [`docs/project-structure.md`](docs/project-structure.md) § *Strict routing
145
161
  (#50)*), so both forms reach the mounted route class and both arrive with
146
162
  `req.path === '/'`. A hook authorizing on `req.path` cannot tell them apart, so
147
- for that hook there is no asymmetry to exploit. **A hook comparing
148
- `req.originalUrl` still sees two different strings, and `strictRoutes` does not
149
- change that.** If your authorization compares `req.originalUrl` rather than
150
- `req.path`, keep whatever URL normalization you have.
163
+ for that hook there is no asymmetry to exploit and there is nothing for
164
+ `strict routing` to reject. **A hook comparing `req.originalUrl` sees two
165
+ different strings**, and that was a live authorization bypass.
166
+
167
+ `canonicalRoutes` closes it, as a per-request check rather than a setting
168
+ ([#54](https://github.com/abofs/stonyx-rest-server/issues/54)). Before your
169
+ `auth` hook runs, the raw request target is compared against the path express
170
+ matched, and a mismatch is rejected as a plain 404. It closes **two** vectors
171
+ against `req.originalUrl` — the field express does not normalize:
172
+
173
+ ```
174
+ before after
175
+ GET /admin 401 401 (hook fires, request blocked)
176
+ GET /admin/ 200 404 (hook never fired; now a miss)
177
+ GET http://host/admin 200 404 (absolute-form; hook never fired)
178
+ GET http://host/admin/settings 200 404 (absolute-form; every route from a request class)
179
+ ```
180
+
181
+ The second vector is the one to check first. [RFC 9112
182
+ §3.2.2](https://www.rfc-editor.org/rfc/rfc9112#section-3.2.2) permits an
183
+ **absolute-form** request target, express routes it, and it hands your hook the
184
+ whole URI — `req.originalUrl === "http://host/admin"`. Unlike the mount-root
185
+ slash, that affects **every route mounted from a request class**, not one edge.
186
+ The qualifier is a registration-site limit, not a special case for one URL: the
187
+ check runs inside the handlers this module registers, so anything you register
188
+ directly on `RestServer.instance.api` is outside it. In this repo that is
189
+ `/health` alone, and `GET http://host/health` still returns 200 — measured.
190
+
191
+ The target is compared **raw**. It is not parsed, normalized or resolved first:
192
+ normalizing it would launder exactly the string your hook is exposed to and
193
+ re-open the absolute-form vector by construction. Only the query string is
194
+ removed before comparison, so `GET /admin?x=1` still reaches your hook — **strip
195
+ the query yourself** if your hook compares `req.originalUrl` against a fixed
196
+ path, or it will not match (see [Consumer
197
+ Contracts](#consumer-contracts)). Rejections are indistinguishable from a
198
+ genuine miss by design; see [Upgrading](#upgrading-behaviour-changes).
199
+
200
+ Routes registered *with* a literal trailing slash are unaffected — their
201
+ canonical target carries the slash. So are index-mounted route classes and
202
+ query strings on canonical paths.
203
+
204
+ **Param routes: "unaffected" means "no regression".** `/resource/:id` keeps
205
+ matching exactly as it did. `canonicalRoutes` is structurally blind to how a
206
+ param value is *spelled* — express decodes only `req.params`, so `target` and
207
+ `canonical` are both the same encoded string and this comparison passes the
208
+ request through by construction. That axis has its own control, below.
209
+
210
+ #### The percent-encoding check (`canonicalEncoding`)
211
+
212
+ Express decodes **`req.params` and nothing else**. `req.path` and
213
+ `req.originalUrl` both stay percent-encoded, so an `auth` hook comparing either
214
+ of them against a fixed string was walked past by re-spelling the id:
215
+
216
+ ```
217
+ before after
218
+ GET /enc/secret 401 401 (hook fires, request blocked)
219
+ GET /enc/%73ecret 200 404 (hook never fired; handler got id "secret")
220
+ GET /enc/%73%65%63%72%65%74 200 404
221
+ GET /private/%66ailure 200 404 (guard missed; absorbed by a sibling /:id)
222
+ ```
223
+
224
+ `canonicalEncoding` closes it
225
+ ([#56](https://github.com/abofs/stonyx-rest-server/issues/56)). Before your
226
+ `auth` hook runs, the query-stripped raw target is rejected as a plain 404 if it
227
+ contains a percent-triplet whose octet is an
228
+ [RFC 3986 §2.3](https://www.rfc-editor.org/rfc/rfc3986#section-2.3)
229
+ **unreserved** character — `A-Z`, `a-z`, `0-9`, `-`, `.`, `_`, `~`. Those are
230
+ exactly the characters a URI generator must **not** encode and a normalizer
231
+ **must** decode, so nothing a client is required to send is affected.
232
+
233
+ **This is not a list of spellings, it is a family.** For an id of *n* bytes
234
+ there are `∏(1 + vᵢ) − 1` non-canonical spellings, where a byte whose hex
235
+ carries a letter digit has two (`m`, `%6d`, `%6D`). Measured against unfixed
236
+ code: **63 of 63** spellings of `secret` returned 200, and **71 of 71** of
237
+ `admin`. Enumerating them in your own hook is not a remedy.
238
+
239
+ **Three things it deliberately does not reject:**
151
240
 
152
- No *setting* closes this, but the module can:
153
- [#54](https://github.com/abofs/stonyx-rest-server/issues/54) tracks closing it
154
- with a canonical-path check ahead of the `auth` hook. Until that ships, an
155
- `originalUrl` hook is bypassed by one character — `GET /admin` denied,
156
- `GET /admin/` reaching the route class's index handler.
241
+ ```
242
+ GET /enc/sec%2fret -> 200 id "sec/ret" %2f is RESERVED — must stay encodable
243
+ GET /enc/a%2Bb -> 200 id "a+b" %2B is RESERVED
244
+ GET /enc/%2573ecret -> 200 id "%73ecret" express decodes exactly ONCE
245
+ GET /enc/x?name=%61 -> 200 id "x" the query string is stripped, not scanned
246
+ GET /enc/%zz -> 400 malformed escapes are the router's 400, unchanged
247
+ ```
248
+
249
+ If you were tempted to write `decodeURIComponent(req.path)` in your hook: the
250
+ first line is why not. The router **splits then decodes**, so `sec%2fret` is one
251
+ segment naming the id `sec/ret`; a hook that decodes then splits sees two
252
+ segments and denies a request the router routed somewhere else entirely. And a
253
+ hook that decodes *until stable* denies line three, which is a legitimately
254
+ distinct id.
255
+
256
+ #### The residual, stated plainly
257
+
258
+ **This does not give each id one spelling.** The rule rejects an over-encoded
259
+ *unreserved* octet, which means every octet **outside** `A-Za-z0-9-._~` stays
260
+ encodable — and any such octet whose hex carries a letter digit therefore has
261
+ two accepted spellings, upper- and lower-case hex. **That is every reserved
262
+ character, every non-ASCII byte, and every control octet whose hex carries a
263
+ letter digit — not only the reserved ones.** It is not the whole complement of
264
+ `A-Za-z0-9-._~`: `%21` and `%40` carry no letter hex digit and alias
265
+ literal-versus-encoded instead, `%00` and `%09` keep exactly one accepted
266
+ spelling and do not alias at all, and `%90` is a 400. Two different raw targets
267
+ can still name the same record:
268
+
269
+ ```
270
+ GET /enc/a+b -> 200 id "a+b"
271
+ GET /enc/a%2Bb -> 200 id "a+b" <- two accepted spellings, one id
272
+ GET /enc/sec%2fret -> 200 id "sec/ret"
273
+ GET /enc/sec%2Fret -> 200 id "sec/ret" <- hex-digit case, same id again
274
+
275
+ GET /i18n/caf%C3%A9 -> 401 hook denies this spelling
276
+ GET /i18n/caf%c3%a9 -> 200 id "café" <- same id, hook walked past
277
+ GET /i18n/%E5%8C%97%E4%BA%AC -> 401
278
+ GET /i18n/%e5%8c%97%e4%ba%ac -> 200 id "北京"
279
+ GET /i18n/a%0Db -> 401
280
+ GET /i18n/a%0db -> 200 id "a\rb" <- a CONTROL octet, same aliasing
281
+ ```
282
+
283
+ The last six lines were measured against this module's shipped predicate on a
284
+ deny list holding **no reserved character at all** — the three uppercase-hex
285
+ spellings, nothing else. If your ids are i18n text, or anything else that is not
286
+ pure `A-Za-z0-9-._~`, the residual applies to you. Reading it as "only affects
287
+ ids with a `/` or a `+` in them" is the mistake this paragraph exists to
288
+ prevent.
289
+
290
+ **So a hook comparing a raw path string is still unsound for any id carrying an
291
+ octet outside `A-Za-z0-9-._~` that keeps more than one accepted spelling —
292
+ reserved characters, non-ASCII bytes and control octets whose hex carries a
293
+ letter digit alike — and `req.params` is the sound comparison.** `req.params` is
294
+ decoded by express and is populated *before* your `auth` hook runs, by design —
295
+ compare that, and none of this applies to you. This module cannot close the
296
+ residual for you without 404ing encodings clients are entitled to send; see
297
+ [Consumer Contracts](#consumer-contracts).
298
+
299
+ #### What this does not do
157
300
 
158
301
  **It does not normalize path *parameter values*.** If your `auth()` hook rejects
159
302
  `params.id === 'restricted'`, then `GET /private/RESTRICTED` still reaches the
@@ -182,7 +325,49 @@ another variant of the bug above.
182
325
 
183
326
  #### Upgrading: behaviour changes
184
327
 
185
- Both settings change which requests match, so both are consumer-visible.
328
+ All four controls change which requests match, so all four are
329
+ consumer-visible.
330
+
331
+ **A client that over-encodes an unreserved character in a path now gets 404.**
332
+ `GET /public/url-params/%61/b/c` returns **404** where it previously returned
333
+ 200 — measured on this repo's own fixture. `%61` is `a`, and
334
+ [RFC 3986 §2.3](https://www.rfc-editor.org/rfc/rfc3986#section-2.3) says a
335
+ generator must not encode it, so no correct client emits this. Some do anyway:
336
+ over-eager `encodeURIComponent` on an id that never needed it, a URL builder
337
+ that percent-encodes everything, or a client library normalizing in the wrong
338
+ direction. Reserved characters are **unaffected** — `%2f`, `%2B`, `%25` and
339
+ every non-ASCII byte still route, and so does anything in the query string.
340
+ Remediation is `REST_CANONICAL_ENCODING=false`, or fix the client. Like the two
341
+ below, the rejection is **indistinguishable from a route that was never
342
+ registered**, and this module emits no request logging.
343
+
344
+ **Clients or forward proxies sending absolute-form request targets now get 404
345
+ on every route mounted from a request class.** `GET http://host/admin HTTP/1.1`
346
+ is a legal request target
347
+ ([RFC 9112 §3.2.2](https://www.rfc-editor.org/rfc/rfc9112#section-3.2.2)), and
348
+ express used to route it. It is now rejected on every route this module
349
+ registers — for a client that emits it, this is a total outage, not a partial
350
+ one, and it is the largest blast radius in this change. The one carve-out is a
351
+ **registration site**, not a route: the check lives in the handlers mounted from
352
+ your request classes, so anything registered directly on
353
+ `RestServer.instance.api` never reaches it. `GET /health` is the only such route
354
+ in this repo, and `GET http://host/health` still returns 200 — so do not use it
355
+ to confirm the new rejection is live, and do not assume an authorized route you
356
+ registered on `api` yourself is covered. Reverse proxies in normal use (nginx,
357
+ HAProxy, AWS ALB) send origin-form and are unaffected; **forward** proxies and
358
+ hand-rolled HTTP clients are the exposure. Remediation is
359
+ `REST_CANONICAL_ROUTES=false`, or fix the client.
360
+
361
+ **`GET /route/` at a mounted route class's root now returns 404.** Previously
362
+ 200. If a client appends a trailing slash to a mount root, it stops working.
363
+
364
+ Both rejections are **indistinguishable from a route that was never
365
+ registered** — same status, same `Content-Type`, same headers — which is the
366
+ intended security property: a distinguishable rejection is an oracle telling an
367
+ attacker the route exists and was merely spelled wrong. Combined with this
368
+ module emitting **no request logging**, a broken client shows up as a bare
369
+ `Cannot GET …` with nothing at all on the server side. **Check this first if
370
+ routes start 404ing after upgrade.**
186
371
 
187
372
  **`GET /health/` now returns 404.** `GET /health` is unaffected. This is the
188
373
  change most likely to page someone, and it is an **availability** problem rather
@@ -208,21 +393,44 @@ no log line and no stack, so it looks like a deploy that dropped a route.
208
393
 
209
394
  #### Opting out
210
395
 
211
- Two separate flags, one per axis:
396
+ Four separate flags, one per axis:
212
397
 
213
398
  ```bash
214
399
  REST_CASE_SENSITIVE_ROUTES=false # restores case-insensitive matching (#47)
215
400
  REST_STRICT_ROUTES=false # restores trailing-slash tolerance (#50)
401
+ REST_CANONICAL_ROUTES=false # restores non-canonical request targets (#54)
402
+ REST_CANONICAL_ENCODING=false # restores percent-encoded spellings (#56)
216
403
  ```
217
404
 
218
- or equivalently `restServer: { caseSensitiveRoutes: false, strictRoutes: false }`.
405
+ or equivalently
406
+ `restServer: { caseSensitiveRoutes: false, strictRoutes: false, canonicalRoutes: false, canonicalEncoding: false }`.
219
407
 
220
- **They are deliberately separate keys, and neither implies the other.** Slash
408
+ **They are deliberately separate keys, and none implies the others.** Slash
221
409
  tolerance is a legitimate need — a health-check URL you cannot change today is
222
410
  the common case. Casing tolerance almost never is. Folding them into one flag
223
411
  would force anyone who needs the first to accept the second, which is why a
224
412
  consumer who took the `#47` opt-out still has to set `REST_STRICT_ROUTES=false`
225
- separately to keep trailing slashes working.
413
+ separately to keep trailing slashes working. `REST_CANONICAL_ROUTES=false` is
414
+ separate for the same reason: a consumer who needs mount-root slash tolerance
415
+ should not have to re-open `#50`'s sub-path bypass to get it.
416
+
417
+ `REST_CANONICAL_ROUTES=false` is a **temporary remediation**, not a
418
+ configuration to run on. It re-opens both `#54` vectors at once — the mount-root
419
+ slash *and* the absolute-form target — against any hook authorizing on
420
+ `req.originalUrl`. It is env-only, so restoring service does not need a
421
+ redeploy; use it to stop the bleeding, then fix the client and remove it.
422
+
423
+ **`REST_CANONICAL_ENCODING=false` re-opens the `#56` bypass, and it is the
424
+ widest of the four.** With it set, `GET /users/%73ecret` reaches your `/:id`
425
+ handler with `id === "secret"` while your hook compared `%73ecret` and did not
426
+ match — and it does that against a hook comparing `req.path` **or**
427
+ `req.originalUrl`, on every route class with a param segment. The other three
428
+ flags each re-open one field's worth of exposure; this one re-opens both. It is
429
+ also **independent** of `REST_CANONICAL_ROUTES`: if you have to set that one for
430
+ an absolute-form-emitting forward proxy, you keep this one on, which is exactly
431
+ why they are separate keys. If you must set it, the mitigation that costs you
432
+ nothing is to compare `req.params` in your hook rather than a raw path string —
433
+ `req.params` is decoded and was never exposed to this.
226
434
 
227
435
  **Each flag restores the corresponding vulnerability described above** — the
228
436
  URL-based authorization in your application becomes bypassable along that axis
@@ -230,6 +438,23 @@ again. They exist as one-line remediations for an existing deployment, not as a
230
438
  configuration to run on. Set the flag to restore service, then fix the client
231
439
  and remove the flag.
232
440
 
441
+ ### Consumer Contracts
442
+
443
+ Three things this module deliberately does **not** do for you. Each is a state
444
+ the framework permits, only your own discipline prevents, and that produces **no
445
+ error and no log** when that discipline lapses — so they are collected here
446
+ rather than left implied by the sections above.
447
+
448
+ | you must | because | symptom if you don't |
449
+ |---|---|---|
450
+ | **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` |
451
+ | **Compare `req.params`, not a raw path string** | express decodes only `req.params`; `req.path` and `req.originalUrl` both stay percent-encoded. `canonicalEncoding` (#56) rejects an over-encoded *unreserved* character, but every octet outside `A-Za-z0-9-._~` stays encodable — **reserved characters, non-ASCII bytes and control octets alike** — and every one of them whose hex carries a letter digit aliases by hex-digit case, so one decoded id still has more than one accepted spelling: `GET /orders/a+b` and `GET /orders/a%2Bb` both run the handler with `id === "a+b"`, `sec%2fret` / `sec%2Fret` both give `sec/ret`, and `caf%C3%A9` / `caf%c3%a9` both give `café`. The class is **not** limited to reserved characters — see [the residual](#the-residual-stated-plainly) | your hook compares one spelling, the request arrives in another, and the handler runs **unauthenticated** — 200, no error, no log. Comparing `req.params.id` instead is immune by construction, and it is populated before `auth()` runs |
452
+ | **Compare param values with the same casing you look them up with** | param *values* are never case-normalized, and record ids are legitimately case-sensitive | `GET /orders/SECRET` runs the handler with `id === "SECRET"` while your hook compared `secret`. Note that lower-casing the value is **not** the fix: it false-denies a genuinely distinct `SECRET` record and, measured in a sibling module, false-allowed an encoded spelling at the same time |
453
+ | **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 |
454
+
455
+ `test/sample/requests/admin.ts` in this repo is the worked example of a
456
+ correctly-written `originalUrl` hook for the first row.
457
+
233
458
  ### Running Behind a Load Balancer
234
459
 
235
460
  When your application runs behind a reverse proxy or load balancer (e.g. AWS ALB/ELB), the load balancer terminates SSL and forwards requests to your server over HTTP internally. This means Express sees `http` as the protocol even though the original client request used `https`.
@@ -1,4 +1,6 @@
1
1
  const {
2
+ REST_CANONICAL_ENCODING,
3
+ REST_CANONICAL_ROUTES,
2
4
  REST_CASE_SENSITIVE_ROUTES,
3
5
  REST_CORS_ORIGIN,
4
6
  REST_CORS_METHODS,
@@ -22,13 +24,20 @@ const config = {
22
24
  // stubs `caseSensitiveRoutes` to `undefined` and src/route-matching.ts reads
23
25
  // `!== false`, so AC6 guards the source's read and not this default.
24
26
  // Measured: pin `caseSensitiveRoutes: true` in test/config/environment.ts AND
25
- // invert this line, and the suite reports 28 pass / 0 fail. A naive pin makes
27
+ // invert this line, and the suite reports 34 pass / 0 fail. A naive pin makes
26
28
  // an insecure published default completely invisible to a green suite --
27
29
  // quieter and weaker, which is the outcome pinning was supposed to prevent.
30
+ // Inverting this line ALONE, unpinned, reports 31 pass / 3 fail (#47's AC3,
31
+ // AC4 and AC5; AC6 green).
28
32
  //
29
33
  // The cost of leaving it unpinned is that the suite is ambient-sensitive here
30
- // (`REST_CASE_SENSITIVE_ROUTES=false pnpm test` => 25 pass / 3 fail), but it
31
- // fails LOUDLY, so there is no false green. Closing #43 for this key needs
34
+ // (`REST_CASE_SENSITIVE_ROUTES=false pnpm test` => 31 pass / 3 fail), but it
35
+ // fails LOUDLY, so there is no false green.
36
+ //
37
+ // Every count in this block was re-measured against the 34-test suite at
38
+ // #54's head. PASS totals here move whenever a test is ADDED anywhere in the
39
+ // repo -- the FAIL counts are the load-bearing half. Re-measure, do not
40
+ // adjust by arithmetic, when this block next looks stale. Closing #43 for this key needs
32
41
  // the subprocess-based env isolation this repo does not yet have; any fix
33
42
  // must keep a live assertion on this default.
34
43
  caseSensitiveRoutes: REST_CASE_SENSITIVE_ROUTES !== 'false',
@@ -46,23 +55,151 @@ const config = {
46
55
  // DELIBERATELY NOT PINNED in test/config/environment.ts -- do not "fix" this
47
56
  // as part of abofs/stonyx-rest-server#43. Same trap as the key above, and now
48
57
  // measured for both: pin `strictRoutes: true` in test/config/environment.ts
49
- // AND invert this line to `=== 'true'`, and the suite reports 31 pass /
58
+ // AND invert this line to `=== 'true'`, and the suite reports 34 pass /
50
59
  // 0 fail. A naive pin makes an insecure published default completely
51
60
  // invisible to a green suite.
52
61
  //
53
- // Unpinned, inverting this line alone turns #50's AC1 and AC2 red (29/2).
62
+ // Unpinned, inverting this line alone turns #50's AC1 and AC2 red (32/2).
54
63
  // AC3 stays GREEN under that mutation, because AC3 sets `strictRoutes` on the
55
64
  // config object directly and so guards src/route-matching.ts's READ rather
56
65
  // than this default -- the two assertions cover different halves and neither
57
66
  // subsumes the other.
58
67
  //
59
68
  // The cost is that the suite is ambient-sensitive here
60
- // (`REST_STRICT_ROUTES=false pnpm test` => 29 pass / 2 fail), but it fails
61
- // LOUDLY, so there is no false green. Closing #43 for either key needs
69
+ // (`REST_STRICT_ROUTES=false pnpm test` => 32 pass / 2 fail), but it fails
70
+ // LOUDLY, so there is no false green. All counts in this block re-measured
71
+ // against the 34-test suite at #54's head; the FAIL count is the load-bearing
72
+ // half, since PASS totals move whenever a test is added anywhere. Closing #43 for either key needs
62
73
  // subprocess-based env isolation this repo does not have; any fix must keep a
63
74
  // live assertion on this default.
64
75
  strictRoutes: REST_STRICT_ROUTES !== 'false',
65
76
 
77
+ // Secure by default, same polarity and same reasoning as the two keys above:
78
+ // a request whose RAW target is not the canonical path express matched is
79
+ // rejected with a plain 404 before the consumer's `auth` hook runs
80
+ // (abofs/stonyx-rest-server#54). This is NOT an express setting -- it is a
81
+ // per-request check in src/route-matching.ts (`shouldRejectTarget`), called
82
+ // from the handler closure in src/request.ts.
83
+ //
84
+ // BEHAVIOUR CHANGE for consumers upgrading, on TWO axes:
85
+ // 1. `GET /route/` at a mounted route class's ROOT now returns 404.
86
+ // 2. Clients or forward proxies emitting an ABSOLUTE-FORM request target
87
+ // (`GET http://host/admin HTTP/1.1`, RFC 9112 3.2.2) now receive 404 on
88
+ // every route registered through Request.registerCalls(). That is a
89
+ // REGISTRATION-SITE limit, not a carve-out for one URL: /health is
90
+ // registered directly on the parent app (src/main.ts) and still answers
91
+ // 200 to an absolute-form target -- and so would any route a consumer
92
+ // registers on the public RestServer.instance.api itself. This is the
93
+ // larger blast radius: for such a client it is a total outage, not a
94
+ // partial one. Reverse proxies in normal use
95
+ // (nginx, HAProxy, ALB) send origin-form and are unaffected.
96
+ // This module emits no request log, so both look like a dropped route.
97
+ //
98
+ // Opt out with REST_CANONICAL_ROUTES=false only as a temporary remediation --
99
+ // it RE-OPENS the bypass. Separate key from REST_STRICT_ROUTES and
100
+ // REST_CASE_SENSITIVE_ROUTES on purpose: coupling it to strictness would
101
+ // force a consumer who needs mount-root slash tolerance to also re-open #50's
102
+ // sub-path bypass.
103
+ //
104
+ // Note trustProxy below deliberately uses `=== 'true'` instead. That is not
105
+ // an inconsistency to "fix": its safe default is FALSY, so a truthy check
106
+ // already fails closed for it. The rule is "the guard must fail toward the
107
+ // safe value", not "all guards look alike".
108
+ //
109
+ // DELIBERATELY NOT PINNED in test/config/environment.ts -- do not "fix" this
110
+ // as part of abofs/stonyx-rest-server#43. Same trap as the two keys above,
111
+ // and now measured for all three: pin `canonicalRoutes: true` in
112
+ // test/config/environment.ts AND invert this line to `=== 'true'`, and the
113
+ // suite reports 34 pass / 0 fail while an insecure default ships. The pin is
114
+ // quieter AND weaker than no pin, which is the outcome pinning was supposed
115
+ // to prevent.
116
+ //
117
+ // Unpinned, inverting this line alone reports 32 pass / 2 fail: #54's
118
+ // integration AC1 and #50's AC2. AC2 (unit) stays GREEN under that mutation,
119
+ // because it sets `canonicalRoutes` on the config object directly and so
120
+ // guards src/route-matching.ts's READ rather than this default -- the two
121
+ // assertions cover different halves and neither subsumes the other.
122
+ // Conversely, weakening the READ to `=== true` reports 33 pass / 1 fail with
123
+ // AC2 as the only failure and AC1 fully green. All four counts in this block
124
+ // were re-measured on the #54 branch head after the AC1.11/AC1.12 assertions
125
+ // were added; the suite is 34 tests, and the FAIL count is the load-bearing
126
+ // half.
127
+ //
128
+ // The cost is that the suite is ambient-sensitive here
129
+ // (`REST_CANONICAL_ROUTES=false pnpm test` => 32 pass / 2 fail), but it fails
130
+ // LOUDLY, so there is no false green. Closing #43 for any of the three keys
131
+ // needs subprocess-based env isolation this repo does not have; any fix must
132
+ // keep a live assertion on this default.
133
+ canonicalRoutes: REST_CANONICAL_ROUTES !== 'false',
134
+
135
+ // Secure by default, same polarity and same reasoning as the three keys
136
+ // above: a request whose RAW target percent-encodes an RFC 3986 2.3
137
+ // UNRESERVED character (ALPHA / DIGIT / "-" / "." / "_" / "~") is rejected
138
+ // with a plain 404 before the consumer's `auth` hook runs
139
+ // (abofs/stonyx-rest-server#56). Like canonicalRoutes this is NOT an express
140
+ // setting -- it is a per-request check in src/route-matching.ts
141
+ // (`shouldRejectEncoding`), called from the handler closure in
142
+ // src/request.ts.
143
+ //
144
+ // What it closes: express decodes `req.params` and NOTHING else, so a
145
+ // consumer hook comparing `req.path` OR `req.originalUrl` was walked past by
146
+ // re-spelling an id -- `GET /enc/secret` -> 401 while
147
+ // `GET /enc/%73ecret` -> 200 with the guarded handler running
148
+ // unauthenticated and `req.params.id === 'secret'`. The spelling family is
149
+ // PROD(1 + v_i) - 1 per id, measured at 63 spellings for `secret` and 71 for
150
+ // `admin`, ALL of them 200 before this key existed. It is EXCLUSIVE to route
151
+ // classes carrying a `:param` segment; literal routes and mount segments
152
+ // match raw and were never reachable this way.
153
+ //
154
+ // BEHAVIOUR CHANGE for consumers upgrading, on a FOURTH axis:
155
+ // Any client that over-encodes an unreserved character in a path now gets
156
+ // 404. Measured on this repo's own fixture:
157
+ // `GET /public/url-params/%61/b/c` -> 404 (was 200). Over-encoding an
158
+ // unreserved character is never required by RFC 3986 -- a normaliser MUST
159
+ // decode these (6.2.2.2) -- so the blast radius is smaller than #54's,
160
+ // whose absolute-form vector hits a real deployment shape. It is still a
161
+ // breaking change and it is documented as one in the README.
162
+ //
163
+ // Opt out with REST_CANONICAL_ENCODING=false only as a temporary remediation
164
+ // -- it RE-OPENS the bypass. SEPARATE key from REST_CANONICAL_ROUTES on
165
+ // purpose, and this one is not a symmetry argument but a measurement: with
166
+ // the rule gated on `canonicalRoutes` instead of its own key,
167
+ // `REST_CANONICAL_ROUTES=false` returns `GET /enc/%73ecret` to 200 -- and
168
+ // that flag is exactly what a consumer behind an absolute-form-emitting
169
+ // forward proxy must set to stay up. Folding the two would hand precisely
170
+ // those consumers the encoding bypass as the price. Killed by
171
+ // test/unit/request-test.ts AC5.
172
+ //
173
+ // DELIBERATELY NOT PINNED in test/config/environment.ts -- do not "fix" this
174
+ // as part of abofs/stonyx-rest-server#43. Both halves RE-MEASURED for this
175
+ // key rather than inferred from the three above, against the 41-test suite
176
+ // at #56's head:
177
+ // (a) invert this line to `=== 'true'` ALONE, unpinned:
178
+ // 36 pass / 5 fail -- #56's integration AC1 and AC2, unit AC5 and AC6,
179
+ // and [Unit] Config AC7. It fails LOUDLY, so there is no false green.
180
+ // (b) pin `canonicalEncoding: true` in test/config/environment.ts AND
181
+ // invert this line: 40 pass / 1 fail, and the ONE failure is
182
+ // [Unit] Config AC7 -- every behavioural assertion goes green because
183
+ // the pin supplies the secure value the suite then observes. Measured
184
+ // again with test/unit/config-test.ts removed: 40 pass / 0 fail, a
185
+ // fully green suite shipping an insecure default. That is the trap, and
186
+ // AC7 is the only thing standing between this key and it.
187
+ //
188
+ // Conversely, weakening the READ in src/route-matching.ts to `=== true`
189
+ // reports 40 pass / 1 fail with unit AC6 as the only failure and every
190
+ // integration assertion green -- the two guard different halves and neither
191
+ // subsumes the other. Closing #43 for any of the four keys needs the
192
+ // subprocess-based env isolation this repo does not have; any fix must keep a
193
+ // live assertion on this default.
194
+ //
195
+ // FOUR security-relevant keys now, all defaulting on, all disable-able, none
196
+ // pinned. Per docs/framework/testing.md the pinned set has to be evaluated as
197
+ // a SET rather than key by key; [Unit] Config AC7 asserts all four together
198
+ // for that reason. Whoever takes #43 inherits four keys and this paragraph as
199
+ // the reason they are unpinned, rather than finding four and assuming
200
+ // neglect.
201
+ canonicalEncoding: REST_CANONICAL_ENCODING !== 'false',
202
+
66
203
  enableHealthCheck: REST_HEALTH_CHECK_DISABLE !== 'true',
67
204
  origin: REST_CORS_ORIGIN ?? '*',
68
205
  methods: REST_CORS_METHODS ?? 'GET,POST,PATCH,PUT,DELETE',
@@ -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;;IAmB3B,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;CAoItB"}
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, { shouldRejectEncoding, 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,7 +28,7 @@ export default class Request {
28
28
  constructor() {
29
29
  const api = express();
30
30
  api.disable('x-powered-by');
31
- // Applies BOTH route-matching settings: case sensitive routing
31
+ // Applies BOTH route-matching SETTINGS: case sensitive routing
32
32
  // (abofs/stonyx-rest-server#47) and strict routing (#50). For #47 this
33
33
  // call closes sub-paths (/public/SUCCESS) and the parent's call closes the
34
34
  // mount segment; for #50 THIS call closes the entire trailing-slash
@@ -36,6 +36,15 @@ export default class Request {
36
36
  // role. Must stay in the constructor: registerCalls() materializes this
37
37
  // router, and a set applied afterwards has no effect. The parent app's
38
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.
39
48
  applyRouteMatching(api);
40
49
  this.expressInstance = api;
41
50
  }
@@ -48,7 +57,86 @@ export default class Request {
48
57
  continue;
49
58
  }
50
59
  for (const [route, handler] of Object.entries(handlers)) {
51
- 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');
100
+ // abofs/stonyx-rest-server#56 -- reject a request whose RAW target
101
+ // spells an UNRESERVED character (RFC 3986 2.3) as a percent-triplet,
102
+ // closing the authorization bypass against a hook that compares
103
+ // `req.path` OR `req.originalUrl` on any route class with a `:param`
104
+ // segment. Express decodes only `req.params`, so both raw fields see
105
+ // `%73ecret` while the handler is given `secret`.
106
+ //
107
+ // A SEPARATE line and a SEPARATE predicate from #54's above, and it
108
+ // must stay that way. Extending shouldRejectTarget()'s comparison
109
+ // cannot reach this: `target === canonical` for EVERY one of these
110
+ // spellings, because both sides carry the same encoded string.
111
+ // Reading #54's key here is worse than useless -- measured, gating
112
+ // this rule on `canonicalRoutes` returns `GET /enc/%73ecret` to 200
113
+ // under `REST_CANONICAL_ROUTES=false`, which is exactly the flag an
114
+ // absolute-form-proxy consumer must set. Killed by
115
+ // `test/unit/request-test.ts` AC5.
116
+ //
117
+ // The same three load-bearing properties as the line above apply
118
+ // verbatim -- outside `if (this.auth)`, BEFORE it, and rejecting with
119
+ // `next('router')` rather than a sendStatus that answers `text/plain`
120
+ // and becomes an oracle. They are killed here by
121
+ // `test/integration/rest-server-test.ts` #56 AC1.4 (the shape
122
+ // deep-equal against a genuine miss) and AC1.6
123
+ // (`/private/restricte%64` -> 404 rather than the hook's own 403),
124
+ // on a class WITH a hook.
125
+ //
126
+ // AC1.6, NOT AC1.5, and the difference is measured rather than
127
+ // reasoned. This comment named AC1.5 (`/private/%66ailure`) until
128
+ // #58's fix round; that assertion CANNOT fail for the relocation.
129
+ // For that request `private.ts`'s hook sees `req.path === '/%66ailure'`
130
+ // (no 505) and `req.params.id === 'failure'` (no 403), so it returns
131
+ // undefined and the relocated check still fires. Only
132
+ // `/private/restricte%64` trips a hook clause -- `req.params.id` is
133
+ // DECODED by express, so it equals `restricted` and answers 403 --
134
+ // which is what makes AC1.6 the assertion that reds. Measured: move
135
+ // this line below the `if (this.auth)` block, rebuild, and the suite
136
+ // reports 40 pass / 1 fail, the single failure being #56's AC1 with
137
+ // both of assertion 6's messages red and assertion 5 GREEN.
138
+ if (shouldRejectEncoding(req))
139
+ return next('router');
52
140
  // Run auth after route matching so request.params is populated
53
141
  if (this.auth) {
54
142
  const status = this.auth(req, getState(req));
@@ -1 +1 @@
1
- {"version":3,"file":"request.js","sourceRoot":"","sources":["../src/request.ts"],"names":[],"mappings":"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"}
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
  *
@@ -63,28 +63,195 @@ import type { Express } from 'express';
63
63
  * settings (they share this function), but the justification differs and the
64
64
  * tests are built on the measured split, not on the analogy.
65
65
  *
66
- * Consequence worth stating so nobody expects this setting to cover it: the
67
- * mount-segment trailing slash (`/public/`) cannot be closed by an express
68
- * SETTING. For both `/public` and `/public/` the mounted sub-app receives
69
- * `req.path === '/'`, so a `req.path` auth hook sees no difference, and a test
70
- * asserting `/public/` -> 404 could never pass.
71
- *
72
- * That is NOT the same as saying the edge is harmless or unclosable.
73
- * `req.originalUrl` does differ, and a hook authorizing on it is bypassed by
74
- * one character -- measured: `GET /admin` -> 401, `GET /admin/` -> 200 with the
75
- * guarded handler running unauthenticated. That is a live bypass of the same
76
- * class as #47 and #50, and it IS closable by this module: a canonical-path
77
- * check ahead of the `auth` call in `Request.registerCalls()`, or opt-in
78
- * normalizing middleware. It is tracked as abofs/stonyx-rest-server#54. Do not
79
- * close it here, and do not read this note as saying it cannot be closed.
80
- *
81
- * Both guards are `!== false` for the same reason: these flags default to the
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
+ * All four guards in this file are `!== false` for the same reason: these flags
81
+ * default to the
82
82
  * truthy direction, so a truthy check fails OPEN for a consumer whose shipped
83
- * config predates the key. Both are also asserted at the unit tier for BOTH
83
+ * config predates the key. All are also asserted at the unit tier for BOTH
84
84
  * failure shapes -- key present-and-`undefined` and key absent as an own
85
- * property -- in `test/unit/request-test.ts` (#47's AC6, #50's AC3). The
86
- * integration tier cannot see either: with the shipped default `true`, a
87
- * fail-open guard leaves every integration assertion green.
85
+ * property -- in `test/unit/request-test.ts` (#47's AC6, #50's AC3, #54's AC2,
86
+ * #56's AC6). The integration tier cannot see any of them: with the shipped
87
+ * default `true`, a fail-open guard leaves every integration assertion green.
88
88
  */
89
89
  export default function applyRouteMatching(api: Express): void;
90
+ /**
91
+ * Decides whether a request must be rejected because its RAW request target is
92
+ * not the canonical path express matched (abofs/stonyx-rest-server#54).
93
+ *
94
+ * Closes two live authorization bypasses against a consumer hook that
95
+ * authorizes on `req.originalUrl` -- the field express does NOT normalize:
96
+ *
97
+ * 1. mount-root trailing slash GET /admin/ -> was 200
98
+ * 2. absolute-form request target GET http://host/admin -> was 200
99
+ * (RFC 9112 3.2.2; hits EVERY route, not just the mount root)
100
+ *
101
+ * Both were measured reaching the guarded handler unauthenticated while
102
+ * `GET /admin` was denied 401.
103
+ *
104
+ * COMPARE THE RAW TARGET; DO NOT PARSE IT. Any implementation reaching for
105
+ * `new URL(req.originalUrl, base).pathname` to "get the path" re-opens vector 2
106
+ * BY CONSTRUCTION: parsing normalizes the exact string the consumer's hook is
107
+ * exposed to, so the check would compare a laundered value while the hook still
108
+ * sees the raw one. Measured: the narrow `endsWith('/')` form closes 1 and
109
+ * leaves 2 at 200.
110
+ *
111
+ * `req.baseUrl + req.path` is likewise NOT usable as the left-hand side. It is
112
+ * `/admin/` for BOTH spellings of vector 1 -- `originalUrl` is the only field
113
+ * that differs, which is precisely why the bypass exists. A check built on
114
+ * `baseUrl + path` cannot see its own defect.
115
+ *
116
+ * TIMING CONTRACT -- DELIBERATELY DIFFERENT FROM ITS TWO SIBLINGS. This lives
117
+ * beside `applyRouteMatching()` for the same "one place anchors it" reason, but
118
+ * deliberately OUTSIDE it: that function's contract is *apply express settings
119
+ * to an app*, it is called from two constructors, and this is neither a setting
120
+ * nor constructor-timed. `caseSensitiveRoutes`/`strictRoutes` are read once in a
121
+ * constructor and are silently ineffective if applied late; this flag is read
122
+ * PER REQUEST, inside the handler closure. There is no lazy-materialisation
123
+ * hazard here, so do not carry that constraint across.
124
+ *
125
+ * The caller must reject with `next('router')`, NOT `res.sendStatus(404)`, and
126
+ * must run this BEFORE `this.auth` as well as outside `if (this.auth)` -- those
127
+ * are two separate properties with two separate assertions (AC1.11 and AC1.6);
128
+ * see src/request.ts.
129
+ *
130
+ * Guard polarity is `!== false`, matching its three siblings, for the same measured
131
+ * reason: the secure value is the TRUTHY one, so a plain truthy check fails
132
+ * OPEN for any consumer whose shipped `restServer` block predates the key --
133
+ * the state every existing consumer is in, and reachable in practice because
134
+ * the stonyx loader only merges a module's `config/environment.js` for modules
135
+ * in devDependencies. `trustProxy` deliberately differs (`=== 'true'`): its safe
136
+ * default is FALSY, so a truthy check already fails closed for it. The rule is
137
+ * "the guard must fail toward the safe value", not "all guards look alike" --
138
+ * preserve the asymmetry.
139
+ *
140
+ * The integration tier cannot see a fail-open guard here: with the shipped
141
+ * default `true`, `=== true` leaves every integration assertion green. Only
142
+ * `test/unit/request-test.ts` AC2 can, and it probes BOTH failure shapes --
143
+ * key present-and-`undefined` and key absent as an own property.
144
+ */
145
+ export declare function shouldRejectTarget(req: ExpressRequest): boolean;
146
+ /**
147
+ * Decides whether a request must be rejected because its RAW request target
148
+ * spells an unreserved character as a percent-triplet
149
+ * (abofs/stonyx-rest-server#56).
150
+ *
151
+ * Closes a live authorization bypass on any route class carrying a `:param`
152
+ * segment. Express decodes `req.params` and NOTHING else -- `req.path` and
153
+ * `req.originalUrl` both stay percent-encoded -- so a consumer hook comparing
154
+ * either of those raw fields was walked past by re-spelling the id:
155
+ *
156
+ * GET /enc/secret -> 401 (hook fires)
157
+ * GET /enc/%73ecret -> 200 guarded handler, unauthenticated, id "secret"
158
+ *
159
+ * Both hook shapes are affected and neither is safer than the other; there is
160
+ * no spelling that defeats one and not the same-id comparison in the other.
161
+ * A third shape is worse still: a LITERAL guarded route co-registered with a
162
+ * sibling `/:id` (this repo's own `test/sample/requests/private.ts`) has the
163
+ * encoded spelling miss the literal layer and be ABSORBED by the param route,
164
+ * so the guard is walked past without the guarded handler ever running --
165
+ * measured `GET /private/failure` -> 505 vs `GET /private/%66ailure` -> 200.
166
+ *
167
+ * THE RULE IS AN UNRESERVED-OCTET SCAN, NOT A DECODE-AND-COMPARE. Two wrong
168
+ * implementations were built and measured, and each breaks a legitimate
169
+ * request:
170
+ *
171
+ * 1. `decodeURIComponent(target) !== target` -- rejects `/enc/sec%2fret`
172
+ * (404), which names the DISTINCT id `sec/ret`. The router SPLITS then
173
+ * DECODES; a whole-target decode decodes then splits, and the two
174
+ * disagree about `%2f` by construction. Killed by AC3.
175
+ * 2. decode until stable -- rejects `/enc/%2573ecret` (404), which names the
176
+ * legitimate id `%73ecret`. Express decodes EXACTLY ONCE, so `%2561` is
177
+ * not a bypass and a loop invents a false deny. Killed by AC4.
178
+ *
179
+ * WHY THIS IS NOT PART OF `shouldRejectTarget()` (#54), and why extending that
180
+ * comparison cannot work: for `GET /enc/%73ecret`, `originalUrl` is
181
+ * `/enc/%73ecret`, `baseUrl` is `/enc` and `path` is `/%73ecret`, so
182
+ * `target === canonical` -- both sides carry the SAME encoded string. The
183
+ * comparison is structurally blind to this axis and no change to it can see it.
184
+ *
185
+ * WHY IT IS A FOURTH KEY AND NOT A REUSE OF `canonicalRoutes`. Measured with
186
+ * the rule implemented correctly but gated on #54's key:
187
+ * `REST_CANONICAL_ROUTES=false` returns `GET /enc/%73ecret` to 200. That flag
188
+ * is exactly what a consumer behind an absolute-form-emitting forward proxy
189
+ * must set, so folding the two would hand precisely those consumers the
190
+ * encoding bypass as the price of staying up. Same argument the block above
191
+ * makes for why #50 is not a rename of #47. Pinned by
192
+ * `test/unit/request-test.ts` AC5, which also asserts -- in that same state --
193
+ * that #54's own vector IS re-opened, so an implementation that simply ignores
194
+ * `canonicalRoutes` cannot pass it vacuously.
195
+ *
196
+ * TIMING CONTRACT: identical to `shouldRejectTarget()` and NOT to the two
197
+ * settings above. Read per request, inside the handler closure in
198
+ * `Request.registerCalls()`; there is no lazy-materialisation hazard, so do not
199
+ * move it into `applyRouteMatching()`. The caller must reject with
200
+ * `next('router')`, and must run this BEFORE `this.auth` as well as outside
201
+ * `if (this.auth)` -- see src/request.ts.
202
+ *
203
+ * Guard polarity is `!== false`, matching all three siblings, for the same
204
+ * measured reason: the secure value is the TRUTHY one, so `=== true` fails OPEN
205
+ * for any consumer whose shipped `restServer` block predates the key. The
206
+ * integration tier CANNOT see that mutation -- with the shipped default `true`
207
+ * every integration assertion stays green -- so it is `test/unit/request-test.ts`
208
+ * AC6 that kills it, probing the key present-and-`undefined` and absent as an
209
+ * own property separately.
210
+ *
211
+ * WHAT THIS DOES NOT CLOSE, stated here rather than left implied. It cannot
212
+ * give each decoded id exactly one accepted spelling, because everything the
213
+ * allowlist above does NOT cover must remain encodable: `/enc/a+b` and
214
+ * `/enc/a%2Bb` both name the id `a+b`, and `/enc/sec%2fret` and
215
+ * `/enc/sec%2Fret` both name `sec/ret`.
216
+ *
217
+ * THE RESIDUAL IS WIDER THAN "RESERVED CHARACTERS" AND MUST NOT BE WRITTEN
218
+ * DOWN THAT WAY. An octet outside `[A-Za-z0-9-._~]` keeps more than one
219
+ * accepted spelling when its hex carries a letter digit (upper- and lower-case
220
+ * hex) or when a client may also send it literally. That is every reserved
221
+ * character, every non-ASCII byte AND every control octet whose hex carries a
222
+ * letter digit. Measured through a real listener against this predicate, on a
223
+ * deny list holding NO reserved character at all:
224
+ *
225
+ * GET /i18n/caf%C3%A9 -> 401 GET /i18n/caf%c3%a9 -> 200, id "café"
226
+ * GET /i18n/%E5%8C%97%E4%BA%AC -> 401 GET /i18n/%e5%8c%97%e4%ba%ac -> 200, id "北京"
227
+ * GET /i18n/a%0Db -> 401 GET /i18n/a%0db -> 200, id "a\rb"
228
+ *
229
+ * A consumer whose ids are i18n text reads "any id containing a reserved
230
+ * character" as not applying to them. It does. The three docs that carried the
231
+ * narrow wording (`README.md`, `docs/project-structure.md`,
232
+ * `docs/agents/security-reviewer.md`) were widened to this scope rather than
233
+ * this comment being narrowed to theirs.
234
+ *
235
+ * NOT the whole complement of the unreserved set, and this qualifier is load-
236
+ * bearing rather than pedantry -- the sentence above is stated as measured, so
237
+ * it must not over-warn either. Measured counterexamples: `%21` and `%40` are
238
+ * reserved and carry no letter hex digit, so they alias literal-versus-encoded
239
+ * rather than by hex case; `%00` and `%09` have exactly one accepted spelling
240
+ * and do not alias at all; `%90` is a 400 (invalid UTF-8), not an alias.
241
+ *
242
+ * So a hook comparing a raw path string REMAINS UNSOUND for any id carrying an
243
+ * octet outside `[A-Za-z0-9-._~]` that keeps more than one accepted spelling,
244
+ * and `req.params` -- which express decodes, and which is populated before
245
+ * `auth()` runs -- is the sound idiom. That
246
+ * residual is the consumer's comparison to own; the module cannot close it
247
+ * without 404ing encodings clients are required to emit.
248
+ *
249
+ * SCOPE LIMIT, separate from the residual above: this predicate is called from
250
+ * `Request.registerCalls()`, so it covers the routes mounted from request
251
+ * classes and nothing else. A route registered directly on the public
252
+ * `RestServer.instance.api` gets none of it -- measured,
253
+ * `GET /direct/%73ecret` -> 200 with `id "secret"` while `GET /enc/%73ecret`
254
+ * -> 404. Same registration-site limit `canonicalRoutes` (#54) has.
255
+ */
256
+ export declare function shouldRejectEncoding(req: ExpressRequest): boolean;
90
257
  //# 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;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAsFG;AACH,MAAM,CAAC,OAAO,UAAU,kBAAkB,CAAC,GAAG,EAAE,OAAO,GAAG,IAAI,CAG7D"}
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;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAsFG;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;AA2BD;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA6GG;AACH,wBAAgB,oBAAoB,CAAC,GAAG,EAAE,cAAc,GAAG,OAAO,CAkBjE"}
@@ -63,28 +63,28 @@ import config from 'stonyx/config';
63
63
  * settings (they share this function), but the justification differs and the
64
64
  * tests are built on the measured split, not on the analogy.
65
65
  *
66
- * Consequence worth stating so nobody expects this setting to cover it: the
67
- * mount-segment trailing slash (`/public/`) cannot be closed by an express
68
- * SETTING. For both `/public` and `/public/` the mounted sub-app receives
69
- * `req.path === '/'`, so a `req.path` auth hook sees no difference, and a test
70
- * asserting `/public/` -> 404 could never pass.
71
- *
72
- * That is NOT the same as saying the edge is harmless or unclosable.
73
- * `req.originalUrl` does differ, and a hook authorizing on it is bypassed by
74
- * one character -- measured: `GET /admin` -> 401, `GET /admin/` -> 200 with the
75
- * guarded handler running unauthenticated. That is a live bypass of the same
76
- * class as #47 and #50, and it IS closable by this module: a canonical-path
77
- * check ahead of the `auth` call in `Request.registerCalls()`, or opt-in
78
- * normalizing middleware. It is tracked as abofs/stonyx-rest-server#54. Do not
79
- * close it here, and do not read this note as saying it cannot be closed.
80
- *
81
- * Both guards are `!== false` for the same reason: these flags default to the
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
+ * All four guards in this file are `!== false` for the same reason: these flags
81
+ * default to the
82
82
  * truthy direction, so a truthy check fails OPEN for a consumer whose shipped
83
- * config predates the key. Both are also asserted at the unit tier for BOTH
83
+ * config predates the key. All are also asserted at the unit tier for BOTH
84
84
  * failure shapes -- key present-and-`undefined` and key absent as an own
85
- * property -- in `test/unit/request-test.ts` (#47's AC6, #50's AC3). The
86
- * integration tier cannot see either: with the shipped default `true`, a
87
- * fail-open guard leaves every integration assertion green.
85
+ * property -- in `test/unit/request-test.ts` (#47's AC6, #50's AC3, #54's AC2,
86
+ * #56's AC6). The integration tier cannot see any of them: with the shipped
87
+ * default `true`, a fail-open guard leaves every integration assertion green.
88
88
  */
89
89
  export default function applyRouteMatching(api) {
90
90
  if (config.restServer?.caseSensitiveRoutes !== false)
@@ -92,4 +92,232 @@ export default function applyRouteMatching(api) {
92
92
  if (config.restServer?.strictRoutes !== false)
93
93
  api.set('strict routing', true);
94
94
  }
95
+ /**
96
+ * Decides whether a request must be rejected because its RAW request target is
97
+ * not the canonical path express matched (abofs/stonyx-rest-server#54).
98
+ *
99
+ * Closes two live authorization bypasses against a consumer hook that
100
+ * authorizes on `req.originalUrl` -- the field express does NOT normalize:
101
+ *
102
+ * 1. mount-root trailing slash GET /admin/ -> was 200
103
+ * 2. absolute-form request target GET http://host/admin -> was 200
104
+ * (RFC 9112 3.2.2; hits EVERY route, not just the mount root)
105
+ *
106
+ * Both were measured reaching the guarded handler unauthenticated while
107
+ * `GET /admin` was denied 401.
108
+ *
109
+ * COMPARE THE RAW TARGET; DO NOT PARSE IT. Any implementation reaching for
110
+ * `new URL(req.originalUrl, base).pathname` to "get the path" re-opens vector 2
111
+ * BY CONSTRUCTION: parsing normalizes the exact string the consumer's hook is
112
+ * exposed to, so the check would compare a laundered value while the hook still
113
+ * sees the raw one. Measured: the narrow `endsWith('/')` form closes 1 and
114
+ * leaves 2 at 200.
115
+ *
116
+ * `req.baseUrl + req.path` is likewise NOT usable as the left-hand side. It is
117
+ * `/admin/` for BOTH spellings of vector 1 -- `originalUrl` is the only field
118
+ * that differs, which is precisely why the bypass exists. A check built on
119
+ * `baseUrl + path` cannot see its own defect.
120
+ *
121
+ * TIMING CONTRACT -- DELIBERATELY DIFFERENT FROM ITS TWO SIBLINGS. This lives
122
+ * beside `applyRouteMatching()` for the same "one place anchors it" reason, but
123
+ * deliberately OUTSIDE it: that function's contract is *apply express settings
124
+ * to an app*, it is called from two constructors, and this is neither a setting
125
+ * nor constructor-timed. `caseSensitiveRoutes`/`strictRoutes` are read once in a
126
+ * constructor and are silently ineffective if applied late; this flag is read
127
+ * PER REQUEST, inside the handler closure. There is no lazy-materialisation
128
+ * hazard here, so do not carry that constraint across.
129
+ *
130
+ * The caller must reject with `next('router')`, NOT `res.sendStatus(404)`, and
131
+ * must run this BEFORE `this.auth` as well as outside `if (this.auth)` -- those
132
+ * are two separate properties with two separate assertions (AC1.11 and AC1.6);
133
+ * see src/request.ts.
134
+ *
135
+ * Guard polarity is `!== false`, matching its three siblings, for the same measured
136
+ * reason: the secure value is the TRUTHY one, so a plain truthy check fails
137
+ * OPEN for any consumer whose shipped `restServer` block predates the key --
138
+ * the state every existing consumer is in, and reachable in practice because
139
+ * the stonyx loader only merges a module's `config/environment.js` for modules
140
+ * in devDependencies. `trustProxy` deliberately differs (`=== 'true'`): its safe
141
+ * default is FALSY, so a truthy check already fails closed for it. The rule is
142
+ * "the guard must fail toward the safe value", not "all guards look alike" --
143
+ * preserve the asymmetry.
144
+ *
145
+ * The integration tier cannot see a fail-open guard here: with the shipped
146
+ * default `true`, `=== true` leaves every integration assertion green. Only
147
+ * `test/unit/request-test.ts` AC2 can, and it probes BOTH failure shapes --
148
+ * key present-and-`undefined` and key absent as an own property.
149
+ */
150
+ export function shouldRejectTarget(req) {
151
+ // Written as `!== false` rather than `=== false` on purpose: the polarity is
152
+ // the load-bearing part and it should read identically to the two guards in
153
+ // applyRouteMatching() above.
154
+ const enforced = config.restServer?.canonicalRoutes !== false;
155
+ if (!enforced)
156
+ return false;
157
+ // Raw, unparsed. Only the query string is removed, by string split.
158
+ const target = req.originalUrl.split('?')[0];
159
+ // At a mount root express reports `req.path === '/'` while the canonical
160
+ // target is the bare mount segment, so the two are not simply concatenated.
161
+ //
162
+ // `&& req.baseUrl` is load-bearing and is NOT a redundant truthiness guard.
163
+ // A route class named `index` mounts at '/' (src/main.ts `mountRoute()`),
164
+ // and that is the one mount shape where `req.baseUrl` is ''. Without the
165
+ // conjunct, `GET /` compares the raw target '/' against a canonical of '' and
166
+ // the APPLICATION ROOT is rejected. Measured before it had a guard: shipped
167
+ // `GET /` -> 200, conjunct dropped -> 404, suite 34 pass / 0 fail BOTH ways.
168
+ // Killed now by AC1.12, against `test/sample/requests/index.ts`.
169
+ const canonical = req.path === '/' && req.baseUrl ? req.baseUrl : req.baseUrl + req.path;
170
+ return target !== canonical;
171
+ }
172
+ // RFC 3986 §2.3 UNRESERVED = ALPHA / DIGIT / "-" / "." / "_" / "~".
173
+ //
174
+ // These are the characters a URI generator MUST NOT percent-encode and that a
175
+ // normaliser MUST decode (§6.2.2.2), so an encoded one carries no information
176
+ // a client is ever required to send. Everything else -- every RESERVED
177
+ // character and every non-ASCII octet -- stays encodable, which is the whole
178
+ // reason this is an allowlist of octets rather than a ban on triplets. See
179
+ // `shouldRejectEncoding()` below.
180
+ const UNRESERVED_OCTET = /^[A-Za-z0-9\-._~]$/;
181
+ // A percent-triplet: `%` followed by exactly two hex digits, either case.
182
+ //
183
+ // A `%` can never be part of ANOTHER triplet's hex digits, because `%` is not a
184
+ // hex digit -- so scanning left to right without skipping cannot produce an
185
+ // overlapping false match. `%2561` therefore yields exactly one candidate
186
+ // (`%25`), which is the property AC4 pins.
187
+ //
188
+ // Malformed and over-long escapes (`%zz`, `%`, `%6`, `%c1%a1`, `%e0%81%a1`) are
189
+ // deliberately NOT this function's business: `router@2.2.0`'s `decodeParam`
190
+ // (lib/layer.js:225) answers 400 for them before any handler or hook runs.
191
+ // Verified here rather than imported -- measured 400 both before and after this
192
+ // change. None of those octets is unreserved, and the first three are not valid
193
+ // triplets at all, so the rule does not touch them either way.
194
+ const PERCENT_TRIPLET = /%([0-9A-Fa-f]{2})/g;
195
+ /**
196
+ * Decides whether a request must be rejected because its RAW request target
197
+ * spells an unreserved character as a percent-triplet
198
+ * (abofs/stonyx-rest-server#56).
199
+ *
200
+ * Closes a live authorization bypass on any route class carrying a `:param`
201
+ * segment. Express decodes `req.params` and NOTHING else -- `req.path` and
202
+ * `req.originalUrl` both stay percent-encoded -- so a consumer hook comparing
203
+ * either of those raw fields was walked past by re-spelling the id:
204
+ *
205
+ * GET /enc/secret -> 401 (hook fires)
206
+ * GET /enc/%73ecret -> 200 guarded handler, unauthenticated, id "secret"
207
+ *
208
+ * Both hook shapes are affected and neither is safer than the other; there is
209
+ * no spelling that defeats one and not the same-id comparison in the other.
210
+ * A third shape is worse still: a LITERAL guarded route co-registered with a
211
+ * sibling `/:id` (this repo's own `test/sample/requests/private.ts`) has the
212
+ * encoded spelling miss the literal layer and be ABSORBED by the param route,
213
+ * so the guard is walked past without the guarded handler ever running --
214
+ * measured `GET /private/failure` -> 505 vs `GET /private/%66ailure` -> 200.
215
+ *
216
+ * THE RULE IS AN UNRESERVED-OCTET SCAN, NOT A DECODE-AND-COMPARE. Two wrong
217
+ * implementations were built and measured, and each breaks a legitimate
218
+ * request:
219
+ *
220
+ * 1. `decodeURIComponent(target) !== target` -- rejects `/enc/sec%2fret`
221
+ * (404), which names the DISTINCT id `sec/ret`. The router SPLITS then
222
+ * DECODES; a whole-target decode decodes then splits, and the two
223
+ * disagree about `%2f` by construction. Killed by AC3.
224
+ * 2. decode until stable -- rejects `/enc/%2573ecret` (404), which names the
225
+ * legitimate id `%73ecret`. Express decodes EXACTLY ONCE, so `%2561` is
226
+ * not a bypass and a loop invents a false deny. Killed by AC4.
227
+ *
228
+ * WHY THIS IS NOT PART OF `shouldRejectTarget()` (#54), and why extending that
229
+ * comparison cannot work: for `GET /enc/%73ecret`, `originalUrl` is
230
+ * `/enc/%73ecret`, `baseUrl` is `/enc` and `path` is `/%73ecret`, so
231
+ * `target === canonical` -- both sides carry the SAME encoded string. The
232
+ * comparison is structurally blind to this axis and no change to it can see it.
233
+ *
234
+ * WHY IT IS A FOURTH KEY AND NOT A REUSE OF `canonicalRoutes`. Measured with
235
+ * the rule implemented correctly but gated on #54's key:
236
+ * `REST_CANONICAL_ROUTES=false` returns `GET /enc/%73ecret` to 200. That flag
237
+ * is exactly what a consumer behind an absolute-form-emitting forward proxy
238
+ * must set, so folding the two would hand precisely those consumers the
239
+ * encoding bypass as the price of staying up. Same argument the block above
240
+ * makes for why #50 is not a rename of #47. Pinned by
241
+ * `test/unit/request-test.ts` AC5, which also asserts -- in that same state --
242
+ * that #54's own vector IS re-opened, so an implementation that simply ignores
243
+ * `canonicalRoutes` cannot pass it vacuously.
244
+ *
245
+ * TIMING CONTRACT: identical to `shouldRejectTarget()` and NOT to the two
246
+ * settings above. Read per request, inside the handler closure in
247
+ * `Request.registerCalls()`; there is no lazy-materialisation hazard, so do not
248
+ * move it into `applyRouteMatching()`. The caller must reject with
249
+ * `next('router')`, and must run this BEFORE `this.auth` as well as outside
250
+ * `if (this.auth)` -- see src/request.ts.
251
+ *
252
+ * Guard polarity is `!== false`, matching all three siblings, for the same
253
+ * measured reason: the secure value is the TRUTHY one, so `=== true` fails OPEN
254
+ * for any consumer whose shipped `restServer` block predates the key. The
255
+ * integration tier CANNOT see that mutation -- with the shipped default `true`
256
+ * every integration assertion stays green -- so it is `test/unit/request-test.ts`
257
+ * AC6 that kills it, probing the key present-and-`undefined` and absent as an
258
+ * own property separately.
259
+ *
260
+ * WHAT THIS DOES NOT CLOSE, stated here rather than left implied. It cannot
261
+ * give each decoded id exactly one accepted spelling, because everything the
262
+ * allowlist above does NOT cover must remain encodable: `/enc/a+b` and
263
+ * `/enc/a%2Bb` both name the id `a+b`, and `/enc/sec%2fret` and
264
+ * `/enc/sec%2Fret` both name `sec/ret`.
265
+ *
266
+ * THE RESIDUAL IS WIDER THAN "RESERVED CHARACTERS" AND MUST NOT BE WRITTEN
267
+ * DOWN THAT WAY. An octet outside `[A-Za-z0-9-._~]` keeps more than one
268
+ * accepted spelling when its hex carries a letter digit (upper- and lower-case
269
+ * hex) or when a client may also send it literally. That is every reserved
270
+ * character, every non-ASCII byte AND every control octet whose hex carries a
271
+ * letter digit. Measured through a real listener against this predicate, on a
272
+ * deny list holding NO reserved character at all:
273
+ *
274
+ * GET /i18n/caf%C3%A9 -> 401 GET /i18n/caf%c3%a9 -> 200, id "café"
275
+ * GET /i18n/%E5%8C%97%E4%BA%AC -> 401 GET /i18n/%e5%8c%97%e4%ba%ac -> 200, id "北京"
276
+ * GET /i18n/a%0Db -> 401 GET /i18n/a%0db -> 200, id "a\rb"
277
+ *
278
+ * A consumer whose ids are i18n text reads "any id containing a reserved
279
+ * character" as not applying to them. It does. The three docs that carried the
280
+ * narrow wording (`README.md`, `docs/project-structure.md`,
281
+ * `docs/agents/security-reviewer.md`) were widened to this scope rather than
282
+ * this comment being narrowed to theirs.
283
+ *
284
+ * NOT the whole complement of the unreserved set, and this qualifier is load-
285
+ * bearing rather than pedantry -- the sentence above is stated as measured, so
286
+ * it must not over-warn either. Measured counterexamples: `%21` and `%40` are
287
+ * reserved and carry no letter hex digit, so they alias literal-versus-encoded
288
+ * rather than by hex case; `%00` and `%09` have exactly one accepted spelling
289
+ * and do not alias at all; `%90` is a 400 (invalid UTF-8), not an alias.
290
+ *
291
+ * So a hook comparing a raw path string REMAINS UNSOUND for any id carrying an
292
+ * octet outside `[A-Za-z0-9-._~]` that keeps more than one accepted spelling,
293
+ * and `req.params` -- which express decodes, and which is populated before
294
+ * `auth()` runs -- is the sound idiom. That
295
+ * residual is the consumer's comparison to own; the module cannot close it
296
+ * without 404ing encodings clients are required to emit.
297
+ *
298
+ * SCOPE LIMIT, separate from the residual above: this predicate is called from
299
+ * `Request.registerCalls()`, so it covers the routes mounted from request
300
+ * classes and nothing else. A route registered directly on the public
301
+ * `RestServer.instance.api` gets none of it -- measured,
302
+ * `GET /direct/%73ecret` -> 200 with `id "secret"` while `GET /enc/%73ecret`
303
+ * -> 404. Same registration-site limit `canonicalRoutes` (#54) has.
304
+ */
305
+ export function shouldRejectEncoding(req) {
306
+ // `!== false`, not `=== false` and not a truthy check: the polarity is the
307
+ // load-bearing part and it should read identically to the three guards above.
308
+ const enforced = config.restServer?.canonicalEncoding !== false;
309
+ if (!enforced)
310
+ return false;
311
+ // Raw, unparsed, and only the query string removed -- by string split, for
312
+ // the same reason #54 gives: parsing would launder the exact string the
313
+ // consumer's hook is exposed to. The query is stripped because a query string
314
+ // is a legitimately variable part of a request target and may carry any
315
+ // encoding at all; `?name=%61` is a normal request and must not 404.
316
+ const target = req.originalUrl.split('?')[0];
317
+ for (const [, hex] of target.matchAll(PERCENT_TRIPLET)) {
318
+ if (UNRESERVED_OCTET.test(String.fromCharCode(parseInt(hex, 16))))
319
+ return true;
320
+ }
321
+ return false;
322
+ }
95
323
  //# 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;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAsFG;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"}
1
+ {"version":3,"file":"route-matching.js","sourceRoot":"","sources":["../src/route-matching.ts"],"names":[],"mappings":"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"}
package/package.json CHANGED
@@ -4,7 +4,7 @@
4
4
  "stonyx-async",
5
5
  "stonyx-module"
6
6
  ],
7
- "version": "0.2.1-beta.91",
7
+ "version": "0.2.1-beta.93",
8
8
  "description": "Rest Server Module for Stonyx Framework",
9
9
  "repository": {
10
10
  "type": "git",