@stonyx/orm 0.3.2-alpha.47 → 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 +224 -2
- package/dist/orm-request.js +322 -36
- package/package.json +4 -4
- package/src/orm-request.ts +325 -35
package/README.md
CHANGED
|
@@ -309,19 +309,241 @@ 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) {
|
|
319
|
-
|
|
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
|
+
|
|
346
|
+
// false → 403 for the whole request
|
|
347
|
+
if (path.startsWith(`${owners}/archived`)) return false;
|
|
348
|
+
|
|
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
|
+
}
|
|
355
|
+
|
|
320
356
|
return ['read', 'create', 'update', 'delete'];
|
|
321
357
|
}
|
|
322
358
|
}
|
|
323
359
|
```
|
|
324
360
|
|
|
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.
|
|
546
|
+
|
|
325
547
|
### Include Parameter (Sideloading Relationships)
|
|
326
548
|
|
|
327
549
|
The ORM supports JSON API-compliant relationship sideloading via the `include` query parameter. This reduces the need for multiple API requests by embedding related records in a single response.
|
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 };
|
|
@@ -187,6 +211,33 @@ function createFilterPredicate(filters) {
|
|
|
187
211
|
return String(current) === value;
|
|
188
212
|
});
|
|
189
213
|
}
|
|
214
|
+
/**
|
|
215
|
+
* A function-style `access` return is a per-record predicate, and it is only
|
|
216
|
+
* meaningful if every surface that can hand a record to a caller consults it.
|
|
217
|
+
* Before #190 exactly one of seven did.
|
|
218
|
+
*
|
|
219
|
+
* Enforcement is deliberately post-fetch. `access` returns an opaque JS
|
|
220
|
+
* predicate and `store.findAll(model, conditions)` accepts only an equality
|
|
221
|
+
* conditions object that the SQL drivers translate to a WHERE clause, so
|
|
222
|
+
* query-layer enforcement would require a breaking change to the published
|
|
223
|
+
* `access` contract. That belongs in #197, not in a security patch. Six of the
|
|
224
|
+
* seven surfaces fetch by primary key anyway, so this costs exactly one row.
|
|
225
|
+
*/
|
|
226
|
+
function isDenied(filter, record) {
|
|
227
|
+
if (typeof filter !== 'function')
|
|
228
|
+
return false;
|
|
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
|
+
}
|
|
240
|
+
}
|
|
190
241
|
export default class OrmRequest extends Request {
|
|
191
242
|
model;
|
|
192
243
|
access;
|
|
@@ -216,10 +267,15 @@ export default class OrmRequest extends Request {
|
|
|
216
267
|
baseUrl
|
|
217
268
|
});
|
|
218
269
|
};
|
|
219
|
-
const getSingleHandler = async (request) => {
|
|
270
|
+
const getSingleHandler = async (request, { filter }) => {
|
|
220
271
|
const record = await store.find(model, getId(request.params));
|
|
221
272
|
if (!record)
|
|
222
273
|
return 404;
|
|
274
|
+
// 404, never 403: the status for "exists but filtered out" must be
|
|
275
|
+
// identical to "does not exist", or the fix trades an authorization
|
|
276
|
+
// bypass for a narrower existence oracle.
|
|
277
|
+
if (isDenied(filter, record))
|
|
278
|
+
return 404;
|
|
223
279
|
const fieldsMap = parseFields(request.query);
|
|
224
280
|
const modelFields = fieldsMap.get(pluralizedModel) || fieldsMap.get(model);
|
|
225
281
|
const baseUrl = getBaseUrl(request);
|
|
@@ -228,15 +284,54 @@ export default class OrmRequest extends Request {
|
|
|
228
284
|
baseUrl
|
|
229
285
|
});
|
|
230
286
|
};
|
|
231
|
-
const createHandler = async ({ body, query }) => {
|
|
287
|
+
const createHandler = async ({ body, query }, { filter }) => {
|
|
232
288
|
const { type, id, attributes, relationships: rels } = (body?.data || {});
|
|
233
289
|
if (!type)
|
|
234
290
|
return 400; // Bad request
|
|
235
291
|
const fieldsMap = parseFields(query);
|
|
236
292
|
const modelFields = fieldsMap.get(pluralizedModel) || fieldsMap.get(model);
|
|
237
|
-
//
|
|
238
|
-
|
|
239
|
-
|
|
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
|
+
}
|
|
240
335
|
const { id: _ignoredId, ...sanitizedAttributes } = attributes || {};
|
|
241
336
|
// Extract relationship IDs from JSON:API relationships object
|
|
242
337
|
if (rels) {
|
|
@@ -248,16 +343,69 @@ export default class OrmRequest extends Request {
|
|
|
248
343
|
}
|
|
249
344
|
}
|
|
250
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;
|
|
251
354
|
const created = createRecord(model, recordAttributes, { serialize: false, _skipAutoPersist: true });
|
|
252
355
|
const record = isOrmRecord(created) ? created : null;
|
|
253
356
|
if (!record)
|
|
254
357
|
return 500;
|
|
358
|
+
const createdNewSlot = (store.get(model)?.size ?? 0) > slotsBefore;
|
|
359
|
+
// 403 here, NOT 404. The oracle argument does not apply to create: there
|
|
360
|
+
// is no pre-existing record whose existence could leak, the caller
|
|
361
|
+
// supplied the attributes, and 404 on a mounted collection route is
|
|
362
|
+
// indistinguishable from "model not mounted" -- a genuinely different
|
|
363
|
+
// failure a developer needs to diagnose.
|
|
364
|
+
//
|
|
365
|
+
// The rollback is not optional. createRecord writes to the store BEFORE
|
|
366
|
+
// the predicate can run, so returning 403 alone would leave the record
|
|
367
|
+
// behind: a worse bug than the bypass being fixed.
|
|
368
|
+
if (isDenied(filter, record)) {
|
|
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
|
+
}
|
|
389
|
+
return 403;
|
|
390
|
+
}
|
|
255
391
|
return { data: record.toJSON?.({ fields: modelFields }) };
|
|
256
392
|
};
|
|
257
|
-
const updateHandler = async ({ body, params }) => {
|
|
393
|
+
const updateHandler = async ({ body, params }, { filter }) => {
|
|
258
394
|
const found = await store.find(model, getId(params));
|
|
259
395
|
if (!found || !isOrmRecord(found))
|
|
260
396
|
return 404;
|
|
397
|
+
// Checked BEFORE any attribute is applied. 404 rather than 403 for the
|
|
398
|
+
// same reason as GET /:id -- 403 would disclose both that the record
|
|
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.
|
|
407
|
+
if (isDenied(filter, found))
|
|
408
|
+
return 404;
|
|
261
409
|
const record = found;
|
|
262
410
|
const { attributes, relationships: rels } = (body?.data || {});
|
|
263
411
|
if (!attributes && !rels)
|
|
@@ -288,7 +436,22 @@ export default class OrmRequest extends Request {
|
|
|
288
436
|
}
|
|
289
437
|
return { data: record.toJSON?.() };
|
|
290
438
|
};
|
|
291
|
-
const deleteHandler = ({ params }) => {
|
|
439
|
+
const deleteHandler = async ({ params }, { filter }) => {
|
|
440
|
+
const record = await store.find(model, getId(params));
|
|
441
|
+
// BEHAVIOUR CHANGE (#190): a DELETE of a record that never existed
|
|
442
|
+
// returned 204 before this change. It now returns 404, matching the
|
|
443
|
+
// denied case below. This is deliberate and load-bearing -- if a denied
|
|
444
|
+
// delete returned 404 while a missing one returned 204, the pair would be
|
|
445
|
+
// a perfect existence oracle and the whole fix would be worthless.
|
|
446
|
+
// Returning 204 for a denied delete was rejected instead: it falsely
|
|
447
|
+
// reports success for a request that changed nothing.
|
|
448
|
+
if (!record)
|
|
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.
|
|
453
|
+
if (isDenied(filter, record))
|
|
454
|
+
return 404;
|
|
292
455
|
store.remove(model, getId(params), { _skipAutoPersist: true });
|
|
293
456
|
return 204;
|
|
294
457
|
};
|
|
@@ -314,9 +477,36 @@ export default class OrmRequest extends Request {
|
|
|
314
477
|
};
|
|
315
478
|
}
|
|
316
479
|
}
|
|
317
|
-
// 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
|
+
// ===========================================================================
|
|
318
507
|
_withHooks(operation, handler) {
|
|
319
508
|
return async (request, state) => {
|
|
509
|
+
const { filter } = (state || {});
|
|
320
510
|
// Build context object for hooks
|
|
321
511
|
const context = {
|
|
322
512
|
model: this.model,
|
|
@@ -330,6 +520,26 @@ export default class OrmRequest extends Request {
|
|
|
330
520
|
// Capture old state for operations that modify data
|
|
331
521
|
if (operation === 'update' || operation === 'delete') {
|
|
332
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;
|
|
333
543
|
if (existingRecord) {
|
|
334
544
|
// Deep copy the record's data to preserve old state
|
|
335
545
|
context.oldState = JSON.parse(JSON.stringify(existingRecord.__data || existingRecord));
|
|
@@ -352,9 +562,27 @@ export default class OrmRequest extends Request {
|
|
|
352
562
|
if (operation === 'update' && response?.data) {
|
|
353
563
|
context.record = store.get(this.model, getId(request.params));
|
|
354
564
|
}
|
|
355
|
-
//
|
|
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.
|
|
574
|
+
//
|
|
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.
|
|
580
|
+
//
|
|
581
|
+
// No file-backed test can observe that, because Orm.instance.sqlDb is
|
|
582
|
+
// null in file/directory mode. See the stubbed-sqlDb assertions in
|
|
583
|
+
// test/unit/access-filter-enforcement-test.ts.
|
|
356
584
|
const sqlDb = Orm.instance.sqlDb;
|
|
357
|
-
if (sqlDb && WRITE_OPERATIONS.has(operation)) {
|
|
585
|
+
if (sqlDb && WRITE_OPERATIONS.has(operation) && !denied) {
|
|
358
586
|
await sqlDb.persist(operation, this.model, context, response);
|
|
359
587
|
}
|
|
360
588
|
// Add response and relevant records to context
|
|
@@ -375,12 +603,29 @@ export default class OrmRequest extends Request {
|
|
|
375
603
|
// For delete, the record may no longer exist, but we have oldState
|
|
376
604
|
context.recordId = getId(request.params);
|
|
377
605
|
}
|
|
378
|
-
//
|
|
379
|
-
|
|
380
|
-
|
|
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
|
+
}
|
|
381
623
|
}
|
|
382
|
-
//
|
|
383
|
-
|
|
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) {
|
|
384
629
|
await Orm.db.save();
|
|
385
630
|
}
|
|
386
631
|
return response;
|
|
@@ -392,10 +637,21 @@ export default class OrmRequest extends Request {
|
|
|
392
637
|
// Dasherize the relationship name for URL paths (e.g., accessLinks -> access-links)
|
|
393
638
|
const dasherizedName = camelCaseToKebabCase(relationshipName);
|
|
394
639
|
// Related resource route: GET /:id/{relationship}
|
|
395
|
-
|
|
640
|
+
//
|
|
641
|
+
// These generated routes are not wrapped by _withHooks, which is why they
|
|
642
|
+
// were the least obvious two of the seven unguarded surfaces in #190.
|
|
643
|
+
// They are still dispatched by @stonyx/rest-server as
|
|
644
|
+
// `handler(req, getState(req))`, so `state` -- and therefore the filter
|
|
645
|
+
// planted by auth() -- has always been available here; it was simply
|
|
646
|
+
// never declared or read.
|
|
647
|
+
routes[`/:id/${dasherizedName}`] = async (request, { filter } = {}) => {
|
|
396
648
|
const record = await store.find(model, getId(request.params));
|
|
397
649
|
if (!record)
|
|
398
650
|
return 404;
|
|
651
|
+
// Filtering the PARENT: a caller who may not see the record may not see
|
|
652
|
+
// what it is related to either.
|
|
653
|
+
if (isDenied(filter, record))
|
|
654
|
+
return 404;
|
|
399
655
|
const relatedData = record.__relationships[relationshipName];
|
|
400
656
|
const baseUrl = getBaseUrl(request);
|
|
401
657
|
let data;
|
|
@@ -414,10 +670,12 @@ export default class OrmRequest extends Request {
|
|
|
414
670
|
};
|
|
415
671
|
};
|
|
416
672
|
// Relationship linkage route: GET /:id/relationships/{relationship}
|
|
417
|
-
routes[`/:id/relationships/${dasherizedName}`] = async (request) => {
|
|
673
|
+
routes[`/:id/relationships/${dasherizedName}`] = async (request, { filter } = {}) => {
|
|
418
674
|
const record = await store.find(model, getId(request.params));
|
|
419
675
|
if (!record)
|
|
420
676
|
return 404;
|
|
677
|
+
if (isDenied(filter, record))
|
|
678
|
+
return 404;
|
|
421
679
|
const relatedData = record.__relationships[relationshipName];
|
|
422
680
|
const baseUrl = getBaseUrl(request);
|
|
423
681
|
let data;
|
|
@@ -446,31 +704,59 @@ export default class OrmRequest extends Request {
|
|
|
446
704
|
};
|
|
447
705
|
};
|
|
448
706
|
}
|
|
449
|
-
// Catch-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
//
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
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;
|
|
464
725
|
return routes;
|
|
465
726
|
}
|
|
466
727
|
auth(request, state) {
|
|
467
|
-
|
|
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
|
+
}
|
|
468
740
|
if (!access)
|
|
469
741
|
return 403;
|
|
470
|
-
if (
|
|
471
|
-
return 403;
|
|
472
|
-
if (typeof access === 'function')
|
|
742
|
+
if (typeof access === 'function') {
|
|
473
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;
|
|
474
760
|
return undefined;
|
|
475
761
|
}
|
|
476
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,
|
|
@@ -251,6 +275,33 @@ function createFilterPredicate(filters: Filter[]): ((record: { [key: string]: un
|
|
|
251
275
|
});
|
|
252
276
|
}
|
|
253
277
|
|
|
278
|
+
/**
|
|
279
|
+
* A function-style `access` return is a per-record predicate, and it is only
|
|
280
|
+
* meaningful if every surface that can hand a record to a caller consults it.
|
|
281
|
+
* Before #190 exactly one of seven did.
|
|
282
|
+
*
|
|
283
|
+
* Enforcement is deliberately post-fetch. `access` returns an opaque JS
|
|
284
|
+
* predicate and `store.findAll(model, conditions)` accepts only an equality
|
|
285
|
+
* conditions object that the SQL drivers translate to a WHERE clause, so
|
|
286
|
+
* query-layer enforcement would require a breaking change to the published
|
|
287
|
+
* `access` contract. That belongs in #197, not in a security patch. Six of the
|
|
288
|
+
* seven surfaces fetch by primary key anyway, so this costs exactly one row.
|
|
289
|
+
*/
|
|
290
|
+
function isDenied(filter: unknown, record: unknown): boolean {
|
|
291
|
+
if (typeof filter !== 'function') return false;
|
|
292
|
+
|
|
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
|
+
}
|
|
303
|
+
}
|
|
304
|
+
|
|
254
305
|
export default class OrmRequest extends Request {
|
|
255
306
|
model: string;
|
|
256
307
|
access: (request: unknown) => AccessMethod;
|
|
@@ -287,9 +338,13 @@ export default class OrmRequest extends Request {
|
|
|
287
338
|
});
|
|
288
339
|
};
|
|
289
340
|
|
|
290
|
-
const getSingleHandler: HandlerFn = async (request) => {
|
|
341
|
+
const getSingleHandler: HandlerFn = async (request, { filter }) => {
|
|
291
342
|
const record = await store.find(model, getId(request.params)) as OrmRecord | undefined;
|
|
292
343
|
if (!record) return 404;
|
|
344
|
+
// 404, never 403: the status for "exists but filtered out" must be
|
|
345
|
+
// identical to "does not exist", or the fix trades an authorization
|
|
346
|
+
// bypass for a narrower existence oracle.
|
|
347
|
+
if (isDenied(filter, record)) return 404;
|
|
293
348
|
|
|
294
349
|
const fieldsMap = parseFields(request.query);
|
|
295
350
|
const modelFields = fieldsMap.get(pluralizedModel) || fieldsMap.get(model);
|
|
@@ -301,7 +356,7 @@ export default class OrmRequest extends Request {
|
|
|
301
356
|
});
|
|
302
357
|
};
|
|
303
358
|
|
|
304
|
-
const createHandler: HandlerFn = async ({ body, query }) => {
|
|
359
|
+
const createHandler: HandlerFn = async ({ body, query }, { filter }) => {
|
|
305
360
|
const { type, id, attributes, relationships: rels } = (body?.data || {}) as {
|
|
306
361
|
type?: string;
|
|
307
362
|
id?: string | number;
|
|
@@ -314,8 +369,47 @@ export default class OrmRequest extends Request {
|
|
|
314
369
|
const fieldsMap = parseFields(query);
|
|
315
370
|
const modelFields = fieldsMap.get(pluralizedModel) || fieldsMap.get(model);
|
|
316
371
|
|
|
317
|
-
//
|
|
318
|
-
|
|
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
|
+
}
|
|
319
413
|
|
|
320
414
|
const { id: _ignoredId, ...sanitizedAttributes } = attributes || {};
|
|
321
415
|
|
|
@@ -330,16 +424,73 @@ export default class OrmRequest extends Request {
|
|
|
330
424
|
}
|
|
331
425
|
|
|
332
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
|
+
|
|
333
437
|
const created = createRecord(model, recordAttributes as { [key: string]: unknown }, { serialize: false, _skipAutoPersist: true });
|
|
334
438
|
const record = isOrmRecord(created) ? created : null;
|
|
335
439
|
if (!record) return 500;
|
|
336
440
|
|
|
441
|
+
const createdNewSlot = ((store.get(model) as Map<string | number, unknown> | undefined)?.size ?? 0) > slotsBefore;
|
|
442
|
+
|
|
443
|
+
// 403 here, NOT 404. The oracle argument does not apply to create: there
|
|
444
|
+
// is no pre-existing record whose existence could leak, the caller
|
|
445
|
+
// supplied the attributes, and 404 on a mounted collection route is
|
|
446
|
+
// indistinguishable from "model not mounted" -- a genuinely different
|
|
447
|
+
// failure a developer needs to diagnose.
|
|
448
|
+
//
|
|
449
|
+
// The rollback is not optional. createRecord writes to the store BEFORE
|
|
450
|
+
// the predicate can run, so returning 403 alone would leave the record
|
|
451
|
+
// behind: a worse bug than the bypass being fixed.
|
|
452
|
+
if (isDenied(filter, record)) {
|
|
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
|
+
|
|
474
|
+
return 403;
|
|
475
|
+
}
|
|
476
|
+
|
|
337
477
|
return { data: record.toJSON?.({ fields: modelFields }) };
|
|
338
478
|
};
|
|
339
479
|
|
|
340
|
-
const updateHandler: HandlerFn = async ({ body, params }) => {
|
|
480
|
+
const updateHandler: HandlerFn = async ({ body, params }, { filter }) => {
|
|
341
481
|
const found = await store.find(model, getId(params));
|
|
342
482
|
if (!found || !isOrmRecord(found)) return 404;
|
|
483
|
+
// Checked BEFORE any attribute is applied. 404 rather than 403 for the
|
|
484
|
+
// same reason as GET /:id -- 403 would disclose both that the record
|
|
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.
|
|
493
|
+
if (isDenied(filter, found)) return 404;
|
|
343
494
|
const record = found;
|
|
344
495
|
const { attributes, relationships: rels } = (body?.data || {}) as {
|
|
345
496
|
attributes?: { [key: string]: unknown };
|
|
@@ -375,7 +526,22 @@ export default class OrmRequest extends Request {
|
|
|
375
526
|
return { data: record.toJSON?.() };
|
|
376
527
|
};
|
|
377
528
|
|
|
378
|
-
const deleteHandler: HandlerFn = ({ params }) => {
|
|
529
|
+
const deleteHandler: HandlerFn = async ({ params }, { filter }) => {
|
|
530
|
+
const record = await store.find(model, getId(params));
|
|
531
|
+
|
|
532
|
+
// BEHAVIOUR CHANGE (#190): a DELETE of a record that never existed
|
|
533
|
+
// returned 204 before this change. It now returns 404, matching the
|
|
534
|
+
// denied case below. This is deliberate and load-bearing -- if a denied
|
|
535
|
+
// delete returned 404 while a missing one returned 204, the pair would be
|
|
536
|
+
// a perfect existence oracle and the whole fix would be worthless.
|
|
537
|
+
// Returning 204 for a denied delete was rejected instead: it falsely
|
|
538
|
+
// reports success for a request that changed nothing.
|
|
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.
|
|
543
|
+
if (isDenied(filter, record)) return 404;
|
|
544
|
+
|
|
379
545
|
store.remove(model, getId(params), { _skipAutoPersist: true });
|
|
380
546
|
return 204;
|
|
381
547
|
};
|
|
@@ -405,9 +571,37 @@ export default class OrmRequest extends Request {
|
|
|
405
571
|
}
|
|
406
572
|
}
|
|
407
573
|
|
|
408
|
-
// 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
|
+
// ===========================================================================
|
|
409
601
|
private _withHooks(operation: string, handler: HandlerFn): HandlerFn {
|
|
410
602
|
return async (request: OrmRequest$, state: { [key: string]: unknown }) => {
|
|
603
|
+
const { filter } = (state || {}) as { filter?: unknown };
|
|
604
|
+
|
|
411
605
|
// Build context object for hooks
|
|
412
606
|
const context: HookContext = {
|
|
413
607
|
model: this.model,
|
|
@@ -422,6 +616,27 @@ export default class OrmRequest extends Request {
|
|
|
422
616
|
// Capture old state for operations that modify data
|
|
423
617
|
if (operation === 'update' || operation === 'delete') {
|
|
424
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
|
+
|
|
425
640
|
if (existingRecord) {
|
|
426
641
|
// Deep copy the record's data to preserve old state
|
|
427
642
|
context.oldState = JSON.parse(JSON.stringify(existingRecord.__data || existingRecord));
|
|
@@ -448,9 +663,28 @@ export default class OrmRequest extends Request {
|
|
|
448
663
|
context.record = store.get(this.model, getId(request.params));
|
|
449
664
|
}
|
|
450
665
|
|
|
451
|
-
//
|
|
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.
|
|
676
|
+
//
|
|
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.
|
|
682
|
+
//
|
|
683
|
+
// No file-backed test can observe that, because Orm.instance.sqlDb is
|
|
684
|
+
// null in file/directory mode. See the stubbed-sqlDb assertions in
|
|
685
|
+
// test/unit/access-filter-enforcement-test.ts.
|
|
452
686
|
const sqlDb = Orm.instance.sqlDb;
|
|
453
|
-
if (sqlDb && WRITE_OPERATIONS.has(operation)) {
|
|
687
|
+
if (sqlDb && WRITE_OPERATIONS.has(operation) && !denied) {
|
|
454
688
|
await sqlDb.persist(operation, this.model, context, response);
|
|
455
689
|
}
|
|
456
690
|
|
|
@@ -471,13 +705,30 @@ export default class OrmRequest extends Request {
|
|
|
471
705
|
context.recordId = getId(request.params);
|
|
472
706
|
}
|
|
473
707
|
|
|
474
|
-
//
|
|
475
|
-
|
|
476
|
-
|
|
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
|
+
}
|
|
477
725
|
}
|
|
478
726
|
|
|
479
|
-
//
|
|
480
|
-
|
|
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) {
|
|
481
732
|
await (Orm.db as { save(): Promise<void> }).save();
|
|
482
733
|
}
|
|
483
734
|
|
|
@@ -497,9 +748,19 @@ export default class OrmRequest extends Request {
|
|
|
497
748
|
const dasherizedName = camelCaseToKebabCase(relationshipName);
|
|
498
749
|
|
|
499
750
|
// Related resource route: GET /:id/{relationship}
|
|
500
|
-
|
|
751
|
+
//
|
|
752
|
+
// These generated routes are not wrapped by _withHooks, which is why they
|
|
753
|
+
// were the least obvious two of the seven unguarded surfaces in #190.
|
|
754
|
+
// They are still dispatched by @stonyx/rest-server as
|
|
755
|
+
// `handler(req, getState(req))`, so `state` -- and therefore the filter
|
|
756
|
+
// planted by auth() -- has always been available here; it was simply
|
|
757
|
+
// never declared or read.
|
|
758
|
+
routes[`/:id/${dasherizedName}`] = async (request: OrmRequest$, { filter }: { [key: string]: unknown } = {}) => {
|
|
501
759
|
const record = await store.find(model, getId(request.params)) as OrmRecord | undefined;
|
|
502
760
|
if (!record) return 404;
|
|
761
|
+
// Filtering the PARENT: a caller who may not see the record may not see
|
|
762
|
+
// what it is related to either.
|
|
763
|
+
if (isDenied(filter, record)) return 404;
|
|
503
764
|
|
|
504
765
|
const relatedData = record.__relationships[relationshipName];
|
|
505
766
|
const baseUrl = getBaseUrl(request);
|
|
@@ -521,9 +782,10 @@ export default class OrmRequest extends Request {
|
|
|
521
782
|
};
|
|
522
783
|
|
|
523
784
|
// Relationship linkage route: GET /:id/relationships/{relationship}
|
|
524
|
-
routes[`/:id/relationships/${dasherizedName}`] = async (request: OrmRequest
|
|
785
|
+
routes[`/:id/relationships/${dasherizedName}`] = async (request: OrmRequest$, { filter }: { [key: string]: unknown } = {}) => {
|
|
525
786
|
const record = await store.find(model, getId(request.params)) as OrmRecord | undefined;
|
|
526
787
|
if (!record) return 404;
|
|
788
|
+
if (isDenied(filter, record)) return 404;
|
|
527
789
|
|
|
528
790
|
const relatedData = record.__relationships[relationshipName];
|
|
529
791
|
const baseUrl = getBaseUrl(request);
|
|
@@ -554,32 +816,60 @@ export default class OrmRequest extends Request {
|
|
|
554
816
|
};
|
|
555
817
|
}
|
|
556
818
|
|
|
557
|
-
// Catch-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
//
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
}
|
|
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;
|
|
573
837
|
|
|
574
838
|
return routes;
|
|
575
839
|
}
|
|
576
840
|
|
|
577
841
|
auth(request: OrmRequest$, state: { [key: string]: unknown }): number | undefined {
|
|
578
|
-
|
|
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
|
+
}
|
|
579
853
|
|
|
580
854
|
if (!access) return 403;
|
|
581
|
-
if (
|
|
582
|
-
|
|
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
|
+
|
|
583
873
|
return undefined;
|
|
584
874
|
}
|
|
585
875
|
}
|