@stonyx/orm 0.3.2-alpha.48 → 0.3.2-alpha.49
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 +220 -9
- package/dist/orm-request.js +247 -42
- package/package.json +4 -4
- package/src/orm-request.ts +252 -39
package/README.md
CHANGED
|
@@ -309,29 +309,240 @@ import setupRestServer from '@stonyx/orm/setup-rest-server';
|
|
|
309
309
|
await setupRestServer('/', './access');
|
|
310
310
|
```
|
|
311
311
|
|
|
312
|
-
Access classes define models and provide custom filtering/authorization logic
|
|
312
|
+
Access classes define models and provide custom filtering/authorization logic.
|
|
313
|
+
|
|
314
|
+
> **The URL-matching in this example is a stopgap. Read
|
|
315
|
+
> [Matching the url](#matching-the-url) before copying it.** The same three-line
|
|
316
|
+
> example has failed **open** in four distinct ways during one review, each found
|
|
317
|
+
> only after the previous was fixed. The sample below closes all four; that is
|
|
318
|
+
> not the same as being safe. The real fix is
|
|
319
|
+
> [#202](https://github.com/abofs/stonyx-orm/issues/202) — `access()` should
|
|
320
|
+
> receive the model, the operation and the record, so there is no URL to parse.
|
|
313
321
|
|
|
314
322
|
```js
|
|
323
|
+
import config from 'stonyx/config';
|
|
324
|
+
|
|
325
|
+
// Build the mount prefix from the SAME value the ORM mounts under, and compare
|
|
326
|
+
// lower-cased. Both are load-bearing — see "Matching the url".
|
|
327
|
+
function collectionPrefix(name) {
|
|
328
|
+
const route = config.orm.restServer.route ?? '/';
|
|
329
|
+
const trimmed = String(route).replace(/^\/+|\/+$/g, '');
|
|
330
|
+
|
|
331
|
+
return `${trimmed === '' ? '' : `/${trimmed}`}/${name}`.toLowerCase();
|
|
332
|
+
}
|
|
333
|
+
|
|
315
334
|
export default class GlobalAccess {
|
|
316
335
|
models = ['owner', 'animal'];
|
|
317
336
|
|
|
318
337
|
access(request) {
|
|
338
|
+
// `originalUrl`, not `url` — `url` is mount-relative, so a prefix match
|
|
339
|
+
// against it is ALWAYS false. Query string stripped, because `originalUrl`
|
|
340
|
+
// carries it. Lower-cased, because the router matched case-insensitively
|
|
341
|
+
// and a matcher stricter than the router can be walked past. Every one of
|
|
342
|
+
// those three omissions fails OPEN.
|
|
343
|
+
const path = String(request.originalUrl ?? '').split('?')[0].toLowerCase();
|
|
344
|
+
const owners = collectionPrefix('owners');
|
|
345
|
+
|
|
319
346
|
// false → 403 for the whole request
|
|
320
|
-
if (
|
|
347
|
+
if (path.startsWith(`${owners}/archived`)) return false;
|
|
321
348
|
|
|
322
|
-
// A function is a per-record filter.
|
|
323
|
-
//
|
|
324
|
-
//
|
|
325
|
-
|
|
326
|
-
|
|
349
|
+
// A function is a per-record filter. Anchored on a `/` boundary so it
|
|
350
|
+
// cannot also match `/owners-archive`. Rejected records are 404 on record
|
|
351
|
+
// routes, 403 on POST.
|
|
352
|
+
if (path === owners || path.startsWith(`${owners}/`)) {
|
|
353
|
+
return record => record.id !== 'angela';
|
|
354
|
+
}
|
|
327
355
|
|
|
328
356
|
return ['read', 'create', 'update', 'delete'];
|
|
329
357
|
}
|
|
330
358
|
}
|
|
331
359
|
```
|
|
332
360
|
|
|
333
|
-
|
|
334
|
-
|
|
361
|
+
### Return values
|
|
362
|
+
|
|
363
|
+
| `access()` returns | Effect |
|
|
364
|
+
|---|---|
|
|
365
|
+
| `false` (or any falsy value) | `403` for the whole request |
|
|
366
|
+
| `true` | full access, no filter |
|
|
367
|
+
| `['read', 'create', 'update', 'delete']` | the listed operations only; anything else is `403` |
|
|
368
|
+
| `'read'` (a bare string) | **one** permission, equivalent to `['read']` |
|
|
369
|
+
| a function | a per-record filter — see below |
|
|
370
|
+
| anything else | `403` — unknown shapes fail **closed** |
|
|
371
|
+
|
|
372
|
+
A `throw` inside `access()` is a **denial**, not a 500.
|
|
373
|
+
|
|
374
|
+
### Filter functions
|
|
375
|
+
|
|
376
|
+
A function return value is a **per-record predicate**, and it is enforced on
|
|
377
|
+
every endpoint that is addressed to a record — not only on the collection:
|
|
378
|
+
|
|
379
|
+
| Endpoint | A record the predicate rejects |
|
|
380
|
+
|---|---|
|
|
381
|
+
| `GET /:models` | omitted from the collection |
|
|
382
|
+
| `GET /:models/:id` | `404` |
|
|
383
|
+
| `GET /:models/:id/{relationship}` | `404` — the **addressed** record is filtered, not the related one |
|
|
384
|
+
| `GET /:models/:id/relationships/{relationship}` | `404` — same |
|
|
385
|
+
| `PATCH /:models/:id` | `404`, no attribute is applied |
|
|
386
|
+
| `DELETE /:models/:id` | `404`, the record is not removed and no SQL `DELETE` is issued |
|
|
387
|
+
| `POST /:models` | `403`, and nothing is left in the store |
|
|
388
|
+
|
|
389
|
+
**Denied record-level requests return 404, not 403.** This is deliberate and it
|
|
390
|
+
is the property most easily "improved" away. 403 would confirm that the record
|
|
391
|
+
exists to a caller who is not allowed to know that, which turns the filter into
|
|
392
|
+
an existence oracle: `404` means "no such record", `403` means "there is one and
|
|
393
|
+
it is not yours". Every status on a record route must therefore be identical for
|
|
394
|
+
"filtered out" and "does not exist" — including `DELETE`, which is why deleting
|
|
395
|
+
a record that never existed also returns 404 rather than 204.
|
|
396
|
+
|
|
397
|
+
`POST` is the one exception and returns **403**, because 404 on a mounted
|
|
398
|
+
collection route is indistinguishable from "model not mounted" — a genuinely
|
|
399
|
+
different failure a developer needs to diagnose.
|
|
400
|
+
|
|
401
|
+
**A client-supplied `id` on `POST` is refused with `403` whenever a function
|
|
402
|
+
filter is in force.** This is the part that keeps `POST` from being an
|
|
403
|
+
enumeration oracle, and it is worth understanding rather than working around.
|
|
404
|
+
The duplicate-id check has to run before the filter, and it sees records the
|
|
405
|
+
filter hides, so the *status* of a `POST` otherwise leaks whether an id is
|
|
406
|
+
taken:
|
|
407
|
+
|
|
408
|
+
| `POST /animals` with a payload the caller may create | before | now |
|
|
409
|
+
|---|---|---|
|
|
410
|
+
| an id held by a record the filter **hides** | `403` | `403` |
|
|
411
|
+
| an id that is **free** | `200` | `403` |
|
|
412
|
+
| an id held by a record the caller **can see** | `409` | `403` |
|
|
413
|
+
|
|
414
|
+
Three outcomes, one request per id, the whole id space. Filtering only the
|
|
415
|
+
*collision* status narrows that to callers who cannot create a record they are
|
|
416
|
+
allowed to see; it does not close it. It cannot be closed while a caller both
|
|
417
|
+
chooses the id and learns whether the create succeeded — so under a filter the
|
|
418
|
+
caller does not choose the id. The refusal happens before any store lookup, so
|
|
419
|
+
neither the status nor the response time depends on whether the id exists.
|
|
420
|
+
|
|
421
|
+
Let the server assign the id and read it back from the response. Callers with no
|
|
422
|
+
function-style filter are unaffected: `409` on a duplicate id and `200` on a free
|
|
423
|
+
one both behave exactly as before.
|
|
424
|
+
|
|
425
|
+
### Matching the url
|
|
426
|
+
|
|
427
|
+
**This section describes a pattern the framework should not be asking you to
|
|
428
|
+
implement.** It has produced four separate fail-open defects, listed below,
|
|
429
|
+
each found only after the previous was fixed, and there is no reason to believe
|
|
430
|
+
the list is complete. [#202](https://github.com/abofs/stonyx-orm/issues/202)
|
|
431
|
+
replaces it. Until then, all four rules apply and each one, omitted, fails
|
|
432
|
+
**open**.
|
|
433
|
+
|
|
434
|
+
**1. Match `request.originalUrl`, never `request.url`.**
|
|
435
|
+
`RestServer.mountRoute` mounts each model as an Express **sub-app**, so by the
|
|
436
|
+
time `access()` runs the mount path has been stripped from `request.url`:
|
|
437
|
+
|
|
438
|
+
| request | `request.url` | `request.originalUrl` |
|
|
439
|
+
|---|---|---|
|
|
440
|
+
| `GET /owners` | `/` | `/owners` |
|
|
441
|
+
| `GET /owners/angela` | `/angela` | `/owners/angela` |
|
|
442
|
+
| `GET /owners/angela/pets` | `/angela/pets` | `/owners/angela/pets` |
|
|
443
|
+
|
|
444
|
+
`request.url.startsWith('/owners')` is therefore **always false**: the branch
|
|
445
|
+
never fires, `access()` falls through to whatever it returns last, and a filter
|
|
446
|
+
that looks correct enforces nothing on any surface.
|
|
447
|
+
|
|
448
|
+
**2. Strip the query string, and match the prefix rather than the exact url.**
|
|
449
|
+
The predicate has to be returned for record routes too, so
|
|
450
|
+
`url.endsWith('/owners')` leaves `/owners/angela` unguarded — and `originalUrl`
|
|
451
|
+
carries the query string, so a bare `=== '/owners'` misses
|
|
452
|
+
`/owners?filter[age]=30` and lets a filtered collection through unfiltered.
|
|
453
|
+
Anchoring on the path portion covers both without also matching
|
|
454
|
+
`/owners-archive`.
|
|
455
|
+
|
|
456
|
+
**3. Compare lower-cased.** `RestServer` mounts with a bare `express()`, whose
|
|
457
|
+
default is `caseSensitive: false`, while `originalUrl` preserves the caller's
|
|
458
|
+
case. A case-sensitive matcher is stricter than the router that dispatched the
|
|
459
|
+
request, so it can simply be stepped around:
|
|
460
|
+
|
|
461
|
+
```
|
|
462
|
+
GET /owners/angela -> 404 GET /OwNeRs/angela -> 200, angela in full
|
|
463
|
+
GET /owners -> filtered GET /OWNERS -> unfiltered
|
|
464
|
+
DELETE /animals/22 -> 404 DELETE /ANIMALS/22 -> 204, record destroyed
|
|
465
|
+
```
|
|
466
|
+
|
|
467
|
+
Lower-case the **path** only. Record ids are case-sensitive and must be compared
|
|
468
|
+
at their real case. The router-side fix is tracked as
|
|
469
|
+
[stonyx-rest-server#47](https://github.com/abofs/stonyx-rest-server/issues/47).
|
|
470
|
+
|
|
471
|
+
**4. Build the prefix from the configured mount route.** With
|
|
472
|
+
`ORM_REST_ROUTE=/api` the urls above become `/api/owners/...`, and a sample
|
|
473
|
+
hard-coded to `/owners` matches nothing — environment-specifically, which is
|
|
474
|
+
harder to notice than failing everywhere.
|
|
475
|
+
|
|
476
|
+
Note what this must *not* be. An earlier version of this document suggested
|
|
477
|
+
`` `${config.orm.restServer.route}owners` ``. For the default route that is
|
|
478
|
+
`/owners` and looks correct; for `ORM_REST_ROUTE=/api` it evaluates to
|
|
479
|
+
**`/apiowners`**, so a reader who followed the correction exactly still failed
|
|
480
|
+
open and believed they had handled it. Join on `/` and collapse the duplicate,
|
|
481
|
+
as `collectionPrefix()` above does.
|
|
482
|
+
|
|
483
|
+
### Known limitations
|
|
484
|
+
|
|
485
|
+
- **Authorization by URL matching is a consumer-side reconstruction of
|
|
486
|
+
information the framework already holds.** `access()` receives a transport
|
|
487
|
+
artifact and is asked to re-derive, correctly and defensively, which model,
|
|
488
|
+
which operation and which record the request addresses. The four rules above
|
|
489
|
+
are the four ways that reconstruction has been observed to fail open so far.
|
|
490
|
+
Tracked as [#202](https://github.com/abofs/stonyx-orm/issues/202).
|
|
491
|
+
- **Related and included records are not filtered.** The predicate is evaluated
|
|
492
|
+
against the record the route is *addressed to*. `GET /animals/1/owner`,
|
|
493
|
+
`GET /animals/1/relationships/owner` and `?include=owner` all serialize the
|
|
494
|
+
related record without resolving that model's own access class, so a filter on
|
|
495
|
+
`/owners` does not hide an owner reached through `/animals`. Tracked as
|
|
496
|
+
[#196](https://github.com/abofs/stonyx-orm/issues/196), which covers
|
|
497
|
+
`include=`, related-resource routes and relationship-linkage routes.
|
|
498
|
+
- **A before-hook that returns a value short-circuits the request.** On write
|
|
499
|
+
operations addressed to a record the filter is consulted first, so a hook
|
|
500
|
+
cannot answer for a record the caller may not see. On reads it is not, so a
|
|
501
|
+
`beforeHook('get', ...)` read-through cache can answer past the filter.
|
|
502
|
+
- **`beforeHook('create', ...)` still runs for a `POST` that is denied.** For
|
|
503
|
+
`create` there is no record to test until the handler has built one, so the
|
|
504
|
+
denial is not knowable in time. Every *after*-hook is gated, and every
|
|
505
|
+
before-hook on `update` and `delete` is gated; before-`create` is the one
|
|
506
|
+
exception. A before-`create` hook must not assume the create will succeed.
|
|
507
|
+
- **A caller can still learn that a collection *has* a per-record filter**, by
|
|
508
|
+
observing `403` rather than `409`/`200` for an id-bearing `POST`. That
|
|
509
|
+
discloses a configuration fact, not the existence of any record.
|
|
510
|
+
- **Enforcement is post-fetch.** The record is loaded and then tested, which
|
|
511
|
+
leaves a small timing difference between a hidden record and one that never
|
|
512
|
+
existed. Tracked as [#197](https://github.com/abofs/stonyx-orm/issues/197).
|
|
513
|
+
|
|
514
|
+
### Breaking changes in 0.4.0
|
|
515
|
+
|
|
516
|
+
There is no changelog or release-notes channel yet
|
|
517
|
+
([stonyx-workflows#17](https://github.com/abofs/stonyx-workflows/issues/17)), so
|
|
518
|
+
they are recorded here.
|
|
519
|
+
|
|
520
|
+
1. **`DELETE /{collection}/{id}` on a record that does not exist returns `404`
|
|
521
|
+
instead of `204`.** This affects **every** consumer issuing a DELETE against
|
|
522
|
+
any mounted collection, whether or not an access filter is configured, and
|
|
523
|
+
`models: '*'` mounts every model by default. It is not optional: if a denied
|
|
524
|
+
delete returned 404 while a missing one returned 204, the pair would be a
|
|
525
|
+
perfect existence oracle and the filter would be worthless.
|
|
526
|
+
2. **After-hooks no longer fire for a write that failed** — denied, missing,
|
|
527
|
+
`400` or `409`. Previously `afterHook('delete', ...)` ran with a populated
|
|
528
|
+
`context.recordId` on a request that deleted nothing, so a consumer cascade
|
|
529
|
+
destroyed children behind a 404.
|
|
530
|
+
3. **`POST` with a client-supplied `id` returns `403` when a function-style
|
|
531
|
+
`access` filter is in force**, whatever the payload and whether or not the id
|
|
532
|
+
exists. Only affects function-style `access` users. See
|
|
533
|
+
[Filter functions](#filter-functions) for why, and let the server assign the
|
|
534
|
+
id instead. `409`-on-duplicate is unchanged for everyone else.
|
|
535
|
+
4. **Function-style `access` is now enforced on all seven surfaces.** Records
|
|
536
|
+
previously reachable by id despite being filtered from the collection now
|
|
537
|
+
return 404. Only affects function-style `access` users, for whom the old
|
|
538
|
+
behaviour was the bypass.
|
|
539
|
+
5. **A predicate that throws is treated as a denial** rather than propagating to
|
|
540
|
+
Express's default 500 handler. So is an `access()` that throws.
|
|
541
|
+
6. **`access()` returning a bare string is one permission, not full access.**
|
|
542
|
+
`AccessMethod` declares `string` legal, and it previously fell through every
|
|
543
|
+
branch and granted all four operations — `return 'read'` allowed `DELETE`.
|
|
544
|
+
It is now equivalent to `['read']`. Any other unrecognised shape (an object,
|
|
545
|
+
a number) now returns `403` rather than granting full access.
|
|
335
546
|
|
|
336
547
|
### Include Parameter (Sideloading Relationships)
|
|
337
548
|
|
package/dist/orm-request.js
CHANGED
|
@@ -56,6 +56,30 @@ function getId(params) {
|
|
|
56
56
|
return id;
|
|
57
57
|
return parseInt(id);
|
|
58
58
|
}
|
|
59
|
+
/**
|
|
60
|
+
* Normalise a caller-supplied BODY id to the key the store will hold it under.
|
|
61
|
+
*
|
|
62
|
+
* `getId()` above is params-shaped: it takes `{ id?: string }` off the URL,
|
|
63
|
+
* where the value is always a string and a falsy one means "no id". A JSON body
|
|
64
|
+
* id is neither -- it can arrive as a number, and `0` is a legitimate id that
|
|
65
|
+
* `getId()` would flatten to `''`.
|
|
66
|
+
*
|
|
67
|
+
* WHY THIS EXISTS AT ALL. createHandler used to look the collision up with the
|
|
68
|
+
* RAW body value while every other surface normalised through `getId()`. The
|
|
69
|
+
* store is a Map keyed by the coerced value, so `store.find(model, '21')` misses
|
|
70
|
+
* the entry held under `21` and the duplicate check is skipped by typing the id
|
|
71
|
+
* as a string. On `dev` that silently overwrote the colliding record and
|
|
72
|
+
* answered 200; combined with the denied-create rollback added for #190 it
|
|
73
|
+
* became an unauthenticated DELETE of any id. Normalising here is half of that
|
|
74
|
+
* fix -- see the rollback in createHandler for the other half.
|
|
75
|
+
*/
|
|
76
|
+
function normalizeBodyId(id) {
|
|
77
|
+
if (typeof id === 'number')
|
|
78
|
+
return id;
|
|
79
|
+
if (typeof id !== 'string' || id.trim() === '')
|
|
80
|
+
return id;
|
|
81
|
+
return isNaN(id) ? id : parseInt(id, 10);
|
|
82
|
+
}
|
|
59
83
|
function buildResponse(data, includeParam, recordOrRecords, options = {}) {
|
|
60
84
|
const { links, baseUrl } = options;
|
|
61
85
|
const response = { data };
|
|
@@ -202,7 +226,17 @@ function createFilterPredicate(filters) {
|
|
|
202
226
|
function isDenied(filter, record) {
|
|
203
227
|
if (typeof filter !== 'function')
|
|
204
228
|
return false;
|
|
205
|
-
|
|
229
|
+
// A predicate that throws is treated as a denial. Unguarded, a throw escapes
|
|
230
|
+
// to express's default handler, which answers 500 (with a stack trace outside
|
|
231
|
+
// NODE_ENV=production) while a missing id still answers 404 -- so a
|
|
232
|
+
// record-dependent throw re-separates "hidden" from "does not exist" and
|
|
233
|
+
// hands back the oracle this whole change exists to close.
|
|
234
|
+
try {
|
|
235
|
+
return !filter(record);
|
|
236
|
+
}
|
|
237
|
+
catch {
|
|
238
|
+
return true;
|
|
239
|
+
}
|
|
206
240
|
}
|
|
207
241
|
export default class OrmRequest extends Request {
|
|
208
242
|
model;
|
|
@@ -256,9 +290,48 @@ export default class OrmRequest extends Request {
|
|
|
256
290
|
return 400; // Bad request
|
|
257
291
|
const fieldsMap = parseFields(query);
|
|
258
292
|
const modelFields = fieldsMap.get(pluralizedModel) || fieldsMap.get(model);
|
|
259
|
-
//
|
|
260
|
-
|
|
261
|
-
|
|
293
|
+
// GATE 0 -- the POST existence oracle.
|
|
294
|
+
//
|
|
295
|
+
// The duplicate check runs before the filter and `store.find` sees hidden
|
|
296
|
+
// records, so POST leaks existence through its STATUS. A previous revision
|
|
297
|
+
// filtered the collision status (403 when the colliding record is denied,
|
|
298
|
+
// 409 when it is visible) and that is NOT sufficient, because the status
|
|
299
|
+
// of a create is a third outcome. With a payload the caller is permitted
|
|
300
|
+
// to create -- the normative case for a per-tenant filter, and the case an
|
|
301
|
+
// attacker picks -- all three are distinguishable in ONE request per id:
|
|
302
|
+
//
|
|
303
|
+
// POST /animals {id:22, owner:'gina'} -> 403 a HIDDEN record has this id
|
|
304
|
+
// POST /animals {id:9500, owner:'gina'} -> 200 this id is FREE
|
|
305
|
+
// POST /animals {id:8, owner:'gina'} -> 409 a VISIBLE record has this id
|
|
306
|
+
//
|
|
307
|
+
// Filtering only the collision status narrows that to callers who cannot
|
|
308
|
+
// create a record they are allowed to see. It does not close it.
|
|
309
|
+
//
|
|
310
|
+
// It cannot be closed while a caller both chooses the id and learns
|
|
311
|
+
// whether the create succeeded: a successful create must answer
|
|
312
|
+
// differently from a refused one. So when a per-record filter is in force
|
|
313
|
+
// the caller does not get to choose the id at all. The refusal is
|
|
314
|
+
// UNCONDITIONAL and happens BEFORE any store lookup, so no status, and no
|
|
315
|
+
// lookup cost, can depend on whether that id exists. 403 -- the same
|
|
316
|
+
// status as a denied create -- so the two cannot be separated either.
|
|
317
|
+
//
|
|
318
|
+
// Scoped to function-style `access` because that is exactly the population
|
|
319
|
+
// the oracle exists for: with no per-record filter there are no hidden
|
|
320
|
+
// records, and 409 discloses nothing GET /:id does not already.
|
|
321
|
+
//
|
|
322
|
+
// RESIDUAL, stated rather than implied: a caller can still learn that a
|
|
323
|
+
// collection HAS a per-record filter (403 rather than 409/200 for an
|
|
324
|
+
// id-bearing POST). That discloses a configuration fact, not a record.
|
|
325
|
+
// See README `### Known limitations`.
|
|
326
|
+
if (id !== undefined) {
|
|
327
|
+
if (typeof filter === 'function')
|
|
328
|
+
return 403; // Forbidden
|
|
329
|
+
// `normalizeBodyId`, not the raw value: a string-typed id misses the
|
|
330
|
+
// store's numeric key, which skipped this check entirely.
|
|
331
|
+
const existing = await store.find(model, normalizeBodyId(id));
|
|
332
|
+
if (existing)
|
|
333
|
+
return 409; // Conflict
|
|
334
|
+
}
|
|
262
335
|
const { id: _ignoredId, ...sanitizedAttributes } = attributes || {};
|
|
263
336
|
// Extract relationship IDs from JSON:API relationships object
|
|
264
337
|
if (rels) {
|
|
@@ -270,10 +343,19 @@ export default class OrmRequest extends Request {
|
|
|
270
343
|
}
|
|
271
344
|
}
|
|
272
345
|
const recordAttributes = id !== undefined ? { id, ...sanitizedAttributes } : sanitizedAttributes;
|
|
346
|
+
// Slot count BEFORE the write. `createRecord` writes to the store before
|
|
347
|
+
// the predicate can run, and the rollback below must be able to prove the
|
|
348
|
+
// slot it removes is one THIS REQUEST created. Identity alone cannot
|
|
349
|
+
// prove it: when `assignRecordId` lands on an occupied id, `createRecord`
|
|
350
|
+
// mutates the existing OrmRecord IN PLACE, so `store.get(...) === record`
|
|
351
|
+
// is true for a record the request did not create. The map's size is the
|
|
352
|
+
// only O(1) signal that distinguishes an insert from an overwrite.
|
|
353
|
+
const slotsBefore = store.get(model)?.size ?? 0;
|
|
273
354
|
const created = createRecord(model, recordAttributes, { serialize: false, _skipAutoPersist: true });
|
|
274
355
|
const record = isOrmRecord(created) ? created : null;
|
|
275
356
|
if (!record)
|
|
276
357
|
return 500;
|
|
358
|
+
const createdNewSlot = (store.get(model)?.size ?? 0) > slotsBefore;
|
|
277
359
|
// 403 here, NOT 404. The oracle argument does not apply to create: there
|
|
278
360
|
// is no pre-existing record whose existence could leak, the caller
|
|
279
361
|
// supplied the attributes, and 404 on a mounted collection route is
|
|
@@ -284,7 +366,26 @@ export default class OrmRequest extends Request {
|
|
|
284
366
|
// the predicate can run, so returning 403 alone would leave the record
|
|
285
367
|
// behind: a worse bug than the bypass being fixed.
|
|
286
368
|
if (isDenied(filter, record)) {
|
|
287
|
-
store.remove(model, record.id
|
|
369
|
+
// ROLL BACK BY IDENTITY, NEVER BY ID. `store.remove(model, record.id)`
|
|
370
|
+
// on its own is a write primitive keyed by a value the caller may have
|
|
371
|
+
// supplied: with the raw-id collision bypass above, a denied
|
|
372
|
+
// `POST {"id":"21"}` answered 403 and DELETED hidden record 21 -- an
|
|
373
|
+
// unauthenticated deletion primitive across the whole id space, created
|
|
374
|
+
// by adding a rollback to a lookup that could be skipped.
|
|
375
|
+
//
|
|
376
|
+
// Both conditions are required and neither implies the other:
|
|
377
|
+
// createdNewSlot -- the store grew, so this request inserted rather
|
|
378
|
+
// than overwrote. Guards `assignRecordId` picking an
|
|
379
|
+
// id that is already taken (it returns
|
|
380
|
+
// last-INSERTED + 1, not max + 1, so a store whose
|
|
381
|
+
// insertion order is not ascending collides) -- see
|
|
382
|
+
// abofs/stonyx-orm#203.
|
|
383
|
+
// identity -- the slot still holds the object we just created,
|
|
384
|
+
// so nothing between createRecord and here replaced
|
|
385
|
+
// it.
|
|
386
|
+
if (createdNewSlot && store.get(model, record.id) === record) {
|
|
387
|
+
store.remove(model, record.id, { _skipAutoPersist: true });
|
|
388
|
+
}
|
|
288
389
|
return 403;
|
|
289
390
|
}
|
|
290
391
|
return { data: record.toJSON?.({ fields: modelFields }) };
|
|
@@ -296,6 +397,13 @@ export default class OrmRequest extends Request {
|
|
|
296
397
|
// Checked BEFORE any attribute is applied. 404 rather than 403 for the
|
|
297
398
|
// same reason as GET /:id -- 403 would disclose both that the record
|
|
298
399
|
// exists and that this caller specifically is excluded.
|
|
400
|
+
//
|
|
401
|
+
// NOT redundant behind GATE 1, and not defence in depth either: GATE 1's
|
|
402
|
+
// verdict is computed BEFORE the before-hook loop runs, and a before-hook
|
|
403
|
+
// is a published extension point that can change the answer -- by
|
|
404
|
+
// mutating the record, or against a predicate that closes over
|
|
405
|
+
// per-request state. This is the only re-evaluation after that window.
|
|
406
|
+
// Pinned by assertion 32; deleting it turns a 404 into an applied update.
|
|
299
407
|
if (isDenied(filter, found))
|
|
300
408
|
return 404;
|
|
301
409
|
const record = found;
|
|
@@ -339,6 +447,9 @@ export default class OrmRequest extends Request {
|
|
|
339
447
|
// reports success for a request that changed nothing.
|
|
340
448
|
if (!record)
|
|
341
449
|
return 404;
|
|
450
|
+
// Re-evaluated after the before-hook loop, exactly as in updateHandler --
|
|
451
|
+
// GATE 1 decided before the hooks ran. Pinned by assertion 33; deleting it
|
|
452
|
+
// turns a 404 into a destroyed record.
|
|
342
453
|
if (isDenied(filter, record))
|
|
343
454
|
return 404;
|
|
344
455
|
store.remove(model, getId(params), { _skipAutoPersist: true });
|
|
@@ -366,9 +477,36 @@ export default class OrmRequest extends Request {
|
|
|
366
477
|
};
|
|
367
478
|
}
|
|
368
479
|
}
|
|
369
|
-
// Wraps a handler with before/after hook execution
|
|
480
|
+
// Wraps a handler with before/after hook execution.
|
|
481
|
+
//
|
|
482
|
+
// ===========================================================================
|
|
483
|
+
// TWO AUTHORIZATION GATES, AND EVERY EXECUTOR SITS BEHIND ONE OF THEM (#190)
|
|
484
|
+
//
|
|
485
|
+
// The defect this function was fixed for is NOT "a delete persists past a
|
|
486
|
+
// 404". It is that _withHooks has SEVERAL executors downstream of the
|
|
487
|
+
// handler, and originally the handler's response gated none of them. Three
|
|
488
|
+
// exist today:
|
|
489
|
+
//
|
|
490
|
+
// 1. sqlDb.persist -- issues real SQL against the backing store
|
|
491
|
+
// 2. the after-hook pipeline -- the PUBLISHED consumer extension point;
|
|
492
|
+
// a cascade delete, a webhook, a search-index
|
|
493
|
+
// purge. `context.recordId` and
|
|
494
|
+
// `context.oldState` are populated for it.
|
|
495
|
+
// 3. Orm.db.save() -- a full serialize-and-write of the store
|
|
496
|
+
//
|
|
497
|
+
// Gating them one at a time is how this keeps regressing, so the rule is:
|
|
498
|
+
// compute denial ONCE at each point where it becomes knowable, and keep every
|
|
499
|
+
// executor downstream of a gate. If you add a fourth executor to this
|
|
500
|
+
// function, it goes below GATE 2 or it is a security bug.
|
|
501
|
+
//
|
|
502
|
+
// GATE 1 (pre-handler) is required because before-hooks and `context.oldState`
|
|
503
|
+
// run/are built BEFORE the handler can consult the filter. Without it a denied
|
|
504
|
+
// DELETE still handed the hidden record's full contents to consumer code.
|
|
505
|
+
// GATE 2 (post-handler) covers everything the handler's status can reach.
|
|
506
|
+
// ===========================================================================
|
|
370
507
|
_withHooks(operation, handler) {
|
|
371
508
|
return async (request, state) => {
|
|
509
|
+
const { filter } = (state || {});
|
|
372
510
|
// Build context object for hooks
|
|
373
511
|
const context = {
|
|
374
512
|
model: this.model,
|
|
@@ -382,6 +520,26 @@ export default class OrmRequest extends Request {
|
|
|
382
520
|
// Capture old state for operations that modify data
|
|
383
521
|
if (operation === 'update' || operation === 'delete') {
|
|
384
522
|
const existingRecord = await store.find(this.model, getId(request.params));
|
|
523
|
+
// GATE 1 -- pre-handler. This record fetch already happened for
|
|
524
|
+
// oldState, so the check is free.
|
|
525
|
+
//
|
|
526
|
+
// Returning here rather than letting updateHandler/deleteHandler
|
|
527
|
+
// produce the same 404 is the point: everything between here and there
|
|
528
|
+
// is an executor the caller is not authorized to reach.
|
|
529
|
+
// - context.oldState is a deep copy of the HIDDEN RECORD'S CONTENTS.
|
|
530
|
+
// Building it and handing it to a before-hook discloses exactly what
|
|
531
|
+
// the filter exists to hide.
|
|
532
|
+
// - context.recordId is populated for delete BEFORE the handler runs,
|
|
533
|
+
// which is the same shape as the sqlDb landmine one layer up:
|
|
534
|
+
// `afterHook('delete', ctx => cascadeDelete(ctx.recordId))` destroys
|
|
535
|
+
// children behind a correct 404.
|
|
536
|
+
// - a before-hook may return a value and short-circuit, which would
|
|
537
|
+
// otherwise return a response without the filter ever executing.
|
|
538
|
+
//
|
|
539
|
+
// 404, not 403, for the same reason as getSingleHandler: the status for
|
|
540
|
+
// "exists but filtered out" must equal "does not exist".
|
|
541
|
+
if (existingRecord && isDenied(filter, existingRecord))
|
|
542
|
+
return 404;
|
|
385
543
|
if (existingRecord) {
|
|
386
544
|
// Deep copy the record's data to preserve old state
|
|
387
545
|
context.oldState = JSON.parse(JSON.stringify(existingRecord.__data || existingRecord));
|
|
@@ -404,19 +562,25 @@ export default class OrmRequest extends Request {
|
|
|
404
562
|
if (operation === 'update' && response?.data) {
|
|
405
563
|
context.record = store.get(this.model, getId(request.params));
|
|
406
564
|
}
|
|
407
|
-
//
|
|
565
|
+
// GATE 2 -- post-handler. A denied or failed handler returns a bare status
|
|
566
|
+
// integer, and no executor below may run for one.
|
|
567
|
+
//
|
|
568
|
+
// `>= 400` deliberately covers every failure status, not just the
|
|
569
|
+
// authorization ones: a 400 (POST with no `type`) and a 409 (duplicate id)
|
|
570
|
+
// are equally requests in which nothing happened, and a persist or a
|
|
571
|
+
// cascade hook for one of them is just as wrong.
|
|
572
|
+
const denied = Number.isInteger(response) && response >= 400;
|
|
573
|
+
// EXECUTOR 1 -- SQL persistence, for all write operations.
|
|
408
574
|
//
|
|
409
|
-
//
|
|
410
|
-
//
|
|
411
|
-
//
|
|
412
|
-
//
|
|
413
|
-
//
|
|
414
|
-
// issues DELETE FROM ... WHERE id = ? on every SQL backend.
|
|
575
|
+
// `response` is passed to sqlDb.persist below, but it is dropped at the
|
|
576
|
+
// driver boundary: _persistDelete(modelName, context) never receives it
|
|
577
|
+
// and guards only on context.recordId -- which _withHooks set above,
|
|
578
|
+
// BEFORE the handler ran. Without this gate a correct 404 still issues
|
|
579
|
+
// DELETE FROM ... WHERE id = ? on every SQL backend.
|
|
415
580
|
//
|
|
416
581
|
// No file-backed test can observe that, because Orm.instance.sqlDb is
|
|
417
582
|
// null in file/directory mode. See the stubbed-sqlDb assertions in
|
|
418
583
|
// test/unit/access-filter-enforcement-test.ts.
|
|
419
|
-
const denied = Number.isInteger(response) && response >= 400;
|
|
420
584
|
const sqlDb = Orm.instance.sqlDb;
|
|
421
585
|
if (sqlDb && WRITE_OPERATIONS.has(operation) && !denied) {
|
|
422
586
|
await sqlDb.persist(operation, this.model, context, response);
|
|
@@ -439,12 +603,29 @@ export default class OrmRequest extends Request {
|
|
|
439
603
|
// For delete, the record may no longer exist, but we have oldState
|
|
440
604
|
context.recordId = getId(request.params);
|
|
441
605
|
}
|
|
442
|
-
//
|
|
443
|
-
|
|
444
|
-
|
|
606
|
+
// EXECUTOR 2 -- the after-hook pipeline. This is the published consumer
|
|
607
|
+
// extension point (`afterHook` is exported from @stonyx/orm and from
|
|
608
|
+
// ./hooks), so it is the executor with the widest possible blast radius:
|
|
609
|
+
// a cascade delete, a webhook, a token revocation, a search-index purge.
|
|
610
|
+
//
|
|
611
|
+
// BEHAVIOUR CHANGE (#190): after-hooks no longer fire for a request that
|
|
612
|
+
// failed. Previously `afterHook('delete', ...)` ran with a populated
|
|
613
|
+
// context.recordId on a 404, so a consumer cascade destroyed children for
|
|
614
|
+
// a request that deleted nothing. Firing a hook named "after<operation>"
|
|
615
|
+
// for an operation that did not occur is a booby trap, and the denied case
|
|
616
|
+
// is unreachable-before-#190 while the missing case is inherited debt --
|
|
617
|
+
// both are closed by the same gate. `context.response` therefore only ever
|
|
618
|
+
// carries a success status into a hook.
|
|
619
|
+
if (!denied) {
|
|
620
|
+
for (const hook of getAfterHooks(operation, this.model)) {
|
|
621
|
+
await hook(context);
|
|
622
|
+
}
|
|
445
623
|
}
|
|
446
|
-
//
|
|
447
|
-
|
|
624
|
+
// EXECUTOR 3 -- file/directory autosave. Ungated this let an
|
|
625
|
+
// unauthenticated caller force a full serialize-and-write of the entire
|
|
626
|
+
// store on every DELETE of any id, with no record touched: amplification
|
|
627
|
+
// rather than corruption, but the same root cause and the same fix.
|
|
628
|
+
if (config.orm.db.autosave === 'onUpdate' && WRITE_OPERATIONS.has(operation) && !denied) {
|
|
448
629
|
await Orm.db.save();
|
|
449
630
|
}
|
|
450
631
|
return response;
|
|
@@ -523,35 +704,59 @@ export default class OrmRequest extends Request {
|
|
|
523
704
|
};
|
|
524
705
|
};
|
|
525
706
|
}
|
|
526
|
-
// Catch-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
//
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
};
|
|
707
|
+
// Catch-alls for invalid relationship names. Every valid relationship was
|
|
708
|
+
// registered above, so reaching either of these means the relationship does
|
|
709
|
+
// not exist and the answer is 404 regardless of the record.
|
|
710
|
+
//
|
|
711
|
+
// These deliberately carry NO access check and no store lookup. An earlier
|
|
712
|
+
// revision of #190 added `if (isDenied(filter, record)) return 404` here for
|
|
713
|
+
// symmetry with the seven real surfaces, but both branches returned 404, so
|
|
714
|
+
// the guard was unobservable by construction -- a mutation deleting it
|
|
715
|
+
// survived the entire suite because no test that could distinguish it can
|
|
716
|
+
// exist. Unkillable code in an authorization diff reads as coverage and is
|
|
717
|
+
// not, so it is gone; skipping the lookup also removes the timing difference
|
|
718
|
+
// between an existing and a missing parent.
|
|
719
|
+
//
|
|
720
|
+
// IF THIS ROUTE EVER RETURNS ANYTHING OTHER THAN A CONSTANT 404, it becomes
|
|
721
|
+
// the eighth surface and must filter the parent first, exactly like
|
|
722
|
+
// `/:id/{relationship}` above.
|
|
723
|
+
routes[`/:id/:relationship`] = async () => 404;
|
|
724
|
+
routes[`/:id/relationships/:relationship`] = async () => 404;
|
|
545
725
|
return routes;
|
|
546
726
|
}
|
|
547
727
|
auth(request, state) {
|
|
548
|
-
|
|
728
|
+
// A consumer `access()` that throws is a DENIAL, matching `isDenied` one
|
|
729
|
+
// layer down. Unguarded it propagates to express's default handler, which
|
|
730
|
+
// answers 500 -- and the documented sample itself can throw
|
|
731
|
+
// (`request.originalUrl.split(...)` when originalUrl is absent), so the
|
|
732
|
+
// failure mode is reachable by following the docs.
|
|
733
|
+
let access;
|
|
734
|
+
try {
|
|
735
|
+
access = this.access(request);
|
|
736
|
+
}
|
|
737
|
+
catch {
|
|
738
|
+
return 403; // Forbidden
|
|
739
|
+
}
|
|
549
740
|
if (!access)
|
|
550
741
|
return 403;
|
|
551
|
-
if (
|
|
552
|
-
return 403;
|
|
553
|
-
if (typeof access === 'function')
|
|
742
|
+
if (typeof access === 'function') {
|
|
554
743
|
state.filter = access;
|
|
744
|
+
return undefined;
|
|
745
|
+
}
|
|
746
|
+
if (access === true)
|
|
747
|
+
return undefined;
|
|
748
|
+
// `AccessMethod` declares `string` legal and it fell through every branch
|
|
749
|
+
// above, returning undefined -- i.e. FULL CRUD, no filter. `return 'read'`
|
|
750
|
+
// is the natural reading of a type that lists `string` first, and it
|
|
751
|
+
// granted DELETE. A bare string is one permission, not a grant of all four.
|
|
752
|
+
const permitted = typeof access === 'string' ? [access] : access;
|
|
753
|
+
// Anything that is not a permission array by this point -- an object, a
|
|
754
|
+
// number, a Symbol -- is a consumer mistake, and the only safe reading of a
|
|
755
|
+
// shape the contract does not define is a denial. Fail CLOSED.
|
|
756
|
+
if (!Array.isArray(permitted))
|
|
757
|
+
return 403;
|
|
758
|
+
if (!permitted.includes(methodAccessMap[request.method]))
|
|
759
|
+
return 403;
|
|
555
760
|
return undefined;
|
|
556
761
|
}
|
|
557
762
|
}
|
package/package.json
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
"stonyx-async",
|
|
5
5
|
"stonyx-module"
|
|
6
6
|
],
|
|
7
|
-
"version": "0.3.2-alpha.
|
|
7
|
+
"version": "0.3.2-alpha.49",
|
|
8
8
|
"description": "",
|
|
9
9
|
"main": "dist/index.js",
|
|
10
10
|
"type": "module",
|
|
@@ -61,10 +61,10 @@
|
|
|
61
61
|
},
|
|
62
62
|
"homepage": "https://github.com/abofs/stonyx-orm#readme",
|
|
63
63
|
"dependencies": {
|
|
64
|
-
"@stonyx/cron": "0.2.1-beta.
|
|
64
|
+
"@stonyx/cron": "0.2.1-beta.84",
|
|
65
65
|
"@stonyx/events": "0.1.1-beta.52",
|
|
66
66
|
"@stonyx/utils": "0.2.3-beta.26",
|
|
67
|
-
"stonyx": "0.2.3-beta.
|
|
67
|
+
"stonyx": "0.2.3-beta.77"
|
|
68
68
|
},
|
|
69
69
|
"peerDependencies": {
|
|
70
70
|
"@aws-sdk/client-dynamodb": "^3.0.0",
|
|
@@ -91,7 +91,7 @@
|
|
|
91
91
|
}
|
|
92
92
|
},
|
|
93
93
|
"devDependencies": {
|
|
94
|
-
"@stonyx/rest-server": "0.2.1-beta.
|
|
94
|
+
"@stonyx/rest-server": "0.2.1-beta.83",
|
|
95
95
|
"@types/node": "^25.6.0",
|
|
96
96
|
"mysql2": "^3.20.0",
|
|
97
97
|
"pg": "^8.20.0",
|
package/src/orm-request.ts
CHANGED
|
@@ -92,6 +92,30 @@ function getId(params: { id?: string; [key: string]: unknown }): string | number
|
|
|
92
92
|
return parseInt(id);
|
|
93
93
|
}
|
|
94
94
|
|
|
95
|
+
/**
|
|
96
|
+
* Normalise a caller-supplied BODY id to the key the store will hold it under.
|
|
97
|
+
*
|
|
98
|
+
* `getId()` above is params-shaped: it takes `{ id?: string }` off the URL,
|
|
99
|
+
* where the value is always a string and a falsy one means "no id". A JSON body
|
|
100
|
+
* id is neither -- it can arrive as a number, and `0` is a legitimate id that
|
|
101
|
+
* `getId()` would flatten to `''`.
|
|
102
|
+
*
|
|
103
|
+
* WHY THIS EXISTS AT ALL. createHandler used to look the collision up with the
|
|
104
|
+
* RAW body value while every other surface normalised through `getId()`. The
|
|
105
|
+
* store is a Map keyed by the coerced value, so `store.find(model, '21')` misses
|
|
106
|
+
* the entry held under `21` and the duplicate check is skipped by typing the id
|
|
107
|
+
* as a string. On `dev` that silently overwrote the colliding record and
|
|
108
|
+
* answered 200; combined with the denied-create rollback added for #190 it
|
|
109
|
+
* became an unauthenticated DELETE of any id. Normalising here is half of that
|
|
110
|
+
* fix -- see the rollback in createHandler for the other half.
|
|
111
|
+
*/
|
|
112
|
+
function normalizeBodyId(id: string | number): string | number {
|
|
113
|
+
if (typeof id === 'number') return id;
|
|
114
|
+
if (typeof id !== 'string' || id.trim() === '') return id;
|
|
115
|
+
|
|
116
|
+
return isNaN(id as unknown as number) ? id : parseInt(id, 10);
|
|
117
|
+
}
|
|
118
|
+
|
|
95
119
|
function buildResponse(
|
|
96
120
|
data: unknown,
|
|
97
121
|
includeParam: string | undefined,
|
|
@@ -266,7 +290,16 @@ function createFilterPredicate(filters: Filter[]): ((record: { [key: string]: un
|
|
|
266
290
|
function isDenied(filter: unknown, record: unknown): boolean {
|
|
267
291
|
if (typeof filter !== 'function') return false;
|
|
268
292
|
|
|
269
|
-
|
|
293
|
+
// A predicate that throws is treated as a denial. Unguarded, a throw escapes
|
|
294
|
+
// to express's default handler, which answers 500 (with a stack trace outside
|
|
295
|
+
// NODE_ENV=production) while a missing id still answers 404 -- so a
|
|
296
|
+
// record-dependent throw re-separates "hidden" from "does not exist" and
|
|
297
|
+
// hands back the oracle this whole change exists to close.
|
|
298
|
+
try {
|
|
299
|
+
return !(filter as (record: unknown) => boolean)(record);
|
|
300
|
+
} catch {
|
|
301
|
+
return true;
|
|
302
|
+
}
|
|
270
303
|
}
|
|
271
304
|
|
|
272
305
|
export default class OrmRequest extends Request {
|
|
@@ -336,8 +369,47 @@ export default class OrmRequest extends Request {
|
|
|
336
369
|
const fieldsMap = parseFields(query);
|
|
337
370
|
const modelFields = fieldsMap.get(pluralizedModel) || fieldsMap.get(model);
|
|
338
371
|
|
|
339
|
-
//
|
|
340
|
-
|
|
372
|
+
// GATE 0 -- the POST existence oracle.
|
|
373
|
+
//
|
|
374
|
+
// The duplicate check runs before the filter and `store.find` sees hidden
|
|
375
|
+
// records, so POST leaks existence through its STATUS. A previous revision
|
|
376
|
+
// filtered the collision status (403 when the colliding record is denied,
|
|
377
|
+
// 409 when it is visible) and that is NOT sufficient, because the status
|
|
378
|
+
// of a create is a third outcome. With a payload the caller is permitted
|
|
379
|
+
// to create -- the normative case for a per-tenant filter, and the case an
|
|
380
|
+
// attacker picks -- all three are distinguishable in ONE request per id:
|
|
381
|
+
//
|
|
382
|
+
// POST /animals {id:22, owner:'gina'} -> 403 a HIDDEN record has this id
|
|
383
|
+
// POST /animals {id:9500, owner:'gina'} -> 200 this id is FREE
|
|
384
|
+
// POST /animals {id:8, owner:'gina'} -> 409 a VISIBLE record has this id
|
|
385
|
+
//
|
|
386
|
+
// Filtering only the collision status narrows that to callers who cannot
|
|
387
|
+
// create a record they are allowed to see. It does not close it.
|
|
388
|
+
//
|
|
389
|
+
// It cannot be closed while a caller both chooses the id and learns
|
|
390
|
+
// whether the create succeeded: a successful create must answer
|
|
391
|
+
// differently from a refused one. So when a per-record filter is in force
|
|
392
|
+
// the caller does not get to choose the id at all. The refusal is
|
|
393
|
+
// UNCONDITIONAL and happens BEFORE any store lookup, so no status, and no
|
|
394
|
+
// lookup cost, can depend on whether that id exists. 403 -- the same
|
|
395
|
+
// status as a denied create -- so the two cannot be separated either.
|
|
396
|
+
//
|
|
397
|
+
// Scoped to function-style `access` because that is exactly the population
|
|
398
|
+
// the oracle exists for: with no per-record filter there are no hidden
|
|
399
|
+
// records, and 409 discloses nothing GET /:id does not already.
|
|
400
|
+
//
|
|
401
|
+
// RESIDUAL, stated rather than implied: a caller can still learn that a
|
|
402
|
+
// collection HAS a per-record filter (403 rather than 409/200 for an
|
|
403
|
+
// id-bearing POST). That discloses a configuration fact, not a record.
|
|
404
|
+
// See README `### Known limitations`.
|
|
405
|
+
if (id !== undefined) {
|
|
406
|
+
if (typeof filter === 'function') return 403; // Forbidden
|
|
407
|
+
|
|
408
|
+
// `normalizeBodyId`, not the raw value: a string-typed id misses the
|
|
409
|
+
// store's numeric key, which skipped this check entirely.
|
|
410
|
+
const existing = await store.find(model, normalizeBodyId(id));
|
|
411
|
+
if (existing) return 409; // Conflict
|
|
412
|
+
}
|
|
341
413
|
|
|
342
414
|
const { id: _ignoredId, ...sanitizedAttributes } = attributes || {};
|
|
343
415
|
|
|
@@ -352,10 +424,22 @@ export default class OrmRequest extends Request {
|
|
|
352
424
|
}
|
|
353
425
|
|
|
354
426
|
const recordAttributes = id !== undefined ? { id, ...sanitizedAttributes } : sanitizedAttributes;
|
|
427
|
+
|
|
428
|
+
// Slot count BEFORE the write. `createRecord` writes to the store before
|
|
429
|
+
// the predicate can run, and the rollback below must be able to prove the
|
|
430
|
+
// slot it removes is one THIS REQUEST created. Identity alone cannot
|
|
431
|
+
// prove it: when `assignRecordId` lands on an occupied id, `createRecord`
|
|
432
|
+
// mutates the existing OrmRecord IN PLACE, so `store.get(...) === record`
|
|
433
|
+
// is true for a record the request did not create. The map's size is the
|
|
434
|
+
// only O(1) signal that distinguishes an insert from an overwrite.
|
|
435
|
+
const slotsBefore = (store.get(model) as Map<string | number, unknown> | undefined)?.size ?? 0;
|
|
436
|
+
|
|
355
437
|
const created = createRecord(model, recordAttributes as { [key: string]: unknown }, { serialize: false, _skipAutoPersist: true });
|
|
356
438
|
const record = isOrmRecord(created) ? created : null;
|
|
357
439
|
if (!record) return 500;
|
|
358
440
|
|
|
441
|
+
const createdNewSlot = ((store.get(model) as Map<string | number, unknown> | undefined)?.size ?? 0) > slotsBefore;
|
|
442
|
+
|
|
359
443
|
// 403 here, NOT 404. The oracle argument does not apply to create: there
|
|
360
444
|
// is no pre-existing record whose existence could leak, the caller
|
|
361
445
|
// supplied the attributes, and 404 on a mounted collection route is
|
|
@@ -366,7 +450,27 @@ export default class OrmRequest extends Request {
|
|
|
366
450
|
// the predicate can run, so returning 403 alone would leave the record
|
|
367
451
|
// behind: a worse bug than the bypass being fixed.
|
|
368
452
|
if (isDenied(filter, record)) {
|
|
369
|
-
store.remove(model, record.id
|
|
453
|
+
// ROLL BACK BY IDENTITY, NEVER BY ID. `store.remove(model, record.id)`
|
|
454
|
+
// on its own is a write primitive keyed by a value the caller may have
|
|
455
|
+
// supplied: with the raw-id collision bypass above, a denied
|
|
456
|
+
// `POST {"id":"21"}` answered 403 and DELETED hidden record 21 -- an
|
|
457
|
+
// unauthenticated deletion primitive across the whole id space, created
|
|
458
|
+
// by adding a rollback to a lookup that could be skipped.
|
|
459
|
+
//
|
|
460
|
+
// Both conditions are required and neither implies the other:
|
|
461
|
+
// createdNewSlot -- the store grew, so this request inserted rather
|
|
462
|
+
// than overwrote. Guards `assignRecordId` picking an
|
|
463
|
+
// id that is already taken (it returns
|
|
464
|
+
// last-INSERTED + 1, not max + 1, so a store whose
|
|
465
|
+
// insertion order is not ascending collides) -- see
|
|
466
|
+
// abofs/stonyx-orm#203.
|
|
467
|
+
// identity -- the slot still holds the object we just created,
|
|
468
|
+
// so nothing between createRecord and here replaced
|
|
469
|
+
// it.
|
|
470
|
+
if (createdNewSlot && store.get(model, record.id as string | number) === record) {
|
|
471
|
+
store.remove(model, record.id as string | number, { _skipAutoPersist: true });
|
|
472
|
+
}
|
|
473
|
+
|
|
370
474
|
return 403;
|
|
371
475
|
}
|
|
372
476
|
|
|
@@ -379,6 +483,13 @@ export default class OrmRequest extends Request {
|
|
|
379
483
|
// Checked BEFORE any attribute is applied. 404 rather than 403 for the
|
|
380
484
|
// same reason as GET /:id -- 403 would disclose both that the record
|
|
381
485
|
// exists and that this caller specifically is excluded.
|
|
486
|
+
//
|
|
487
|
+
// NOT redundant behind GATE 1, and not defence in depth either: GATE 1's
|
|
488
|
+
// verdict is computed BEFORE the before-hook loop runs, and a before-hook
|
|
489
|
+
// is a published extension point that can change the answer -- by
|
|
490
|
+
// mutating the record, or against a predicate that closes over
|
|
491
|
+
// per-request state. This is the only re-evaluation after that window.
|
|
492
|
+
// Pinned by assertion 32; deleting it turns a 404 into an applied update.
|
|
382
493
|
if (isDenied(filter, found)) return 404;
|
|
383
494
|
const record = found;
|
|
384
495
|
const { attributes, relationships: rels } = (body?.data || {}) as {
|
|
@@ -426,6 +537,9 @@ export default class OrmRequest extends Request {
|
|
|
426
537
|
// Returning 204 for a denied delete was rejected instead: it falsely
|
|
427
538
|
// reports success for a request that changed nothing.
|
|
428
539
|
if (!record) return 404;
|
|
540
|
+
// Re-evaluated after the before-hook loop, exactly as in updateHandler --
|
|
541
|
+
// GATE 1 decided before the hooks ran. Pinned by assertion 33; deleting it
|
|
542
|
+
// turns a 404 into a destroyed record.
|
|
429
543
|
if (isDenied(filter, record)) return 404;
|
|
430
544
|
|
|
431
545
|
store.remove(model, getId(params), { _skipAutoPersist: true });
|
|
@@ -457,9 +571,37 @@ export default class OrmRequest extends Request {
|
|
|
457
571
|
}
|
|
458
572
|
}
|
|
459
573
|
|
|
460
|
-
// Wraps a handler with before/after hook execution
|
|
574
|
+
// Wraps a handler with before/after hook execution.
|
|
575
|
+
//
|
|
576
|
+
// ===========================================================================
|
|
577
|
+
// TWO AUTHORIZATION GATES, AND EVERY EXECUTOR SITS BEHIND ONE OF THEM (#190)
|
|
578
|
+
//
|
|
579
|
+
// The defect this function was fixed for is NOT "a delete persists past a
|
|
580
|
+
// 404". It is that _withHooks has SEVERAL executors downstream of the
|
|
581
|
+
// handler, and originally the handler's response gated none of them. Three
|
|
582
|
+
// exist today:
|
|
583
|
+
//
|
|
584
|
+
// 1. sqlDb.persist -- issues real SQL against the backing store
|
|
585
|
+
// 2. the after-hook pipeline -- the PUBLISHED consumer extension point;
|
|
586
|
+
// a cascade delete, a webhook, a search-index
|
|
587
|
+
// purge. `context.recordId` and
|
|
588
|
+
// `context.oldState` are populated for it.
|
|
589
|
+
// 3. Orm.db.save() -- a full serialize-and-write of the store
|
|
590
|
+
//
|
|
591
|
+
// Gating them one at a time is how this keeps regressing, so the rule is:
|
|
592
|
+
// compute denial ONCE at each point where it becomes knowable, and keep every
|
|
593
|
+
// executor downstream of a gate. If you add a fourth executor to this
|
|
594
|
+
// function, it goes below GATE 2 or it is a security bug.
|
|
595
|
+
//
|
|
596
|
+
// GATE 1 (pre-handler) is required because before-hooks and `context.oldState`
|
|
597
|
+
// run/are built BEFORE the handler can consult the filter. Without it a denied
|
|
598
|
+
// DELETE still handed the hidden record's full contents to consumer code.
|
|
599
|
+
// GATE 2 (post-handler) covers everything the handler's status can reach.
|
|
600
|
+
// ===========================================================================
|
|
461
601
|
private _withHooks(operation: string, handler: HandlerFn): HandlerFn {
|
|
462
602
|
return async (request: OrmRequest$, state: { [key: string]: unknown }) => {
|
|
603
|
+
const { filter } = (state || {}) as { filter?: unknown };
|
|
604
|
+
|
|
463
605
|
// Build context object for hooks
|
|
464
606
|
const context: HookContext = {
|
|
465
607
|
model: this.model,
|
|
@@ -474,6 +616,27 @@ export default class OrmRequest extends Request {
|
|
|
474
616
|
// Capture old state for operations that modify data
|
|
475
617
|
if (operation === 'update' || operation === 'delete') {
|
|
476
618
|
const existingRecord = await store.find(this.model, getId(request.params)) as OrmRecord | undefined;
|
|
619
|
+
|
|
620
|
+
// GATE 1 -- pre-handler. This record fetch already happened for
|
|
621
|
+
// oldState, so the check is free.
|
|
622
|
+
//
|
|
623
|
+
// Returning here rather than letting updateHandler/deleteHandler
|
|
624
|
+
// produce the same 404 is the point: everything between here and there
|
|
625
|
+
// is an executor the caller is not authorized to reach.
|
|
626
|
+
// - context.oldState is a deep copy of the HIDDEN RECORD'S CONTENTS.
|
|
627
|
+
// Building it and handing it to a before-hook discloses exactly what
|
|
628
|
+
// the filter exists to hide.
|
|
629
|
+
// - context.recordId is populated for delete BEFORE the handler runs,
|
|
630
|
+
// which is the same shape as the sqlDb landmine one layer up:
|
|
631
|
+
// `afterHook('delete', ctx => cascadeDelete(ctx.recordId))` destroys
|
|
632
|
+
// children behind a correct 404.
|
|
633
|
+
// - a before-hook may return a value and short-circuit, which would
|
|
634
|
+
// otherwise return a response without the filter ever executing.
|
|
635
|
+
//
|
|
636
|
+
// 404, not 403, for the same reason as getSingleHandler: the status for
|
|
637
|
+
// "exists but filtered out" must equal "does not exist".
|
|
638
|
+
if (existingRecord && isDenied(filter, existingRecord)) return 404;
|
|
639
|
+
|
|
477
640
|
if (existingRecord) {
|
|
478
641
|
// Deep copy the record's data to preserve old state
|
|
479
642
|
context.oldState = JSON.parse(JSON.stringify(existingRecord.__data || existingRecord));
|
|
@@ -500,19 +663,26 @@ export default class OrmRequest extends Request {
|
|
|
500
663
|
context.record = store.get(this.model, getId(request.params));
|
|
501
664
|
}
|
|
502
665
|
|
|
503
|
-
//
|
|
666
|
+
// GATE 2 -- post-handler. A denied or failed handler returns a bare status
|
|
667
|
+
// integer, and no executor below may run for one.
|
|
668
|
+
//
|
|
669
|
+
// `>= 400` deliberately covers every failure status, not just the
|
|
670
|
+
// authorization ones: a 400 (POST with no `type`) and a 409 (duplicate id)
|
|
671
|
+
// are equally requests in which nothing happened, and a persist or a
|
|
672
|
+
// cascade hook for one of them is just as wrong.
|
|
673
|
+
const denied = Number.isInteger(response) && (response as number) >= 400;
|
|
674
|
+
|
|
675
|
+
// EXECUTOR 1 -- SQL persistence, for all write operations.
|
|
504
676
|
//
|
|
505
|
-
//
|
|
506
|
-
//
|
|
507
|
-
//
|
|
508
|
-
//
|
|
509
|
-
//
|
|
510
|
-
// issues DELETE FROM ... WHERE id = ? on every SQL backend.
|
|
677
|
+
// `response` is passed to sqlDb.persist below, but it is dropped at the
|
|
678
|
+
// driver boundary: _persistDelete(modelName, context) never receives it
|
|
679
|
+
// and guards only on context.recordId -- which _withHooks set above,
|
|
680
|
+
// BEFORE the handler ran. Without this gate a correct 404 still issues
|
|
681
|
+
// DELETE FROM ... WHERE id = ? on every SQL backend.
|
|
511
682
|
//
|
|
512
683
|
// No file-backed test can observe that, because Orm.instance.sqlDb is
|
|
513
684
|
// null in file/directory mode. See the stubbed-sqlDb assertions in
|
|
514
685
|
// test/unit/access-filter-enforcement-test.ts.
|
|
515
|
-
const denied = Number.isInteger(response) && (response as number) >= 400;
|
|
516
686
|
const sqlDb = Orm.instance.sqlDb;
|
|
517
687
|
if (sqlDb && WRITE_OPERATIONS.has(operation) && !denied) {
|
|
518
688
|
await sqlDb.persist(operation, this.model, context, response);
|
|
@@ -535,13 +705,30 @@ export default class OrmRequest extends Request {
|
|
|
535
705
|
context.recordId = getId(request.params);
|
|
536
706
|
}
|
|
537
707
|
|
|
538
|
-
//
|
|
539
|
-
|
|
540
|
-
|
|
708
|
+
// EXECUTOR 2 -- the after-hook pipeline. This is the published consumer
|
|
709
|
+
// extension point (`afterHook` is exported from @stonyx/orm and from
|
|
710
|
+
// ./hooks), so it is the executor with the widest possible blast radius:
|
|
711
|
+
// a cascade delete, a webhook, a token revocation, a search-index purge.
|
|
712
|
+
//
|
|
713
|
+
// BEHAVIOUR CHANGE (#190): after-hooks no longer fire for a request that
|
|
714
|
+
// failed. Previously `afterHook('delete', ...)` ran with a populated
|
|
715
|
+
// context.recordId on a 404, so a consumer cascade destroyed children for
|
|
716
|
+
// a request that deleted nothing. Firing a hook named "after<operation>"
|
|
717
|
+
// for an operation that did not occur is a booby trap, and the denied case
|
|
718
|
+
// is unreachable-before-#190 while the missing case is inherited debt --
|
|
719
|
+
// both are closed by the same gate. `context.response` therefore only ever
|
|
720
|
+
// carries a success status into a hook.
|
|
721
|
+
if (!denied) {
|
|
722
|
+
for (const hook of getAfterHooks(operation, this.model)) {
|
|
723
|
+
await hook(context);
|
|
724
|
+
}
|
|
541
725
|
}
|
|
542
726
|
|
|
543
|
-
//
|
|
544
|
-
|
|
727
|
+
// EXECUTOR 3 -- file/directory autosave. Ungated this let an
|
|
728
|
+
// unauthenticated caller force a full serialize-and-write of the entire
|
|
729
|
+
// store on every DELETE of any id, with no record touched: amplification
|
|
730
|
+
// rather than corruption, but the same root cause and the same fix.
|
|
731
|
+
if (config.orm.db.autosave === 'onUpdate' && WRITE_OPERATIONS.has(operation) && !denied) {
|
|
545
732
|
await (Orm.db as { save(): Promise<void> }).save();
|
|
546
733
|
}
|
|
547
734
|
|
|
@@ -629,34 +816,60 @@ export default class OrmRequest extends Request {
|
|
|
629
816
|
};
|
|
630
817
|
}
|
|
631
818
|
|
|
632
|
-
// Catch-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
//
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
819
|
+
// Catch-alls for invalid relationship names. Every valid relationship was
|
|
820
|
+
// registered above, so reaching either of these means the relationship does
|
|
821
|
+
// not exist and the answer is 404 regardless of the record.
|
|
822
|
+
//
|
|
823
|
+
// These deliberately carry NO access check and no store lookup. An earlier
|
|
824
|
+
// revision of #190 added `if (isDenied(filter, record)) return 404` here for
|
|
825
|
+
// symmetry with the seven real surfaces, but both branches returned 404, so
|
|
826
|
+
// the guard was unobservable by construction -- a mutation deleting it
|
|
827
|
+
// survived the entire suite because no test that could distinguish it can
|
|
828
|
+
// exist. Unkillable code in an authorization diff reads as coverage and is
|
|
829
|
+
// not, so it is gone; skipping the lookup also removes the timing difference
|
|
830
|
+
// between an existing and a missing parent.
|
|
831
|
+
//
|
|
832
|
+
// IF THIS ROUTE EVER RETURNS ANYTHING OTHER THAN A CONSTANT 404, it becomes
|
|
833
|
+
// the eighth surface and must filter the parent first, exactly like
|
|
834
|
+
// `/:id/{relationship}` above.
|
|
835
|
+
routes[`/:id/:relationship`] = async () => 404;
|
|
836
|
+
routes[`/:id/relationships/:relationship`] = async () => 404;
|
|
650
837
|
|
|
651
838
|
return routes;
|
|
652
839
|
}
|
|
653
840
|
|
|
654
841
|
auth(request: OrmRequest$, state: { [key: string]: unknown }): number | undefined {
|
|
655
|
-
|
|
842
|
+
// A consumer `access()` that throws is a DENIAL, matching `isDenied` one
|
|
843
|
+
// layer down. Unguarded it propagates to express's default handler, which
|
|
844
|
+
// answers 500 -- and the documented sample itself can throw
|
|
845
|
+
// (`request.originalUrl.split(...)` when originalUrl is absent), so the
|
|
846
|
+
// failure mode is reachable by following the docs.
|
|
847
|
+
let access: AccessMethod;
|
|
848
|
+
try {
|
|
849
|
+
access = this.access(request);
|
|
850
|
+
} catch {
|
|
851
|
+
return 403; // Forbidden
|
|
852
|
+
}
|
|
656
853
|
|
|
657
854
|
if (!access) return 403;
|
|
658
|
-
if (
|
|
659
|
-
|
|
855
|
+
if (typeof access === 'function') {
|
|
856
|
+
state.filter = access;
|
|
857
|
+
return undefined;
|
|
858
|
+
}
|
|
859
|
+
if (access === true) return undefined;
|
|
860
|
+
|
|
861
|
+
// `AccessMethod` declares `string` legal and it fell through every branch
|
|
862
|
+
// above, returning undefined -- i.e. FULL CRUD, no filter. `return 'read'`
|
|
863
|
+
// is the natural reading of a type that lists `string` first, and it
|
|
864
|
+
// granted DELETE. A bare string is one permission, not a grant of all four.
|
|
865
|
+
const permitted = typeof access === 'string' ? [access] : access;
|
|
866
|
+
|
|
867
|
+
// Anything that is not a permission array by this point -- an object, a
|
|
868
|
+
// number, a Symbol -- is a consumer mistake, and the only safe reading of a
|
|
869
|
+
// shape the contract does not define is a denial. Fail CLOSED.
|
|
870
|
+
if (!Array.isArray(permitted)) return 403;
|
|
871
|
+
if (!permitted.includes(methodAccessMap[request.method])) return 403;
|
|
872
|
+
|
|
660
873
|
return undefined;
|
|
661
874
|
}
|
|
662
875
|
}
|