|
| 1 | +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | +// |
| 3 | +// [#3933] The bulk write routes used to spread the request body OVER |
| 4 | +// `object: req.params.object`, so a body key moved the write to a different |
| 5 | +// object than the one in the URL. `enforceApiAccess` gates on `req.params.object` |
| 6 | +// — so `enable.apiEnabled` / `enable.apiMethods` (ADR-0049 / #1889) was enforced |
| 7 | +// on the object in the PATH while the object in the BODY got written. The path |
| 8 | +// object is now written last, and the body is parsed against the spec contract |
| 9 | +// (which also strips a body `context`). |
| 10 | + |
| 11 | +import { describe, it, expect, vi } from 'vitest'; |
| 12 | +import { RestServer } from './rest-server'; |
| 13 | + |
| 14 | +const UPDATE_MANY = '/api/v1/data/:object/updateMany'; |
| 15 | +const DELETE_MANY = '/api/v1/data/:object/deleteMany'; |
| 16 | + |
| 17 | +function createMockServer() { |
| 18 | + return { |
| 19 | + get: vi.fn(), post: vi.fn(), put: vi.fn(), delete: vi.fn(), patch: vi.fn(), use: vi.fn(), |
| 20 | + listen: vi.fn().mockResolvedValue(undefined), close: vi.fn().mockResolvedValue(undefined), |
| 21 | + }; |
| 22 | +} |
| 23 | + |
| 24 | +function makeRes() { |
| 25 | + const res: any = { statusCode: 200, body: undefined }; |
| 26 | + res.status = vi.fn((c: number) => { res.statusCode = c; return res; }); |
| 27 | + res.json = vi.fn((b: any) => { res.body = b; return res; }); |
| 28 | + res.setHeader = vi.fn(); res.write = vi.fn(); res.end = vi.fn(); |
| 29 | + return res; |
| 30 | +} |
| 31 | + |
| 32 | +/** |
| 33 | + * `open` is exposed to the API; `locked` is not. The exploit points the URL at |
| 34 | + * `open` (so the gate clears) and names `locked` in the body. |
| 35 | + */ |
| 36 | +function setup() { |
| 37 | + const updateManyData = vi.fn().mockResolvedValue({ |
| 38 | + success: true, operation: 'update', total: 1, succeeded: 1, failed: 0, results: [], |
| 39 | + }); |
| 40 | + const deleteManyData = vi.fn().mockResolvedValue({ |
| 41 | + success: true, operation: 'delete', total: 1, succeeded: 1, failed: 0, results: [], |
| 42 | + }); |
| 43 | + const protocol: any = { |
| 44 | + getDiscovery: vi.fn().mockResolvedValue({ version: 'v0', endpoints: { data: '', metadata: '', ui: '', auth: '/auth' } }), |
| 45 | + getMetaTypes: vi.fn().mockResolvedValue([]), |
| 46 | + getMetaItems: vi.fn().mockResolvedValue([ |
| 47 | + { name: 'open' }, |
| 48 | + { name: 'locked', enable: { apiEnabled: false } }, |
| 49 | + ]), |
| 50 | + getMetaItem: vi.fn().mockResolvedValue({}), |
| 51 | + findData: vi.fn().mockResolvedValue([]), |
| 52 | + updateManyData, |
| 53 | + deleteManyData, |
| 54 | + }; |
| 55 | + const rest = new RestServer(createMockServer() as any, protocol, { api: { requireAuth: false } } as any); |
| 56 | + rest.registerRoutes(); |
| 57 | + return { rest, updateManyData, deleteManyData }; |
| 58 | +} |
| 59 | + |
| 60 | +async function post(rest: any, path: string, object: string, body: any) { |
| 61 | + const route = rest.getRoutes().find((r: any) => r.method === 'POST' && r.path === path); |
| 62 | + if (!route) throw new Error(`route not registered: ${path}`); |
| 63 | + const res = makeRes(); |
| 64 | + await route.handler({ method: 'POST', params: { object }, query: {}, body }, res); |
| 65 | + return res; |
| 66 | +} |
| 67 | + |
| 68 | +const ONE_UPDATE = [{ id: 'r1', data: { name: 'x' } }]; |
| 69 | + |
| 70 | +describe('bulk writes bind to the object in the PATH (#3933)', () => { |
| 71 | + it('updateMany: a body `object` cannot redirect the write', async () => { |
| 72 | + const { rest, updateManyData } = setup(); |
| 73 | + const res = await post(rest, UPDATE_MANY, 'open', { object: 'locked', records: ONE_UPDATE }); |
| 74 | + |
| 75 | + expect(res.statusCode).toBe(200); |
| 76 | + expect(updateManyData.mock.calls[0][0].object).toBe('open'); |
| 77 | + }); |
| 78 | + |
| 79 | + it('deleteMany: a body `object` cannot redirect the delete', async () => { |
| 80 | + const { rest, deleteManyData } = setup(); |
| 81 | + const res = await post(rest, DELETE_MANY, 'open', { object: 'locked', ids: ['r1'] }); |
| 82 | + |
| 83 | + expect(res.statusCode).toBe(200); |
| 84 | + expect(deleteManyData.mock.calls[0][0].object).toBe('open'); |
| 85 | + }); |
| 86 | + |
| 87 | + it('the apiEnabled gate cannot be bypassed by naming the locked object in the body', async () => { |
| 88 | + const { rest, updateManyData, deleteManyData } = setup(); |
| 89 | + // Sanity: addressed directly, `locked` is hidden (404, ADR-0049). |
| 90 | + expect((await post(rest, UPDATE_MANY, 'locked', { records: ONE_UPDATE })).statusCode).toBe(404); |
| 91 | + expect((await post(rest, DELETE_MANY, 'locked', { ids: ['r1'] })).statusCode).toBe(404); |
| 92 | + expect(updateManyData).not.toHaveBeenCalled(); |
| 93 | + expect(deleteManyData).not.toHaveBeenCalled(); |
| 94 | + |
| 95 | + // Via the exploit: the gate clears `open`, and `open` is what gets written. |
| 96 | + await post(rest, UPDATE_MANY, 'open', { object: 'locked', records: ONE_UPDATE }); |
| 97 | + await post(rest, DELETE_MANY, 'open', { object: 'locked', ids: ['r1'] }); |
| 98 | + expect(updateManyData.mock.calls.every((c) => c[0].object === 'open')).toBe(true); |
| 99 | + expect(deleteManyData.mock.calls.every((c) => c[0].object === 'open')).toBe(true); |
| 100 | + }); |
| 101 | +}); |
| 102 | + |
| 103 | +describe('updateMany ingress validation (#3933)', () => { |
| 104 | + it('strips a body-supplied context so the principal cannot be forged', async () => { |
| 105 | + const { rest, updateManyData } = setup(); |
| 106 | + // `requireAuth: false` → no execution context resolves, so a body `context` |
| 107 | + // used to be the only one the protocol (and then the engine) ever saw. |
| 108 | + await post(rest, UPDATE_MANY, 'open', { |
| 109 | + records: ONE_UPDATE, |
| 110 | + context: { userId: 'nobody', isSystem: true, roles: ['admin'] }, |
| 111 | + }); |
| 112 | + |
| 113 | + expect(updateManyData.mock.calls[0][0].context).toBeUndefined(); |
| 114 | + }); |
| 115 | + |
| 116 | + it('forwards a well-formed request unchanged', async () => { |
| 117 | + const { rest, updateManyData } = setup(); |
| 118 | + const res = await post(rest, UPDATE_MANY, 'open', { |
| 119 | + records: ONE_UPDATE, |
| 120 | + options: { atomic: false, continueOnError: true }, |
| 121 | + }); |
| 122 | + |
| 123 | + expect(res.statusCode).toBe(200); |
| 124 | + const req = updateManyData.mock.calls[0][0]; |
| 125 | + expect(req.records).toEqual(ONE_UPDATE); |
| 126 | + expect(req.options).toMatchObject({ atomic: false, continueOnError: true }); |
| 127 | + }); |
| 128 | + |
| 129 | + it('rejects a malformed records list with 400 VALIDATION_FAILED', async () => { |
| 130 | + const { rest, updateManyData } = setup(); |
| 131 | + const res = await post(rest, UPDATE_MANY, 'open', { records: [{ data: { name: 'x' } }] }); |
| 132 | + |
| 133 | + expect(res.statusCode).toBe(400); |
| 134 | + expect(res.body.code).toBe('VALIDATION_FAILED'); |
| 135 | + expect(updateManyData).not.toHaveBeenCalled(); |
| 136 | + }); |
| 137 | +}); |
0 commit comments