ReviewOS

also looking at this

stacks/bun-query-builder

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
packages/bun-query-builder/test/orm.hidden-attributes.test.tsadded+88-0
Changes to packages/bun-query-builder/test/orm.hidden-attributes.test.ts
@@ -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
18import { describe, expect, it } from 'bun:test'
19import { createModel } from '../src/orm'
20
21function 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
36describe('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
75describe('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})