also looking at this
fix(security): toArray() now applies hidden, and toJSON() delegates to it
#1184
Merged
glennmichael123
wants to merge
fix/toarray-hidden
into
main
2 files
+126
-24
| @@ -0,0 +1,88 @@ | ||
| 1 | +/** | |
| 2 | + * `hidden: true` across the serialization boundary — | |
| 3 | + * stacksjs/bun-query-builder#1182. | |
| 4 | + * | |
| 5 | + * `toJSON()` redacted and `toArray()`, written directly beneath it, did not: | |
| 6 | + * | |
| 7 | + * toJSON() {"id":1,"email":"a@b.c"} | |
| 8 | + * toArray() {"id":1,"email":"a@b.c","password":"bcrypt$hash","api_token":"sk_live_abc"} | |
| 9 | + * | |
| 10 | + * Two serializers side by side with opposite behaviour. This API takes its | |
| 11 | + * names from Eloquent, where `toArray()` applies `$hidden` and `toJSON()` is | |
| 12 | + * defined in terms of it, so the expectation someone arrives with is the wrong | |
| 13 | + * one — they reach for `toArray()` in a response serializer and ship the hash. | |
| 14 | + * | |
| 15 | + * `toJSON()` now delegates to `toArray()`, so the two cannot drift again. | |
| 16 | + */ | |
| 17 | + | |
| 18 | +import { describe, expect, it } from 'bun:test' | |
| 19 | +import { createModel } from '../src/orm' | |
| 20 | + | |
| 21 | +function user(): any { | |
| 22 | + const User: any = createModel({ | |
| 23 | + name: 'User', | |
| 24 | + table: 'users', | |
| 25 | + primaryKey: 'id', | |
| 26 | + attributes: { | |
| 27 | + id: { validation: { rule: {} } }, | |
| 28 | + email: { validation: { rule: {} } }, | |
| 29 | + password: { validation: { rule: {} }, hidden: true }, | |
| 30 | + api_token: { validation: { rule: {} }, hidden: true }, | |
| 31 | + }, | |
| 32 | + } as any) | |
| 33 | + return User.make({ id: 1, email: 'a@b.c', password: 'bcrypt$hash', api_token: 'sk_live_abc' }) | |
| 34 | +} | |
| 35 | + | |
| 36 | +describe('hidden attributes are redacted when serialized (#1182)', () => { | |
| 37 | + it('toArray() drops them, which is the regression', () => { | |
| 38 | + const out = user().toArray() | |
| 39 | + expect(out.password).toBeUndefined() | |
| 40 | + expect(out.api_token).toBeUndefined() | |
| 41 | + // Still a usable snapshot of everything else. | |
| 42 | + expect(out.id).toBe(1) | |
| 43 | + expect(out.email).toBe('a@b.c') | |
| 44 | + }) | |
| 45 | + | |
| 46 | + it('toJSON() drops them too, and agrees with toArray()', () => { | |
| 47 | + const u = user() | |
| 48 | + expect(JSON.stringify(u.toJSON())).toBe(JSON.stringify(u.toArray())) | |
| 49 | + }) | |
| 50 | + | |
| 51 | + it('JSON.stringify of the model drops them', () => { | |
| 52 | + const encoded = JSON.stringify(user()) | |
| 53 | + expect(encoded).not.toContain('bcrypt') | |
| 54 | + expect(encoded).not.toContain('sk_live') | |
| 55 | + }) | |
| 56 | + | |
| 57 | + it('neither serializer leaks the secret as a substring', () => { | |
| 58 | + // Blunt, but it is the property that actually matters at a response | |
| 59 | + // boundary: the value must not appear anywhere in the payload. | |
| 60 | + for (const payload of [JSON.stringify(user().toArray()), JSON.stringify(user().toJSON())]) { | |
| 61 | + expect(payload).not.toContain('bcrypt$hash') | |
| 62 | + expect(payload).not.toContain('sk_live_abc') | |
| 63 | + } | |
| 64 | + }) | |
| 65 | + | |
| 66 | + it('getAttributes() still returns everything, deliberately', () => { | |
| 67 | + // The raw accessor: dirty-tracking and diffing need the whole row. It is | |
| 68 | + // documented as unredacted, which is the difference between this and the | |
| 69 | + // toArray() bug. | |
| 70 | + const attrs = user().getAttributes() | |
| 71 | + expect(attrs.password).toBe('bcrypt$hash') | |
| 72 | + }) | |
| 73 | +}) | |
| 74 | + | |
| 75 | +describe('redaction reaches eager-loaded relations (#1182)', () => { | |
| 76 | + it('a related model is serialized through the same filter', () => { | |
| 77 | + // toArray() recurses with toArray(), so before the fix an eager-loaded | |
| 78 | + // relation carried the related model's secrets into the parent payload — | |
| 79 | + // the same propagation shape as #1093. | |
| 80 | + const parent = user() | |
| 81 | + parent._relations = { author: user(), readers: [user(), user()] } | |
| 82 | + | |
| 83 | + const out: any = parent.toArray() | |
| 84 | + expect(out.author.password).toBeUndefined() | |
| 85 | + expect(out.readers[0].password).toBeUndefined() | |
| 86 | + expect(JSON.stringify(out)).not.toContain('bcrypt$hash') | |
| 87 | + }) | |
| 88 | +}) | |