@stonyx/rest-server 0.2.1-alpha.25 → 0.2.1-alpha.26

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
@@ -82,24 +82,27 @@ Configuration is read from `stonyx/config` under `restServer`:
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
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)). |
85
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. |
86
87
  | `statusMap` | **Object** | `{}` | Optional mapping of HTTP status codes to custom messages |
87
88
 
88
89
  ### Route Matching Strictness
89
90
 
90
- Routes match **case-sensitively, strictly, and only at their canonical target
91
- by default**. Three controls, all on:
91
+ Routes match **case-sensitively, strictly, only at their canonical target, and
92
+ only at their canonical spelling by default**. Four controls, all on:
92
93
 
93
94
  | axis | control | example that no longer matches |
94
95
  |---|---|---|
95
96
  | casing | `case sensitive routing` (setting) | `GET /users/Success` -> does not reach `/success` |
96
97
  | trailing slash | `strict routing` (setting) | `GET /users/success/` -> does not reach `/success` |
97
98
  | canonical target | `canonicalRoutes` (per-request check) | `GET /users/` and `GET http://host/users` -> do not reach the mounted `/users` class |
99
+ | percent-encoding | `canonicalEncoding` (per-request check) | `GET /users/%73ecret` -> does not reach `/users/:id` with `id === "secret"` |
98
100
 
99
- The first two are express settings applied at both construction sites. The
100
- third is **not a setting** — no express setting can express itit is a
101
- per-request comparison of the raw request target against the path express
102
- matched, run ahead of your `auth` hook. See
101
+ The first two are express settings applied at both construction sites. The last
102
+ two are **not settings** — no express setting can express eitherthey are
103
+ per-request checks run ahead of your `auth` hook: one compares the raw request
104
+ target against the path express matched, the other rejects a raw target that
105
+ percent-encodes a character which never needs encoding. See
103
106
  [`src/route-matching.ts`](src/route-matching.ts).
104
107
 
105
108
  Read [What this does not do](#what-this-does-not-do) and
@@ -191,19 +194,75 @@ Routes registered *with* a literal trailing slash are unaffected — their
191
194
  canonical target carries the slash. So are index-mounted route classes and
192
195
  query strings on canonical paths.
193
196
 
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.
197
+ **Param routes: "unaffected" means "no regression".** `/resource/:id` keeps
198
+ matching exactly as it did. `canonicalRoutes` is structurally blind to how a
199
+ param value is *spelled* — express decodes only `req.params`, so `target` and
200
+ `canonical` are both the same encoded string and this comparison passes the
201
+ request through by construction. That axis has its own control, below.
202
+
203
+ #### The percent-encoding check (`canonicalEncoding`)
204
+
205
+ Express decodes **`req.params` and nothing else**. `req.path` and
206
+ `req.originalUrl` both stay percent-encoded, so an `auth` hook comparing either
207
+ of them against a fixed string was walked past by re-spelling the id:
208
+
209
+ ```
210
+ before after
211
+ GET /enc/secret 401 401 (hook fires, request blocked)
212
+ GET /enc/%73ecret 200 404 (hook never fired; handler got id "secret")
213
+ GET /enc/%73%65%63%72%65%74 200 404
214
+ GET /private/%66ailure 200 404 (guard missed; absorbed by a sibling /:id)
215
+ ```
216
+
217
+ `canonicalEncoding` closes it
218
+ ([#56](https://github.com/abofs/stonyx-rest-server/issues/56)). Before your
219
+ `auth` hook runs, the query-stripped raw target is rejected as a plain 404 if it
220
+ contains a percent-triplet whose octet is an
221
+ [RFC 3986 §2.3](https://www.rfc-editor.org/rfc/rfc3986#section-2.3)
222
+ **unreserved** character — `A-Z`, `a-z`, `0-9`, `-`, `.`, `_`, `~`. Those are
223
+ exactly the characters a URI generator must **not** encode and a normalizer
224
+ **must** decode, so nothing a client is required to send is affected.
225
+
226
+ **This is not a list of spellings, it is a family.** For an id of *n* bytes
227
+ there are `∏(1 + vᵢ) − 1` non-canonical spellings, where a byte whose hex
228
+ carries a letter digit has two (`m`, `%6d`, `%6D`). Measured against unfixed
229
+ code: **63 of 63** spellings of `secret` returned 200, and **71 of 71** of
230
+ `admin`. Enumerating them in your own hook is not a remedy.
231
+
232
+ **Three things it deliberately does not reject:**
233
+
234
+ ```
235
+ GET /enc/sec%2fret -> 200 id "sec/ret" %2f is RESERVED — must stay encodable
236
+ GET /enc/a%2Bb -> 200 id "a+b" %2B is RESERVED
237
+ GET /enc/%2573ecret -> 200 id "%73ecret" express decodes exactly ONCE
238
+ GET /enc/x?name=%61 -> 200 id "x" the query string is stripped, not scanned
239
+ GET /enc/%zz -> 400 malformed escapes are the router's 400, unchanged
240
+ ```
241
+
242
+ If you were tempted to write `decodeURIComponent(req.path)` in your hook: the
243
+ first line is why not. The router **splits then decodes**, so `sec%2fret` is one
244
+ segment naming the id `sec/ret`; a hook that decodes then splits sees two
245
+ segments and denies a request the router routed somewhere else entirely. And a
246
+ hook that decodes *until stable* denies line three, which is a legitimately
247
+ distinct id.
248
+
249
+ **The residual, stated plainly: this does not give each id one spelling.**
250
+ Because reserved characters must stay encodable, two different raw targets can
251
+ still name the same record:
252
+
253
+ ```
254
+ GET /enc/a+b -> 200 id "a+b"
255
+ GET /enc/a%2Bb -> 200 id "a+b" <- two accepted spellings, one id
256
+ GET /enc/sec%2fret -> 200 id "sec/ret"
257
+ GET /enc/sec%2Fret -> 200 id "sec/ret" <- hex-digit case, same id again
258
+ ```
259
+
260
+ **So a hook comparing a raw path string is still unsound for any id containing a
261
+ reserved character, and `req.params` is the sound comparison.** `req.params` is
262
+ decoded by express and is populated *before* your `auth` hook runs, by design —
263
+ compare that, and none of this applies to you. This module cannot close the
264
+ residual for you without 404ing encodings clients are entitled to send; see
265
+ [Consumer Contracts](#consumer-contracts).
207
266
 
208
267
  #### What this does not do
209
268
 
@@ -234,9 +293,22 @@ another variant of the bug above.
234
293
 
235
294
  #### Upgrading: behaviour changes
236
295
 
237
- All three controls change which requests match, so all three are
296
+ All four controls change which requests match, so all four are
238
297
  consumer-visible.
239
298
 
299
+ **A client that over-encodes an unreserved character in a path now gets 404.**
300
+ `GET /public/url-params/%61/b/c` returns **404** where it previously returned
301
+ 200 — measured on this repo's own fixture. `%61` is `a`, and
302
+ [RFC 3986 §2.3](https://www.rfc-editor.org/rfc/rfc3986#section-2.3) says a
303
+ generator must not encode it, so no correct client emits this. Some do anyway:
304
+ over-eager `encodeURIComponent` on an id that never needed it, a URL builder
305
+ that percent-encodes everything, or a client library normalizing in the wrong
306
+ direction. Reserved characters are **unaffected** — `%2f`, `%2B`, `%25` and
307
+ every non-ASCII byte still route, and so does anything in the query string.
308
+ Remediation is `REST_CANONICAL_ENCODING=false`, or fix the client. Like the two
309
+ below, the rejection is **indistinguishable from a route that was never
310
+ registered**, and this module emits no request logging.
311
+
240
312
  **Clients or forward proxies sending absolute-form request targets now get 404
241
313
  on every route mounted from a request class.** `GET http://host/admin HTTP/1.1`
242
314
  is a legal request target
@@ -289,16 +361,17 @@ no log line and no stack, so it looks like a deploy that dropped a route.
289
361
 
290
362
  #### Opting out
291
363
 
292
- Three separate flags, one per axis:
364
+ Four separate flags, one per axis:
293
365
 
294
366
  ```bash
295
367
  REST_CASE_SENSITIVE_ROUTES=false # restores case-insensitive matching (#47)
296
368
  REST_STRICT_ROUTES=false # restores trailing-slash tolerance (#50)
297
369
  REST_CANONICAL_ROUTES=false # restores non-canonical request targets (#54)
370
+ REST_CANONICAL_ENCODING=false # restores percent-encoded spellings (#56)
298
371
  ```
299
372
 
300
373
  or equivalently
301
- `restServer: { caseSensitiveRoutes: false, strictRoutes: false, canonicalRoutes: false }`.
374
+ `restServer: { caseSensitiveRoutes: false, strictRoutes: false, canonicalRoutes: false, canonicalEncoding: false }`.
302
375
 
303
376
  **They are deliberately separate keys, and none implies the others.** Slash
304
377
  tolerance is a legitimate need — a health-check URL you cannot change today is
@@ -315,6 +388,18 @@ slash *and* the absolute-form target — against any hook authorizing on
315
388
  `req.originalUrl`. It is env-only, so restoring service does not need a
316
389
  redeploy; use it to stop the bleeding, then fix the client and remove it.
317
390
 
391
+ **`REST_CANONICAL_ENCODING=false` re-opens the `#56` bypass, and it is the
392
+ widest of the four.** With it set, `GET /users/%73ecret` reaches your `/:id`
393
+ handler with `id === "secret"` while your hook compared `%73ecret` and did not
394
+ match — and it does that against a hook comparing `req.path` **or**
395
+ `req.originalUrl`, on every route class with a param segment. The other three
396
+ flags each re-open one field's worth of exposure; this one re-opens both. It is
397
+ also **independent** of `REST_CANONICAL_ROUTES`: if you have to set that one for
398
+ an absolute-form-emitting forward proxy, you keep this one on, which is exactly
399
+ why they are separate keys. If you must set it, the mitigation that costs you
400
+ nothing is to compare `req.params` in your hook rather than a raw path string —
401
+ `req.params` is decoded and was never exposed to this.
402
+
318
403
  **Each flag restores the corresponding vulnerability described above** — the
319
404
  URL-based authorization in your application becomes bypassable along that axis
320
405
  again. They exist as one-line remediations for an existing deployment, not as a
@@ -331,7 +416,8 @@ rather than left implied by the sections above.
331
416
  | you must | because | symptom if you don't |
332
417
  |---|---|---|
333
418
  | **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 matchunauthenticated 200. Tracked as [#56](https://github.com/abofs/stonyx-rest-server/issues/56) |
419
+ | **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 **reserved** characters must stay encodable, 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"`, and `sec%2fret` / `sec%2Fret` both give `sec/ret` | 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 |
420
+ | **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 |
335
421
  | **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
422
 
337
423
  `test/sample/requests/admin.ts` in this repo is the worked example of a
@@ -1,4 +1,5 @@
1
1
  const {
2
+ REST_CANONICAL_ENCODING,
2
3
  REST_CANONICAL_ROUTES,
3
4
  REST_CASE_SENSITIVE_ROUTES,
4
5
  REST_CORS_ORIGIN,
@@ -131,6 +132,74 @@ const config = {
131
132
  // keep a live assertion on this default.
132
133
  canonicalRoutes: REST_CANONICAL_ROUTES !== 'false',
133
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
+
134
203
  enableHealthCheck: REST_HEALTH_CHECK_DISABLE !== 'true',
135
204
  origin: REST_CORS_ORIGIN ?? '*',
136
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,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"}
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;CAsHtB"}
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, { shouldRejectTarget } 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';
@@ -97,6 +97,32 @@ export default class Request {
97
97
  // half of it was covered. Do not re-merge them.
98
98
  if (shouldRejectTarget(req))
99
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.5 (`/private/%66ailure`
123
+ // -> 404 rather than the hook's own status), on a class WITH a hook.
124
+ if (shouldRejectEncoding(req))
125
+ return next('router');
100
126
  // Run auth after route matching so request.params is populated
101
127
  if (this.auth) {
102
128
  const status = this.auth(req, getState(req));
@@ -1 +1 @@
1
- {"version":3,"file":"request.js","sourceRoot":"","sources":["../src/request.ts"],"names":[],"mappings":"AAAA,OAAO,OAA8G,MAAM,SAAS,CAAC;AACrI,OAAO,MAAM,MAAM,eAAe,CAAC;AACnC,OAAO,EAAE,SAAS,EAAE,MAAM,sBAAsB,CAAC;AACjD,OAAO,kBAAkB,EAAE,EAAE,kBAAkB,EAAE,MAAM,qBAAqB,CAAC;AAE7E,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,+DAA+D;QAC/D,uEAAuE;QACvE,2EAA2E;QAC3E,oEAAoE;QACpE,yEAAyE;QACzE,wEAAwE;QACxE,uEAAuE;QACvE,4DAA4D;QAC5D,EAAE;QACF,4EAA4E;QAC5E,yEAAyE;QACzE,uEAAuE;QACvE,2DAA2D;QAC3D,uEAAuE;QACvE,yEAAyE;QACzE,wEAAwE;QACxE,UAAU;QACV,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,eAAiK,CAAC,MAAM,CAAC,CAAC,KAAK,EAAE,KAAK,EAAE,GAAmB,EAAE,GAAoB,EAAE,IAAkB,EAAE,EAAE;oBACxP,sEAAsE;oBACtE,oEAAoE;oBACpE,kEAAkE;oBAClE,kEAAkE;oBAClE,EAAE;oBACF,qEAAqE;oBACrE,mEAAmE;oBACnE,sEAAsE;oBACtE,qEAAqE;oBACrE,EAAE;oBACF,qEAAqE;oBACrE,sEAAsE;oBACtE,sEAAsE;oBACtE,mCAAmC;oBACnC,mEAAmE;oBACnE,qEAAqE;oBACrE,kEAAkE;oBAClE,oEAAoE;oBACpE,gEAAgE;oBAChE,qEAAqE;oBACrE,oEAAoE;oBACpE,qDAAqD;oBACrD,qEAAqE;oBACrE,yDAAyD;oBACzD,6DAA6D;oBAC7D,iEAAiE;oBACjE,mEAAmE;oBACnE,oEAAoE;oBACpE,gEAAgE;oBAChE,0DAA0D;oBAC1D,gEAAgE;oBAChE,8DAA8D;oBAC9D,wBAAwB;oBACxB,EAAE;oBACF,kEAAkE;oBAClE,kEAAkE;oBAClE,gDAAgD;oBAChD,IAAI,kBAAkB,CAAC,GAAG,CAAC;wBAAE,OAAO,IAAI,CAAC,QAAQ,CAAC,CAAC;oBAEnD,+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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"}
@@ -77,13 +77,14 @@ import type { Express, Request as ExpressRequest } from 'express';
77
77
  * guarded handler running unauthenticated. `GET /public/` now returns 404, and
78
78
  * the integration AC asserts exactly that.
79
79
  *
80
- * Both guards are `!== false` for the same reason: these flags default to the
80
+ * All four guards in this file are `!== false` for the same reason: these flags
81
+ * default to the
81
82
  * 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
+ * config predates the key. All are also asserted at the unit tier for BOTH
83
84
  * 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.
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.
87
88
  */
88
89
  export default function applyRouteMatching(api: Express): void;
89
90
  /**
@@ -126,7 +127,7 @@ export default function applyRouteMatching(api: Express): void;
126
127
  * are two separate properties with two separate assertions (AC1.11 and AC1.6);
127
128
  * see src/request.ts.
128
129
  *
129
- * Guard polarity is `!== false`, matching both siblings, for the same measured
130
+ * Guard polarity is `!== false`, matching its three siblings, for the same measured
130
131
  * reason: the secure value is the TRUTHY one, so a plain truthy check fails
131
132
  * OPEN for any consumer whose shipped `restServer` block predates the key --
132
133
  * the state every existing consumer is in, and reachable in practice because
@@ -142,4 +143,80 @@ export default function applyRouteMatching(api: Express): void;
142
143
  * key present-and-`undefined` and key absent as an own property.
143
144
  */
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 reserved
213
+ * characters must remain encodable: `/enc/a+b` and `/enc/a%2Bb` both name the
214
+ * id `a+b`, and `/enc/sec%2fret` and `/enc/sec%2Fret` both name `sec/ret`. So a
215
+ * hook comparing a raw path string REMAINS UNSOUND for any id containing a
216
+ * reserved character, and `req.params` -- which express decodes, and which is
217
+ * populated before `auth()` runs -- is the sound idiom. That residual is the
218
+ * consumer's comparison to own; the module cannot close it without 404ing
219
+ * encodings clients are required to emit.
220
+ */
221
+ export declare function shouldRejectEncoding(req: ExpressRequest): boolean;
145
222
  //# 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,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"}
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;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA0EG;AACH,wBAAgB,oBAAoB,CAAC,GAAG,EAAE,cAAc,GAAG,OAAO,CAkBjE"}
@@ -77,13 +77,14 @@ import config from 'stonyx/config';
77
77
  * guarded handler running unauthenticated. `GET /public/` now returns 404, and
78
78
  * the integration AC asserts exactly that.
79
79
  *
80
- * Both guards are `!== false` for the same reason: these flags default to the
80
+ * All four guards in this file are `!== false` for the same reason: these flags
81
+ * default to the
81
82
  * 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
+ * config predates the key. All are also asserted at the unit tier for BOTH
83
84
  * 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.
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.
87
88
  */
88
89
  export default function applyRouteMatching(api) {
89
90
  if (config.restServer?.caseSensitiveRoutes !== false)
@@ -131,7 +132,7 @@ export default function applyRouteMatching(api) {
131
132
  * are two separate properties with two separate assertions (AC1.11 and AC1.6);
132
133
  * see src/request.ts.
133
134
  *
134
- * Guard polarity is `!== false`, matching both siblings, for the same measured
135
+ * Guard polarity is `!== false`, matching its three siblings, for the same measured
135
136
  * reason: the secure value is the TRUTHY one, so a plain truthy check fails
136
137
  * OPEN for any consumer whose shipped `restServer` block predates the key --
137
138
  * the state every existing consumer is in, and reachable in practice because
@@ -168,4 +169,120 @@ export function shouldRejectTarget(req) {
168
169
  const canonical = req.path === '/' && req.baseUrl ? req.baseUrl : req.baseUrl + req.path;
169
170
  return target !== canonical;
170
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 reserved
262
+ * characters must remain encodable: `/enc/a+b` and `/enc/a%2Bb` both name the
263
+ * id `a+b`, and `/enc/sec%2fret` and `/enc/sec%2Fret` both name `sec/ret`. So a
264
+ * hook comparing a raw path string REMAINS UNSOUND for any id containing a
265
+ * reserved character, and `req.params` -- which express decodes, and which is
266
+ * populated before `auth()` runs -- is the sound idiom. That residual is the
267
+ * consumer's comparison to own; the module cannot close it without 404ing
268
+ * encodings clients are required to emit.
269
+ */
270
+ export function shouldRejectEncoding(req) {
271
+ // `!== false`, not `=== false` and not a truthy check: the polarity is the
272
+ // load-bearing part and it should read identically to the three guards above.
273
+ const enforced = config.restServer?.canonicalEncoding !== false;
274
+ if (!enforced)
275
+ return false;
276
+ // Raw, unparsed, and only the query string removed -- by string split, for
277
+ // the same reason #54 gives: parsing would launder the exact string the
278
+ // consumer's hook is exposed to. The query is stripped because a query string
279
+ // is a legitimately variable part of a request target and may carry any
280
+ // encoding at all; `?name=%61` is a normal request and must not 404.
281
+ const target = req.originalUrl.split('?')[0];
282
+ for (const [, hex] of target.matchAll(PERCENT_TRIPLET)) {
283
+ if (UNRESERVED_OCTET.test(String.fromCharCode(parseInt(hex, 16))))
284
+ return true;
285
+ }
286
+ return false;
287
+ }
171
288
  //# 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;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;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"}
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;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;AAED,oEAAoE;AACpE,EAAE;AACF,8EAA8E;AAC9E,8EAA8E;AAC9E,uEAAuE;AACvE,6EAA6E;AAC7E,2EAA2E;AAC3E,kCAAkC;AAClC,MAAM,gBAAgB,GAAG,oBAAoB,CAAC;AAE9C,0EAA0E;AAC1E,EAAE;AACF,gFAAgF;AAChF,4EAA4E;AAC5E,0EAA0E;AAC1E,2CAA2C;AAC3C,EAAE;AACF,gFAAgF;AAChF,4EAA4E;AAC5E,2EAA2E;AAC3E,gFAAgF;AAChF,gFAAgF;AAChF,+DAA+D;AAC/D,MAAM,eAAe,GAAG,oBAAoB,CAAC;AAE7C;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA0EG;AACH,MAAM,UAAU,oBAAoB,CAAC,GAAmB;IACtD,2EAA2E;IAC3E,8EAA8E;IAC9E,MAAM,QAAQ,GAAG,MAAM,CAAC,UAAU,EAAE,iBAAiB,KAAK,KAAK,CAAC;IAChE,IAAI,CAAC,QAAQ;QAAE,OAAO,KAAK,CAAC;IAE5B,2EAA2E;IAC3E,wEAAwE;IACxE,8EAA8E;IAC9E,wEAAwE;IACxE,qEAAqE;IACrE,MAAM,MAAM,GAAG,GAAG,CAAC,WAAW,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,CAAC;IAE7C,KAAK,MAAM,CAAC,EAAE,GAAG,CAAC,IAAI,MAAM,CAAC,QAAQ,CAAC,eAAe,CAAC,EAAE,CAAC;QACvD,IAAI,gBAAgB,CAAC,IAAI,CAAC,MAAM,CAAC,YAAY,CAAC,QAAQ,CAAC,GAAI,EAAE,EAAE,CAAC,CAAC,CAAC;YAAE,OAAO,IAAI,CAAC;IAClF,CAAC;IAED,OAAO,KAAK,CAAC;AACf,CAAC"}
package/package.json CHANGED
@@ -4,7 +4,7 @@
4
4
  "stonyx-async",
5
5
  "stonyx-module"
6
6
  ],
7
- "version": "0.2.1-alpha.25",
7
+ "version": "0.2.1-alpha.26",
8
8
  "description": "Rest Server Module for Stonyx Framework",
9
9
  "repository": {
10
10
  "type": "git",