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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -81,23 +81,30 @@ 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)). |
84
85
  | `trustProxy` | **Boolean** | `false` | Trust reverse proxy headers (e.g. `X-Forwarded-Proto`). Enable via `REST_TRUST_PROXY=true` when running behind a load balancer such as AWS ALB/ELB to ensure correct protocol detection. |
85
86
  | `statusMap` | **Object** | `{}` | Optional mapping of HTTP status codes to custom messages |
86
87
 
87
88
  ### Route Matching Strictness
88
89
 
89
- Routes match **case-sensitively and strictly by default**. Two settings, both
90
- on, both applied at both express construction sites:
90
+ Routes match **case-sensitively, strictly, and only at their canonical target
91
+ by default**. Three controls, all on:
91
92
 
92
- | axis | setting | example that no longer matches |
93
+ | axis | control | example that no longer matches |
93
94
  |---|---|---|
94
- | casing | `case sensitive routing` | `GET /users/Success` -> does not reach `/success` |
95
- | trailing slash | `strict routing` | `GET /users/success/` -> does not reach `/success` |
95
+ | casing | `case sensitive routing` (setting) | `GET /users/Success` -> does not reach `/success` |
96
+ | trailing slash | `strict routing` (setting) | `GET /users/success/` -> does not reach `/success` |
97
+ | canonical target | `canonicalRoutes` (per-request check) | `GET /users/` and `GET http://host/users` -> do not reach the mounted `/users` class |
98
+
99
+ The first two are express settings applied at both construction sites. The
100
+ third is **not a setting** — no express setting can express it — it is a
101
+ per-request comparison of the raw request target against the path express
102
+ matched, run ahead of your `auth` hook. See
103
+ [`src/route-matching.ts`](src/route-matching.ts).
96
104
 
97
105
  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.
106
+ [Upgrading](#upgrading-behaviour-changes) before you rely on that. One thing the
107
+ table does not say: "does not reach the handler" is not the same as "404".
101
108
 
102
109
  This is deliberate and security-relevant. Express matches both case-insensitively
103
110
  and slash-insensitively by default, which means any authorization written
@@ -128,11 +135,10 @@ be the exact registered spelling, in the exact registered casing, with no
128
135
  trailing slash. That closes [#47](https://github.com/abofs/stonyx-rest-server/issues/47)
129
136
  and [#50](https://github.com/abofs/stonyx-rest-server/issues/50).
130
137
 
131
- #### What this does not do
138
+ #### The canonical-target check (`canonicalRoutes`)
132
139
 
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:
140
+ **No express *setting* closes the trailing slash on a mount root**, and that has
141
+ not changed:
136
142
 
137
143
  ```
138
144
  GET /public -> req.path '/' req.originalUrl '/public'
@@ -144,16 +150,62 @@ unconditionally (`router@2.2.0`; the file-and-line citation is in
144
150
  [`docs/project-structure.md`](docs/project-structure.md) § *Strict routing
145
151
  (#50)*), so both forms reach the mounted route class and both arrive with
146
152
  `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.
153
+ for that hook there is no asymmetry to exploit and there is nothing for
154
+ `strict routing` to reject. **A hook comparing `req.originalUrl` sees two
155
+ different strings**, and that was a live authorization bypass.
156
+
157
+ `canonicalRoutes` closes it, as a per-request check rather than a setting
158
+ ([#54](https://github.com/abofs/stonyx-rest-server/issues/54)). Before your
159
+ `auth` hook runs, the raw request target is compared against the path express
160
+ matched, and a mismatch is rejected as a plain 404. It closes **two** vectors
161
+ against `req.originalUrl` — the field express does not normalize:
162
+
163
+ ```
164
+ before after
165
+ GET /admin 401 401 (hook fires, request blocked)
166
+ GET /admin/ 200 404 (hook never fired; now a miss)
167
+ GET http://host/admin 200 404 (absolute-form; hook never fired)
168
+ GET http://host/admin/settings 200 404 (absolute-form; every route from a request class)
169
+ ```
170
+
171
+ The second vector is the one to check first. [RFC 9112
172
+ §3.2.2](https://www.rfc-editor.org/rfc/rfc9112#section-3.2.2) permits an
173
+ **absolute-form** request target, express routes it, and it hands your hook the
174
+ whole URI — `req.originalUrl === "http://host/admin"`. Unlike the mount-root
175
+ slash, that affects **every route mounted from a request class**, not one edge.
176
+ The qualifier is a registration-site limit, not a special case for one URL: the
177
+ check runs inside the handlers this module registers, so anything you register
178
+ directly on `RestServer.instance.api` is outside it. In this repo that is
179
+ `/health` alone, and `GET http://host/health` still returns 200 — measured.
180
+
181
+ The target is compared **raw**. It is not parsed, normalized or resolved first:
182
+ normalizing it would launder exactly the string your hook is exposed to and
183
+ re-open the absolute-form vector by construction. Only the query string is
184
+ removed before comparison, so `GET /admin?x=1` still reaches your hook — **strip
185
+ the query yourself** if your hook compares `req.originalUrl` against a fixed
186
+ path, or it will not match (see [Consumer
187
+ Contracts](#consumer-contracts)). Rejections are indistinguishable from a
188
+ genuine miss by design; see [Upgrading](#upgrading-behaviour-changes).
189
+
190
+ Routes registered *with* a literal trailing slash are unaffected — their
191
+ canonical target carries the slash. So are index-mounted route classes and
192
+ query strings on canonical paths.
193
+
194
+ **Param routes: "unaffected" means "no regression", not "no residual".**
195
+ `/resource/:id` keeps matching exactly as it did. But percent-encoding is not
196
+ normalized on either side of the comparison — express decodes only
197
+ `req.params`, not `req.path` and not `req.originalUrl` — so `target` and
198
+ `canonical` are *both* the encoded string and this check passes the request
199
+ through by construction. Measured on a hook that authorizes on
200
+ `req.originalUrl` with the query correctly stripped, on a `/:id` route:
201
+ `GET /enc/secret` → 401, `GET /enc/%73ecret` → **200 with the guarded handler
202
+ running unauthenticated**, identically before and after this change. It is a
203
+ residual, not something `canonicalRoutes` introduced, and it is **wider** than
204
+ the two vectors above: `%73` defeats a `req.path` hook as well. Tracked as
205
+ [#56](https://github.com/abofs/stonyx-rest-server/issues/56) — until it ships,
206
+ do not read a param-segment route class as covered on this axis.
151
207
 
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.
208
+ #### What this does not do
157
209
 
158
210
  **It does not normalize path *parameter values*.** If your `auth()` hook rejects
159
211
  `params.id === 'restricted'`, then `GET /private/RESTRICTED` still reaches the
@@ -182,7 +234,36 @@ another variant of the bug above.
182
234
 
183
235
  #### Upgrading: behaviour changes
184
236
 
185
- Both settings change which requests match, so both are consumer-visible.
237
+ All three controls change which requests match, so all three are
238
+ consumer-visible.
239
+
240
+ **Clients or forward proxies sending absolute-form request targets now get 404
241
+ on every route mounted from a request class.** `GET http://host/admin HTTP/1.1`
242
+ is a legal request target
243
+ ([RFC 9112 §3.2.2](https://www.rfc-editor.org/rfc/rfc9112#section-3.2.2)), and
244
+ express used to route it. It is now rejected on every route this module
245
+ registers — for a client that emits it, this is a total outage, not a partial
246
+ one, and it is the largest blast radius in this change. The one carve-out is a
247
+ **registration site**, not a route: the check lives in the handlers mounted from
248
+ your request classes, so anything registered directly on
249
+ `RestServer.instance.api` never reaches it. `GET /health` is the only such route
250
+ in this repo, and `GET http://host/health` still returns 200 — so do not use it
251
+ to confirm the new rejection is live, and do not assume an authorized route you
252
+ registered on `api` yourself is covered. Reverse proxies in normal use (nginx,
253
+ HAProxy, AWS ALB) send origin-form and are unaffected; **forward** proxies and
254
+ hand-rolled HTTP clients are the exposure. Remediation is
255
+ `REST_CANONICAL_ROUTES=false`, or fix the client.
256
+
257
+ **`GET /route/` at a mounted route class's root now returns 404.** Previously
258
+ 200. If a client appends a trailing slash to a mount root, it stops working.
259
+
260
+ Both rejections are **indistinguishable from a route that was never
261
+ registered** — same status, same `Content-Type`, same headers — which is the
262
+ intended security property: a distinguishable rejection is an oracle telling an
263
+ attacker the route exists and was merely spelled wrong. Combined with this
264
+ module emitting **no request logging**, a broken client shows up as a bare
265
+ `Cannot GET …` with nothing at all on the server side. **Check this first if
266
+ routes start 404ing after upgrade.**
186
267
 
187
268
  **`GET /health/` now returns 404.** `GET /health` is unaffected. This is the
188
269
  change most likely to page someone, and it is an **availability** problem rather
@@ -208,21 +289,31 @@ no log line and no stack, so it looks like a deploy that dropped a route.
208
289
 
209
290
  #### Opting out
210
291
 
211
- Two separate flags, one per axis:
292
+ Three separate flags, one per axis:
212
293
 
213
294
  ```bash
214
295
  REST_CASE_SENSITIVE_ROUTES=false # restores case-insensitive matching (#47)
215
296
  REST_STRICT_ROUTES=false # restores trailing-slash tolerance (#50)
297
+ REST_CANONICAL_ROUTES=false # restores non-canonical request targets (#54)
216
298
  ```
217
299
 
218
- or equivalently `restServer: { caseSensitiveRoutes: false, strictRoutes: false }`.
300
+ or equivalently
301
+ `restServer: { caseSensitiveRoutes: false, strictRoutes: false, canonicalRoutes: false }`.
219
302
 
220
- **They are deliberately separate keys, and neither implies the other.** Slash
303
+ **They are deliberately separate keys, and none implies the others.** Slash
221
304
  tolerance is a legitimate need — a health-check URL you cannot change today is
222
305
  the common case. Casing tolerance almost never is. Folding them into one flag
223
306
  would force anyone who needs the first to accept the second, which is why a
224
307
  consumer who took the `#47` opt-out still has to set `REST_STRICT_ROUTES=false`
225
- separately to keep trailing slashes working.
308
+ separately to keep trailing slashes working. `REST_CANONICAL_ROUTES=false` is
309
+ separate for the same reason: a consumer who needs mount-root slash tolerance
310
+ should not have to re-open `#50`'s sub-path bypass to get it.
311
+
312
+ `REST_CANONICAL_ROUTES=false` is a **temporary remediation**, not a
313
+ configuration to run on. It re-opens both `#54` vectors at once — the mount-root
314
+ slash *and* the absolute-form target — against any hook authorizing on
315
+ `req.originalUrl`. It is env-only, so restoring service does not need a
316
+ redeploy; use it to stop the bleeding, then fix the client and remove it.
226
317
 
227
318
  **Each flag restores the corresponding vulnerability described above** — the
228
319
  URL-based authorization in your application becomes bypassable along that axis
@@ -230,6 +321,22 @@ again. They exist as one-line remediations for an existing deployment, not as a
230
321
  configuration to run on. Set the flag to restore service, then fix the client
231
322
  and remove the flag.
232
323
 
324
+ ### Consumer Contracts
325
+
326
+ Three things this module deliberately does **not** do for you. Each is a state
327
+ the framework permits, only your own discipline prevents, and that produces **no
328
+ error and no log** when that discipline lapses — so they are collected here
329
+ rather than left implied by the sections above.
330
+
331
+ | you must | because | symptom if you don't |
332
+ |---|---|---|
333
+ | **Strip the query string** before comparing `req.originalUrl` to a fixed path in an `auth` hook | `canonicalRoutes` compares the query-*stripped* target — a query string is a legitimately variable part of a request target, and rejecting on it would 404 every `?`-carrying request | `GET /admin?x=1` reaches your guarded handler **unauthenticated**, 200, no error, no log. Measured identical before and after `canonicalRoutes` |
334
+ | **Compare param values with the same decoding and casing you look them up with** | express decodes only `req.params`; `req.path` and `req.originalUrl` both stay percent-encoded, and param *values* are never case-normalized | `GET /orders/%73ecret` runs the handler with `id === "secret"` while your hook compared `%73ecret` and did not match — unauthenticated 200. Tracked as [#56](https://github.com/abofs/stonyx-rest-server/issues/56) |
335
+ | **Return `undefined` from `auth()` to mean "authorized"** — never `0` | any integer return is sent as the HTTP status | returning `0` sends a `0` status rather than allowing the request |
336
+
337
+ `test/sample/requests/admin.ts` in this repo is the worked example of a
338
+ correctly-written `originalUrl` hook for the first row.
339
+
233
340
  ### Running Behind a Load Balancer
234
341
 
235
342
  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,5 @@
1
1
  const {
2
+ REST_CANONICAL_ROUTES,
2
3
  REST_CASE_SENSITIVE_ROUTES,
3
4
  REST_CORS_ORIGIN,
4
5
  REST_CORS_METHODS,
@@ -22,13 +23,20 @@ const config = {
22
23
  // stubs `caseSensitiveRoutes` to `undefined` and src/route-matching.ts reads
23
24
  // `!== false`, so AC6 guards the source's read and not this default.
24
25
  // 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
26
+ // invert this line, and the suite reports 34 pass / 0 fail. A naive pin makes
26
27
  // an insecure published default completely invisible to a green suite --
27
28
  // quieter and weaker, which is the outcome pinning was supposed to prevent.
29
+ // Inverting this line ALONE, unpinned, reports 31 pass / 3 fail (#47's AC3,
30
+ // AC4 and AC5; AC6 green).
28
31
  //
29
32
  // 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
33
+ // (`REST_CASE_SENSITIVE_ROUTES=false pnpm test` => 31 pass / 3 fail), but it
34
+ // fails LOUDLY, so there is no false green.
35
+ //
36
+ // Every count in this block was re-measured against the 34-test suite at
37
+ // #54's head. PASS totals here move whenever a test is ADDED anywhere in the
38
+ // repo -- the FAIL counts are the load-bearing half. Re-measure, do not
39
+ // adjust by arithmetic, when this block next looks stale. Closing #43 for this key needs
32
40
  // the subprocess-based env isolation this repo does not yet have; any fix
33
41
  // must keep a live assertion on this default.
34
42
  caseSensitiveRoutes: REST_CASE_SENSITIVE_ROUTES !== 'false',
@@ -46,23 +54,83 @@ const config = {
46
54
  // DELIBERATELY NOT PINNED in test/config/environment.ts -- do not "fix" this
47
55
  // as part of abofs/stonyx-rest-server#43. Same trap as the key above, and now
48
56
  // measured for both: pin `strictRoutes: true` in test/config/environment.ts
49
- // AND invert this line to `=== 'true'`, and the suite reports 31 pass /
57
+ // AND invert this line to `=== 'true'`, and the suite reports 34 pass /
50
58
  // 0 fail. A naive pin makes an insecure published default completely
51
59
  // invisible to a green suite.
52
60
  //
53
- // Unpinned, inverting this line alone turns #50's AC1 and AC2 red (29/2).
61
+ // Unpinned, inverting this line alone turns #50's AC1 and AC2 red (32/2).
54
62
  // AC3 stays GREEN under that mutation, because AC3 sets `strictRoutes` on the
55
63
  // config object directly and so guards src/route-matching.ts's READ rather
56
64
  // than this default -- the two assertions cover different halves and neither
57
65
  // subsumes the other.
58
66
  //
59
67
  // 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
68
+ // (`REST_STRICT_ROUTES=false pnpm test` => 32 pass / 2 fail), but it fails
69
+ // LOUDLY, so there is no false green. All counts in this block re-measured
70
+ // against the 34-test suite at #54's head; the FAIL count is the load-bearing
71
+ // half, since PASS totals move whenever a test is added anywhere. Closing #43 for either key needs
62
72
  // subprocess-based env isolation this repo does not have; any fix must keep a
63
73
  // live assertion on this default.
64
74
  strictRoutes: REST_STRICT_ROUTES !== 'false',
65
75
 
76
+ // Secure by default, same polarity and same reasoning as the two keys above:
77
+ // a request whose RAW target is not the canonical path express matched is
78
+ // rejected with a plain 404 before the consumer's `auth` hook runs
79
+ // (abofs/stonyx-rest-server#54). This is NOT an express setting -- it is a
80
+ // per-request check in src/route-matching.ts (`shouldRejectTarget`), called
81
+ // from the handler closure in src/request.ts.
82
+ //
83
+ // BEHAVIOUR CHANGE for consumers upgrading, on TWO axes:
84
+ // 1. `GET /route/` at a mounted route class's ROOT now returns 404.
85
+ // 2. Clients or forward proxies emitting an ABSOLUTE-FORM request target
86
+ // (`GET http://host/admin HTTP/1.1`, RFC 9112 3.2.2) now receive 404 on
87
+ // every route registered through Request.registerCalls(). That is a
88
+ // REGISTRATION-SITE limit, not a carve-out for one URL: /health is
89
+ // registered directly on the parent app (src/main.ts) and still answers
90
+ // 200 to an absolute-form target -- and so would any route a consumer
91
+ // registers on the public RestServer.instance.api itself. This is the
92
+ // larger blast radius: for such a client it is a total outage, not a
93
+ // partial one. Reverse proxies in normal use
94
+ // (nginx, HAProxy, ALB) send origin-form and are unaffected.
95
+ // This module emits no request log, so both look like a dropped route.
96
+ //
97
+ // Opt out with REST_CANONICAL_ROUTES=false only as a temporary remediation --
98
+ // it RE-OPENS the bypass. Separate key from REST_STRICT_ROUTES and
99
+ // REST_CASE_SENSITIVE_ROUTES on purpose: coupling it to strictness would
100
+ // force a consumer who needs mount-root slash tolerance to also re-open #50's
101
+ // sub-path bypass.
102
+ //
103
+ // Note trustProxy below deliberately uses `=== 'true'` instead. That is not
104
+ // an inconsistency to "fix": its safe default is FALSY, so a truthy check
105
+ // already fails closed for it. The rule is "the guard must fail toward the
106
+ // safe value", not "all guards look alike".
107
+ //
108
+ // DELIBERATELY NOT PINNED in test/config/environment.ts -- do not "fix" this
109
+ // as part of abofs/stonyx-rest-server#43. Same trap as the two keys above,
110
+ // and now measured for all three: pin `canonicalRoutes: true` in
111
+ // test/config/environment.ts AND invert this line to `=== 'true'`, and the
112
+ // suite reports 34 pass / 0 fail while an insecure default ships. The pin is
113
+ // quieter AND weaker than no pin, which is the outcome pinning was supposed
114
+ // to prevent.
115
+ //
116
+ // Unpinned, inverting this line alone reports 32 pass / 2 fail: #54's
117
+ // integration AC1 and #50's AC2. AC2 (unit) stays GREEN under that mutation,
118
+ // because it sets `canonicalRoutes` on the config object directly and so
119
+ // guards src/route-matching.ts's READ rather than this default -- the two
120
+ // assertions cover different halves and neither subsumes the other.
121
+ // Conversely, weakening the READ to `=== true` reports 33 pass / 1 fail with
122
+ // AC2 as the only failure and AC1 fully green. All four counts in this block
123
+ // were re-measured on the #54 branch head after the AC1.11/AC1.12 assertions
124
+ // were added; the suite is 34 tests, and the FAIL count is the load-bearing
125
+ // half.
126
+ //
127
+ // The cost is that the suite is ambient-sensitive here
128
+ // (`REST_CANONICAL_ROUTES=false pnpm test` => 32 pass / 2 fail), but it fails
129
+ // LOUDLY, so there is no false green. Closing #43 for any of the three keys
130
+ // needs subprocess-based env isolation this repo does not have; any fix must
131
+ // keep a live assertion on this default.
132
+ canonicalRoutes: REST_CANONICAL_ROUTES !== 'false',
133
+
66
134
  enableHealthCheck: REST_HEALTH_CHECK_DISABLE !== 'true',
67
135
  origin: REST_CORS_ORIGIN ?? '*',
68
136
  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;CA4FtB"}
package/dist/request.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import express from 'express';
2
2
  import config from 'stonyx/config';
3
3
  import { makeArray } from '@stonyx/utils/object';
4
- import applyRouteMatching from './route-matching.js';
4
+ import applyRouteMatching, { shouldRejectTarget } from './route-matching.js';
5
5
  const METHODS = new Set(['get', 'post', 'put', 'delete', 'patch']);
6
6
  export default class Request {
7
7
  static stateProp = '__stonyxState';
@@ -28,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,46 @@ 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');
52
100
  // Run auth after route matching so request.params is populated
53
101
  if (this.auth) {
54
102
  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,20 +63,19 @@ 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.
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.
80
79
  *
81
80
  * Both guards are `!== false` for the same reason: these flags default to the
82
81
  * truthy direction, so a truthy check fails OPEN for a consumer whose shipped
@@ -87,4 +86,60 @@ import type { Express } from 'express';
87
86
  * fail-open guard leaves every integration assertion green.
88
87
  */
89
88
  export default function applyRouteMatching(api: Express): void;
89
+ /**
90
+ * Decides whether a request must be rejected because its RAW request target is
91
+ * not the canonical path express matched (abofs/stonyx-rest-server#54).
92
+ *
93
+ * Closes two live authorization bypasses against a consumer hook that
94
+ * authorizes on `req.originalUrl` -- the field express does NOT normalize:
95
+ *
96
+ * 1. mount-root trailing slash GET /admin/ -> was 200
97
+ * 2. absolute-form request target GET http://host/admin -> was 200
98
+ * (RFC 9112 3.2.2; hits EVERY route, not just the mount root)
99
+ *
100
+ * Both were measured reaching the guarded handler unauthenticated while
101
+ * `GET /admin` was denied 401.
102
+ *
103
+ * COMPARE THE RAW TARGET; DO NOT PARSE IT. Any implementation reaching for
104
+ * `new URL(req.originalUrl, base).pathname` to "get the path" re-opens vector 2
105
+ * BY CONSTRUCTION: parsing normalizes the exact string the consumer's hook is
106
+ * exposed to, so the check would compare a laundered value while the hook still
107
+ * sees the raw one. Measured: the narrow `endsWith('/')` form closes 1 and
108
+ * leaves 2 at 200.
109
+ *
110
+ * `req.baseUrl + req.path` is likewise NOT usable as the left-hand side. It is
111
+ * `/admin/` for BOTH spellings of vector 1 -- `originalUrl` is the only field
112
+ * that differs, which is precisely why the bypass exists. A check built on
113
+ * `baseUrl + path` cannot see its own defect.
114
+ *
115
+ * TIMING CONTRACT -- DELIBERATELY DIFFERENT FROM ITS TWO SIBLINGS. This lives
116
+ * beside `applyRouteMatching()` for the same "one place anchors it" reason, but
117
+ * deliberately OUTSIDE it: that function's contract is *apply express settings
118
+ * to an app*, it is called from two constructors, and this is neither a setting
119
+ * nor constructor-timed. `caseSensitiveRoutes`/`strictRoutes` are read once in a
120
+ * constructor and are silently ineffective if applied late; this flag is read
121
+ * PER REQUEST, inside the handler closure. There is no lazy-materialisation
122
+ * hazard here, so do not carry that constraint across.
123
+ *
124
+ * The caller must reject with `next('router')`, NOT `res.sendStatus(404)`, and
125
+ * must run this BEFORE `this.auth` as well as outside `if (this.auth)` -- those
126
+ * are two separate properties with two separate assertions (AC1.11 and AC1.6);
127
+ * see src/request.ts.
128
+ *
129
+ * Guard polarity is `!== false`, matching both siblings, for the same measured
130
+ * reason: the secure value is the TRUTHY one, so a plain truthy check fails
131
+ * OPEN for any consumer whose shipped `restServer` block predates the key --
132
+ * the state every existing consumer is in, and reachable in practice because
133
+ * the stonyx loader only merges a module's `config/environment.js` for modules
134
+ * in devDependencies. `trustProxy` deliberately differs (`=== 'true'`): its safe
135
+ * default is FALSY, so a truthy check already fails closed for it. The rule is
136
+ * "the guard must fail toward the safe value", not "all guards look alike" --
137
+ * preserve the asymmetry.
138
+ *
139
+ * The integration tier cannot see a fail-open guard here: with the shipped
140
+ * default `true`, `=== true` leaves every integration assertion green. Only
141
+ * `test/unit/request-test.ts` AC2 can, and it probes BOTH failure shapes --
142
+ * key present-and-`undefined` and key absent as an own property.
143
+ */
144
+ export declare function shouldRejectTarget(req: ExpressRequest): boolean;
90
145
  //# sourceMappingURL=route-matching.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"route-matching.d.ts","sourceRoot":"","sources":["../src/route-matching.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,SAAS,CAAC;AAGvC;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;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;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;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"}
@@ -63,20 +63,19 @@ 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.
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.
80
79
  *
81
80
  * Both guards are `!== false` for the same reason: these flags default to the
82
81
  * truthy direction, so a truthy check fails OPEN for a consumer whose shipped
@@ -92,4 +91,81 @@ export default function applyRouteMatching(api) {
92
91
  if (config.restServer?.strictRoutes !== false)
93
92
  api.set('strict routing', true);
94
93
  }
94
+ /**
95
+ * Decides whether a request must be rejected because its RAW request target is
96
+ * not the canonical path express matched (abofs/stonyx-rest-server#54).
97
+ *
98
+ * Closes two live authorization bypasses against a consumer hook that
99
+ * authorizes on `req.originalUrl` -- the field express does NOT normalize:
100
+ *
101
+ * 1. mount-root trailing slash GET /admin/ -> was 200
102
+ * 2. absolute-form request target GET http://host/admin -> was 200
103
+ * (RFC 9112 3.2.2; hits EVERY route, not just the mount root)
104
+ *
105
+ * Both were measured reaching the guarded handler unauthenticated while
106
+ * `GET /admin` was denied 401.
107
+ *
108
+ * COMPARE THE RAW TARGET; DO NOT PARSE IT. Any implementation reaching for
109
+ * `new URL(req.originalUrl, base).pathname` to "get the path" re-opens vector 2
110
+ * BY CONSTRUCTION: parsing normalizes the exact string the consumer's hook is
111
+ * exposed to, so the check would compare a laundered value while the hook still
112
+ * sees the raw one. Measured: the narrow `endsWith('/')` form closes 1 and
113
+ * leaves 2 at 200.
114
+ *
115
+ * `req.baseUrl + req.path` is likewise NOT usable as the left-hand side. It is
116
+ * `/admin/` for BOTH spellings of vector 1 -- `originalUrl` is the only field
117
+ * that differs, which is precisely why the bypass exists. A check built on
118
+ * `baseUrl + path` cannot see its own defect.
119
+ *
120
+ * TIMING CONTRACT -- DELIBERATELY DIFFERENT FROM ITS TWO SIBLINGS. This lives
121
+ * beside `applyRouteMatching()` for the same "one place anchors it" reason, but
122
+ * deliberately OUTSIDE it: that function's contract is *apply express settings
123
+ * to an app*, it is called from two constructors, and this is neither a setting
124
+ * nor constructor-timed. `caseSensitiveRoutes`/`strictRoutes` are read once in a
125
+ * constructor and are silently ineffective if applied late; this flag is read
126
+ * PER REQUEST, inside the handler closure. There is no lazy-materialisation
127
+ * hazard here, so do not carry that constraint across.
128
+ *
129
+ * The caller must reject with `next('router')`, NOT `res.sendStatus(404)`, and
130
+ * must run this BEFORE `this.auth` as well as outside `if (this.auth)` -- those
131
+ * are two separate properties with two separate assertions (AC1.11 and AC1.6);
132
+ * see src/request.ts.
133
+ *
134
+ * Guard polarity is `!== false`, matching both siblings, for the same measured
135
+ * reason: the secure value is the TRUTHY one, so a plain truthy check fails
136
+ * OPEN for any consumer whose shipped `restServer` block predates the key --
137
+ * the state every existing consumer is in, and reachable in practice because
138
+ * the stonyx loader only merges a module's `config/environment.js` for modules
139
+ * in devDependencies. `trustProxy` deliberately differs (`=== 'true'`): its safe
140
+ * default is FALSY, so a truthy check already fails closed for it. The rule is
141
+ * "the guard must fail toward the safe value", not "all guards look alike" --
142
+ * preserve the asymmetry.
143
+ *
144
+ * The integration tier cannot see a fail-open guard here: with the shipped
145
+ * default `true`, `=== true` leaves every integration assertion green. Only
146
+ * `test/unit/request-test.ts` AC2 can, and it probes BOTH failure shapes --
147
+ * key present-and-`undefined` and key absent as an own property.
148
+ */
149
+ export function shouldRejectTarget(req) {
150
+ // Written as `!== false` rather than `=== false` on purpose: the polarity is
151
+ // the load-bearing part and it should read identically to the two guards in
152
+ // applyRouteMatching() above.
153
+ const enforced = config.restServer?.canonicalRoutes !== false;
154
+ if (!enforced)
155
+ return false;
156
+ // Raw, unparsed. Only the query string is removed, by string split.
157
+ const target = req.originalUrl.split('?')[0];
158
+ // At a mount root express reports `req.path === '/'` while the canonical
159
+ // target is the bare mount segment, so the two are not simply concatenated.
160
+ //
161
+ // `&& req.baseUrl` is load-bearing and is NOT a redundant truthiness guard.
162
+ // A route class named `index` mounts at '/' (src/main.ts `mountRoute()`),
163
+ // and that is the one mount shape where `req.baseUrl` is ''. Without the
164
+ // conjunct, `GET /` compares the raw target '/' against a canonical of '' and
165
+ // the APPLICATION ROOT is rejected. Measured before it had a guard: shipped
166
+ // `GET /` -> 200, conjunct dropped -> 404, suite 34 pass / 0 fail BOTH ways.
167
+ // Killed now by AC1.12, against `test/sample/requests/index.ts`.
168
+ const canonical = req.path === '/' && req.baseUrl ? req.baseUrl : req.baseUrl + req.path;
169
+ return target !== canonical;
170
+ }
95
171
  //# sourceMappingURL=route-matching.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"route-matching.js","sourceRoot":"","sources":["../src/route-matching.ts"],"names":[],"mappings":"AACA,OAAO,MAAM,MAAM,eAAe,CAAC;AAEnC;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;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":"AACA,OAAO,MAAM,MAAM,eAAe,CAAC;AAEnC;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAqFG;AACH,MAAM,CAAC,OAAO,UAAU,kBAAkB,CAAC,GAAY;IACrD,IAAI,MAAM,CAAC,UAAU,EAAE,mBAAmB,KAAK,KAAK;QAAE,GAAG,CAAC,GAAG,CAAC,wBAAwB,EAAE,IAAI,CAAC,CAAC;IAC9F,IAAI,MAAM,CAAC,UAAU,EAAE,YAAY,KAAK,KAAK;QAAE,GAAG,CAAC,GAAG,CAAC,gBAAgB,EAAE,IAAI,CAAC,CAAC;AACjF,CAAC;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAsDG;AACH,MAAM,UAAU,kBAAkB,CAAC,GAAmB;IACpD,6EAA6E;IAC7E,4EAA4E;IAC5E,8BAA8B;IAC9B,MAAM,QAAQ,GAAG,MAAM,CAAC,UAAU,EAAE,eAAe,KAAK,KAAK,CAAC;IAC9D,IAAI,CAAC,QAAQ;QAAE,OAAO,KAAK,CAAC;IAE5B,oEAAoE;IACpE,MAAM,MAAM,GAAG,GAAG,CAAC,WAAW,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,CAAC;IAE7C,yEAAyE;IACzE,4EAA4E;IAC5E,EAAE;IACF,4EAA4E;IAC5E,0EAA0E;IAC1E,yEAAyE;IACzE,8EAA8E;IAC9E,4EAA4E;IAC5E,6EAA6E;IAC7E,iEAAiE;IACjE,MAAM,SAAS,GAAG,GAAG,CAAC,IAAI,KAAK,GAAG,IAAI,GAAG,CAAC,OAAO,CAAC,CAAC,CAAC,GAAG,CAAC,OAAO,CAAC,CAAC,CAAC,GAAG,CAAC,OAAO,GAAG,GAAG,CAAC,IAAI,CAAC;IAEzF,OAAO,MAAM,KAAK,SAAS,CAAC;AAC9B,CAAC"}
package/package.json CHANGED
@@ -4,7 +4,7 @@
4
4
  "stonyx-async",
5
5
  "stonyx-module"
6
6
  ],
7
- "version": "0.2.1-beta.91",
7
+ "version": "0.2.1-beta.92",
8
8
  "description": "Rest Server Module for Stonyx Framework",
9
9
  "repository": {
10
10
  "type": "git",