@stonyx/orm 0.3.2-alpha.62 → 0.3.2-alpha.64

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
@@ -350,19 +350,24 @@ export default class GlobalAccess {
350
350
  // `model` is the model this route was mounted for. It is assigned once, at
351
351
  // mount time, and no request can influence it — not a mount prefix, not a
352
352
  // query string, not a case-varied path, not an absolute-form request
353
- // target. Nothing below parses anything, so none of the five fail-open
354
- // variants recorded in the header is constructible against this predicate
355
- // any more; they are history, not rules to follow.
353
+ // target. Nothing below parses anything, so variants 1, 2, 4 and 5 are not
354
+ // constructible against this predicate any more they are history, not
355
+ // rules to follow. VARIANT 3 IS THE EXCEPTION AND THE CLAIM IS NARROWER
356
+ // THAN IT WAS: a matcher stricter than the router can still be stepped
357
+ // around, because the sub-path rule below is still a string comparison.
358
+ // Case is handled; percent-encoding is not — abofs/stonyx-orm#228.
356
359
  //
357
360
  // `operation` is destructured to name the whole contract at the point of
358
361
  // use. This sample's rules are per-model and per-sub-path rather than
359
362
  // per-verb, so it does not branch on it; the permission array at the bottom
360
363
  // is where the verb is answered.
361
364
 
362
- // FAIL CLOSED. `model` is absent for any caller that resolved this
363
- // predicate without supplying the context, and a request this function
364
- // cannot identify DENIES rather than falling through to the CRUD grant at
365
- // the bottom. An unidentifiable input must never be the permissive path.
365
+ // FAIL CLOSED ON ARGUMENT TWO. `model` is absent for any caller that
366
+ // resolved this predicate without supplying the context, and a request this
367
+ // function cannot identify DENIES rather than falling through to the CRUD
368
+ // grant at the bottom. An unidentifiable input must never be the permissive
369
+ // path. Argument ONE is guarded at its own read, below — this guard does
370
+ // not cover it.
366
371
  if (typeof model !== 'string' || model === '') return false;
367
372
 
368
373
  if (model === 'owner') {
@@ -370,14 +375,35 @@ export default class GlobalAccess {
370
375
  // distinct owner surfaces produce one identical context, so a rule that
371
376
  // depends on the SUB-PATH still needs argument one. `request.path` is
372
377
  // mount-relative and query-free, and it is the one read of the raw
373
- // request the README sanctions. Lower-cased because the router matched
374
- // case-insensitively, so a case-sensitive rule here would be stricter
375
- // than the router and could be stepped around. false → 403 for the whole
376
- // request.
378
+ // request the README sanctions. false 403 for the whole request.
377
379
  //
378
380
  // THIS DENY CANNOT BE EXPRESSED FROM THE CONTEXT ALONE. Migrating it away
379
381
  // does not remove a rule, it turns a deny into an ALLOW, silently.
380
- const path = String(request.path ?? '').toLowerCase();
382
+ //
383
+ // FAIL CLOSED ON ARGUMENT ONE TOO. The guard above covers the context;
384
+ // this one covers the request, and since #202 they are two different
385
+ // objects. A caller that resolves this predicate through the documented
386
+ // `Orm.instance.getAccess()` path and hand-assembles a request can supply
387
+ // a perfectly valid context with no usable `path` — and
388
+ // `String(request.path ?? '')` is then `''`, which matches no sub-path
389
+ // rule and falls straight through to the per-record filter below. That is
390
+ // a DENY becoming an ALLOW. An input this function cannot identify DENIES,
391
+ // whichever ARGUMENT it arrived on — which is also why the `?? ''` this
392
+ // file's header condemns does not appear below.
393
+ if (typeof request?.path !== 'string' || request.path === '') return false;
394
+
395
+ // Lower-cased because the router matched case-insensitively, so a
396
+ // case-sensitive rule here would be stricter than the router and could be
397
+ // stepped around.
398
+ //
399
+ // CASE-FOLDING ALONE IS NOT A SUFFICIENT NORMALISATION, and this line is
400
+ // not a recipe for one. Express sets `request.path` from the RAW pathname
401
+ // while the router DECODES `:id`, so `GET /owners/%61rchived` reaches this
402
+ // comparison as `/%61rchived`, walks past the deny, and is dispatched as
403
+ // the record `archived` — abofs/stonyx-orm#228. A matcher must normalise
404
+ // the way the router that dispatched the request does. Record ids are
405
+ // case-sensitive and must be compared at their real case.
406
+ const path = request.path.toLowerCase();
381
407
 
382
408
  if (path === '/archived' || path.startsWith('/archived/')) return false;
383
409
 
@@ -672,7 +698,12 @@ variant was found only after the previous one was fixed:
672
698
  | 5 | any match on `originalUrl` at all | HTTP/1.1 permits an **absolute-form** request-target. Express routes on `parseurl(req).pathname`, so the request dispatches normally — but `originalUrl` is the raw target. `GET http://anything.example/owners/angela` yields `originalUrl === 'http://anything.example/owners/angela'`, which has no `/owners` prefix. Measured: the record came back in full, `DELETE` succeeded, and it walked past a hard `return false` deny the same way. |
673
699
 
674
700
  **The fix is not a sixth rule, and it is not a better string to match.** It is
675
- to stop identifying the collection at all.
701
+ to stop identifying the collection at all. That is a statement about
702
+ **identifying the collection**, and it is not a statement about the sample as a
703
+ whole: the `/archived` sub-path rule *is* still a string match, and
704
+ [#228](https://github.com/abofs/stonyx-orm/issues/228) is a sixth spelling that
705
+ gets past it. Sub-path rules are the residue this fix does not cover, which is
706
+ why they must normalise the way the router does.
676
707
 
677
708
  An intermediate revision read **`request.baseUrl`** — the mount Express
678
709
  *actually matched*. That closed all five variants: it carries no query string
@@ -699,18 +730,30 @@ mount-relative and query-free, and it is for rules that distinguish **sub-paths*
699
730
  beneath the mount — as the `/archived` deny in the sample above does. The context
700
731
  names which model and which verb, **not which route**, so that deny *cannot be
701
732
  expressed from the context alone*, and a context-only rewrite would silently turn
702
- it into an allow. Lower-case it before comparing: the router matched
703
- case-insensitively, so a case-sensitive sub-path rule is stricter than the router
704
- that dispatched the request and can be stepped around. Record ids are
733
+ it into an allow.
734
+
735
+ **Normalise the way the router that dispatched the request does and
736
+ case-folding alone does not.** A matcher stricter than the router can be stepped
737
+ around, so the sample lower-cases before comparing (the router matched
738
+ case-insensitively). That closes the case gap and **it is not the whole rule**:
739
+ Express sets `request.path` from the **raw, undecoded** pathname while the router
740
+ **decodes** `:id`, so `GET /owners/%61rchived` reaches a `path === '/archived'`
741
+ comparison as `/%61rchived`, walks past the deny, and is dispatched as the record
742
+ `archived`. That gap is live in the sample above and is tracked as
743
+ [#228](https://github.com/abofs/stonyx-orm/issues/228) — **do not read the
744
+ `.toLowerCase()` there as a complete normalisation recipe.** Record ids are
705
745
  case-sensitive and must be compared at their real case.
706
746
 
707
- **Fail closed on anything you cannot identify.**
747
+ **Fail closed on anything you cannot identify — on *either* argument.**
708
748
  `String(request.originalUrl ?? '')` was once added here to stop a `TypeError`,
709
749
  and it traded fail-closed for fail-**open**: an empty string matched no
710
750
  collection, so `access()` fell through to the permission array and granted full
711
- CRUD. The same rule now applies to the context — the sample returns `false` for
712
- an absent `model` rather than falling through. An input you cannot identify must
713
- **deny**.
751
+ CRUD. The same rule applies to the context — the sample returns `false` for an
752
+ absent `model` rather than falling through. Since #202 the guard and the read can
753
+ sit on **different objects**, and a guard on argument two does not protect a read
754
+ of argument one: the sample therefore also returns `false` when `request.path` is
755
+ absent or is not a string, rather than letting `?? ''` fall through to the
756
+ per-record filter. An input you cannot identify must **deny**.
714
757
 
715
758
  ### Known limitations
716
759
 
@@ -153,7 +153,10 @@
153
153
  * `return false` deny the same way.
154
154
  *
155
155
  * The fix is not a sixth rule, and it is not a better string to match. It is to
156
- * stop identifying the collection at all: read `context.model`.
156
+ * stop identifying the collection at all: read `context.model`. That is a claim
157
+ * about IDENTIFYING THE COLLECTION, not about the sample as a whole -- the
158
+ * `/archived` SUB-PATH rule is still a string match, and abofs/stonyx-orm#228 is
159
+ * a sixth spelling that gets past it.
157
160
  *
158
161
  * An intermediate revision of the sample read `request.baseUrl` -- the mount
159
162
  * Express ACTUALLY MATCHED. That closed all five variants (no query string,
@@ -168,14 +171,23 @@
168
171
  * beneath the mount. The context names which model and which verb, NOT which
169
172
  * route, so the sample's `/archived` deny cannot be expressed from the context
170
173
  * alone and a context-ONLY rewrite would silently turn that deny into an allow.
171
- * Lower-case it before comparing -- the router matched case-insensitively -- and
172
- * compare record ids at their real case.
174
+ *
175
+ * NORMALISE THE WAY THE ROUTER DOES, AND CASE-FOLDING ALONE IS NOT THAT. The
176
+ * sample lower-cases before comparing, because a matcher stricter than the
177
+ * case-insensitive router can be stepped around. That closes the case gap only.
178
+ * Express sets `request.path` from the RAW, UNDECODED pathname while the router
179
+ * DECODES `:id`, so `GET /owners/%61rchived` reaches a `path === '/archived'`
180
+ * comparison as `/%61rchived` and walks past the deny. That gap is live in the
181
+ * sample and is tracked as abofs/stonyx-orm#228; the `.toLowerCase()` is not a
182
+ * complete normalisation recipe. Compare record ids at their real case.
173
183
  *
174
184
  * `?? ''` is not a defence. It converts an absent request target into an empty
175
185
  * string, which matches no collection, which falls through to the permission
176
186
  * array -- a total grant. An input you cannot identify must DENY, and that
177
- * applies to the context too: the sample returns `false` for an absent `model`
178
- * rather than falling through.
187
+ * applies to BOTH arguments: since #202 the guard and the read can sit on
188
+ * different objects, and a guard on argument two does not protect a read of
189
+ * argument one. The sample returns `false` for an absent `model` AND for an
190
+ * absent or non-string `request.path`, rather than falling through either way.
179
191
  *
180
192
  * THE REAL FIX IS abofs/stonyx-orm#202: `access()` should receive the model,
181
193
  * the operation and the record. Prefer the array shape (`['read']`) or `false`
@@ -153,7 +153,10 @@
153
153
  * `return false` deny the same way.
154
154
  *
155
155
  * The fix is not a sixth rule, and it is not a better string to match. It is to
156
- * stop identifying the collection at all: read `context.model`.
156
+ * stop identifying the collection at all: read `context.model`. That is a claim
157
+ * about IDENTIFYING THE COLLECTION, not about the sample as a whole -- the
158
+ * `/archived` SUB-PATH rule is still a string match, and abofs/stonyx-orm#228 is
159
+ * a sixth spelling that gets past it.
157
160
  *
158
161
  * An intermediate revision of the sample read `request.baseUrl` -- the mount
159
162
  * Express ACTUALLY MATCHED. That closed all five variants (no query string,
@@ -168,14 +171,23 @@
168
171
  * beneath the mount. The context names which model and which verb, NOT which
169
172
  * route, so the sample's `/archived` deny cannot be expressed from the context
170
173
  * alone and a context-ONLY rewrite would silently turn that deny into an allow.
171
- * Lower-case it before comparing -- the router matched case-insensitively -- and
172
- * compare record ids at their real case.
174
+ *
175
+ * NORMALISE THE WAY THE ROUTER DOES, AND CASE-FOLDING ALONE IS NOT THAT. The
176
+ * sample lower-cases before comparing, because a matcher stricter than the
177
+ * case-insensitive router can be stepped around. That closes the case gap only.
178
+ * Express sets `request.path` from the RAW, UNDECODED pathname while the router
179
+ * DECODES `:id`, so `GET /owners/%61rchived` reaches a `path === '/archived'`
180
+ * comparison as `/%61rchived` and walks past the deny. That gap is live in the
181
+ * sample and is tracked as abofs/stonyx-orm#228; the `.toLowerCase()` is not a
182
+ * complete normalisation recipe. Compare record ids at their real case.
173
183
  *
174
184
  * `?? ''` is not a defence. It converts an absent request target into an empty
175
185
  * string, which matches no collection, which falls through to the permission
176
186
  * array -- a total grant. An input you cannot identify must DENY, and that
177
- * applies to the context too: the sample returns `false` for an absent `model`
178
- * rather than falling through.
187
+ * applies to BOTH arguments: since #202 the guard and the read can sit on
188
+ * different objects, and a guard on argument two does not protect a read of
189
+ * argument one. The sample returns `false` for an absent `model` AND for an
190
+ * absent or non-string `request.path`, rather than falling through either way.
179
191
  *
180
192
  * THE REAL FIX IS abofs/stonyx-orm#202: `access()` should receive the model,
181
193
  * the operation and the record. Prefer the array shape (`['read']`) or `false`
package/package.json CHANGED
@@ -4,7 +4,7 @@
4
4
  "stonyx-async",
5
5
  "stonyx-module"
6
6
  ],
7
- "version": "0.3.2-alpha.62",
7
+ "version": "0.3.2-alpha.64",
8
8
  "description": "",
9
9
  "main": "dist/index.js",
10
10
  "type": "module",
@@ -153,7 +153,10 @@
153
153
  * `return false` deny the same way.
154
154
  *
155
155
  * The fix is not a sixth rule, and it is not a better string to match. It is to
156
- * stop identifying the collection at all: read `context.model`.
156
+ * stop identifying the collection at all: read `context.model`. That is a claim
157
+ * about IDENTIFYING THE COLLECTION, not about the sample as a whole -- the
158
+ * `/archived` SUB-PATH rule is still a string match, and abofs/stonyx-orm#228 is
159
+ * a sixth spelling that gets past it.
157
160
  *
158
161
  * An intermediate revision of the sample read `request.baseUrl` -- the mount
159
162
  * Express ACTUALLY MATCHED. That closed all five variants (no query string,
@@ -168,14 +171,23 @@
168
171
  * beneath the mount. The context names which model and which verb, NOT which
169
172
  * route, so the sample's `/archived` deny cannot be expressed from the context
170
173
  * alone and a context-ONLY rewrite would silently turn that deny into an allow.
171
- * Lower-case it before comparing -- the router matched case-insensitively -- and
172
- * compare record ids at their real case.
174
+ *
175
+ * NORMALISE THE WAY THE ROUTER DOES, AND CASE-FOLDING ALONE IS NOT THAT. The
176
+ * sample lower-cases before comparing, because a matcher stricter than the
177
+ * case-insensitive router can be stepped around. That closes the case gap only.
178
+ * Express sets `request.path` from the RAW, UNDECODED pathname while the router
179
+ * DECODES `:id`, so `GET /owners/%61rchived` reaches a `path === '/archived'`
180
+ * comparison as `/%61rchived` and walks past the deny. That gap is live in the
181
+ * sample and is tracked as abofs/stonyx-orm#228; the `.toLowerCase()` is not a
182
+ * complete normalisation recipe. Compare record ids at their real case.
173
183
  *
174
184
  * `?? ''` is not a defence. It converts an absent request target into an empty
175
185
  * string, which matches no collection, which falls through to the permission
176
186
  * array -- a total grant. An input you cannot identify must DENY, and that
177
- * applies to the context too: the sample returns `false` for an absent `model`
178
- * rather than falling through.
187
+ * applies to BOTH arguments: since #202 the guard and the read can sit on
188
+ * different objects, and a guard on argument two does not protect a read of
189
+ * argument one. The sample returns `false` for an absent `model` AND for an
190
+ * absent or non-string `request.path`, rather than falling through either way.
179
191
  *
180
192
  * THE REAL FIX IS abofs/stonyx-orm#202: `access()` should receive the model,
181
193
  * the operation and the record. Prefer the array shape (`['read']`) or `false`