digital-objects 1.0.0

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.
Files changed (87) hide show
  1. package/.turbo/turbo-build.log +4 -0
  2. package/CHANGELOG.md +25 -0
  3. package/LICENSE +21 -0
  4. package/README.md +476 -0
  5. package/dist/ai-database-adapter.d.ts +49 -0
  6. package/dist/ai-database-adapter.d.ts.map +1 -0
  7. package/dist/ai-database-adapter.js +89 -0
  8. package/dist/ai-database-adapter.js.map +1 -0
  9. package/dist/errors.d.ts +47 -0
  10. package/dist/errors.d.ts.map +1 -0
  11. package/dist/errors.js +72 -0
  12. package/dist/errors.js.map +1 -0
  13. package/dist/http-schemas.d.ts +165 -0
  14. package/dist/http-schemas.d.ts.map +1 -0
  15. package/dist/http-schemas.js +55 -0
  16. package/dist/http-schemas.js.map +1 -0
  17. package/dist/index.d.ts +29 -0
  18. package/dist/index.d.ts.map +1 -0
  19. package/dist/index.js +32 -0
  20. package/dist/index.js.map +1 -0
  21. package/dist/linguistic.d.ts +54 -0
  22. package/dist/linguistic.d.ts.map +1 -0
  23. package/dist/linguistic.js +226 -0
  24. package/dist/linguistic.js.map +1 -0
  25. package/dist/memory-provider.d.ts +46 -0
  26. package/dist/memory-provider.d.ts.map +1 -0
  27. package/dist/memory-provider.js +279 -0
  28. package/dist/memory-provider.js.map +1 -0
  29. package/dist/ns-client.d.ts +88 -0
  30. package/dist/ns-client.d.ts.map +1 -0
  31. package/dist/ns-client.js +253 -0
  32. package/dist/ns-client.js.map +1 -0
  33. package/dist/ns-exports.d.ts +23 -0
  34. package/dist/ns-exports.d.ts.map +1 -0
  35. package/dist/ns-exports.js +21 -0
  36. package/dist/ns-exports.js.map +1 -0
  37. package/dist/ns.d.ts +60 -0
  38. package/dist/ns.d.ts.map +1 -0
  39. package/dist/ns.js +818 -0
  40. package/dist/ns.js.map +1 -0
  41. package/dist/r2-persistence.d.ts +112 -0
  42. package/dist/r2-persistence.d.ts.map +1 -0
  43. package/dist/r2-persistence.js +252 -0
  44. package/dist/r2-persistence.js.map +1 -0
  45. package/dist/schema-validation.d.ts +80 -0
  46. package/dist/schema-validation.d.ts.map +1 -0
  47. package/dist/schema-validation.js +233 -0
  48. package/dist/schema-validation.js.map +1 -0
  49. package/dist/types.d.ts +184 -0
  50. package/dist/types.d.ts.map +1 -0
  51. package/dist/types.js +26 -0
  52. package/dist/types.js.map +1 -0
  53. package/package.json +55 -0
  54. package/src/ai-database-adapter.test.ts +610 -0
  55. package/src/ai-database-adapter.ts +189 -0
  56. package/src/benchmark.test.ts +109 -0
  57. package/src/errors.ts +91 -0
  58. package/src/http-schemas.ts +67 -0
  59. package/src/index.ts +87 -0
  60. package/src/linguistic.test.ts +1107 -0
  61. package/src/linguistic.ts +253 -0
  62. package/src/memory-provider.ts +470 -0
  63. package/src/ns-client.test.ts +1360 -0
  64. package/src/ns-client.ts +342 -0
  65. package/src/ns-exports.ts +23 -0
  66. package/src/ns.test.ts +1381 -0
  67. package/src/ns.ts +1215 -0
  68. package/src/provider.test.ts +675 -0
  69. package/src/r2-persistence.test.ts +263 -0
  70. package/src/r2-persistence.ts +367 -0
  71. package/src/schema-validation.test.ts +167 -0
  72. package/src/schema-validation.ts +330 -0
  73. package/src/types.ts +252 -0
  74. package/test/action-status.test.ts +42 -0
  75. package/test/batch-limits.test.ts +165 -0
  76. package/test/docs.test.ts +48 -0
  77. package/test/errors.test.ts +148 -0
  78. package/test/http-validation.test.ts +401 -0
  79. package/test/ns-client-errors.test.ts +208 -0
  80. package/test/ns-namespace.test.ts +307 -0
  81. package/test/performance.test.ts +168 -0
  82. package/test/schema-validation-error.test.ts +213 -0
  83. package/test/schema-validation.test.ts +440 -0
  84. package/test/search-escaping.test.ts +359 -0
  85. package/test/security.test.ts +322 -0
  86. package/tsconfig.json +10 -0
  87. package/wrangler.jsonc +16 -0
@@ -0,0 +1,359 @@
1
+ /**
2
+ * Search Escaping Tests for LIKE Wildcard Prevention
3
+ *
4
+ * These tests expose the vulnerability in ns.ts where LIKE wildcards (%, _)
5
+ * in search queries are not escaped, leading to unexpected matching behavior.
6
+ *
7
+ * Issues: aip-cxgw (RED), aip-4v14 (GREEN)
8
+ * Phase: RED (tests should FAIL initially to prove vulnerability exists)
9
+ *
10
+ * Current vulnerable code (ns.ts ~line 766):
11
+ * const q = `%${query.toLowerCase()}%`
12
+ * let sql = `SELECT * FROM things WHERE LOWER(data) LIKE ?`
13
+ *
14
+ * The problem:
15
+ * - "100%" becomes "%100%%" which matches "100" followed by anything
16
+ * - "test_name" becomes "%test_name%" where _ matches any single character
17
+ * - Backslashes are not handled as escape characters
18
+ *
19
+ * Expected fix:
20
+ * function escapeLikePattern(query: string): string {
21
+ * return query.replace(/[%_\\]/g, '\\$&')
22
+ * }
23
+ * const q = `%${escapeLikePattern(query.toLowerCase())}%`
24
+ * // SQL must include: LIKE ? ESCAPE '\'
25
+ */
26
+
27
+ import { describe, it, expect, beforeEach, vi, type Mock } from 'vitest'
28
+ import { NS, type Env } from '../src/ns.js'
29
+ import { MemoryProvider } from '../src/memory-provider.js'
30
+
31
+ // Mock data storage for SQLite testing
32
+ type Row = Record<string, unknown>
33
+
34
+ interface MockSqlStorage {
35
+ exec: Mock<(...args: unknown[]) => { rowsWritten: number } & Iterable<Row>>
36
+ _tables: Map<string, Row[]>
37
+ }
38
+
39
+ const createMockSqlStorage = (): MockSqlStorage => {
40
+ const tables = new Map<string, Row[]>()
41
+ tables.set('nouns', [])
42
+ tables.set('verbs', [])
43
+ tables.set('things', [])
44
+ tables.set('actions', [])
45
+
46
+ const exec = vi.fn((...args: unknown[]) => {
47
+ const sql = args[0] as string
48
+
49
+ if (sql.includes('CREATE TABLE') || sql.includes('CREATE INDEX')) {
50
+ return { rowsWritten: 0, [Symbol.iterator]: () => [][Symbol.iterator]() }
51
+ }
52
+
53
+ if (sql.includes('INSERT INTO things')) {
54
+ const row: Row = {
55
+ id: args[1],
56
+ noun: args[2],
57
+ data: args[3],
58
+ created_at: args[4],
59
+ updated_at: args[5],
60
+ }
61
+ tables.get('things')!.push(row)
62
+ return { rowsWritten: 1, [Symbol.iterator]: () => [][Symbol.iterator]() }
63
+ }
64
+
65
+ // Handle search queries with LIKE
66
+ if (sql.includes('SELECT * FROM things WHERE LOWER(data) LIKE')) {
67
+ const pattern = args[1] as string
68
+ // Simulate SQLite LIKE behavior
69
+ // Convert SQL LIKE pattern to regex for simulation
70
+ // Check for ESCAPE '\' clause (note: in JS, ESCAPE '\\' in template literal = ESCAPE '\' in string)
71
+ const hasEscape = sql.includes("ESCAPE '\\'")
72
+ let regexPattern = pattern
73
+
74
+ if (hasEscape) {
75
+ // Process escaped characters: \% -> literal %, \_ -> literal _, \\ -> literal \
76
+ // We need to escape regex special chars in the result literals
77
+ regexPattern = regexPattern
78
+ .replace(/\\\\/g, '\x00') // Temporarily replace \\ (escaped backslash)
79
+ .replace(/\\%/g, '\x01') // Temporarily replace \% (escaped percent)
80
+ .replace(/\\_/g, '\x02') // Temporarily replace \_ (escaped underscore)
81
+ .replace(/%/g, '.*') // LIKE % -> regex .*
82
+ .replace(/_/g, '.') // LIKE _ -> regex .
83
+ .replace(/\x00/g, '\\\\') // Restore literal backslash (escaped for regex)
84
+ .replace(/\x01/g, '%') // Restore literal %
85
+ .replace(/\x02/g, '_') // Restore literal _
86
+ } else {
87
+ // No escape handling - current vulnerable behavior
88
+ regexPattern = regexPattern
89
+ .replace(/%/g, '.*') // LIKE % -> regex .*
90
+ .replace(/_/g, '.') // LIKE _ -> regex .
91
+ }
92
+
93
+ try {
94
+ const regex = new RegExp(`^${regexPattern}$`, 'i')
95
+ const results = tables.get('things')!.filter((t) => {
96
+ const dataStr = (t.data as string).toLowerCase()
97
+ return regex.test(dataStr)
98
+ })
99
+ return { rowsWritten: 0, [Symbol.iterator]: () => results[Symbol.iterator]() }
100
+ } catch {
101
+ return { rowsWritten: 0, [Symbol.iterator]: () => [][Symbol.iterator]() }
102
+ }
103
+ }
104
+
105
+ if (sql.includes('SELECT * FROM things WHERE noun = ?')) {
106
+ const noun = args[1]
107
+ const results = tables.get('things')!.filter((t) => t.noun === noun)
108
+ return { rowsWritten: 0, [Symbol.iterator]: () => results[Symbol.iterator]() }
109
+ }
110
+
111
+ return { rowsWritten: 0, [Symbol.iterator]: () => [][Symbol.iterator]() }
112
+ })
113
+
114
+ return { exec, _tables: tables }
115
+ }
116
+
117
+ const createMockState = (mockSql: MockSqlStorage) => ({
118
+ storage: { sql: mockSql },
119
+ })
120
+
121
+ const createMockEnv = (): Env => ({
122
+ NS: {
123
+ idFromName: vi.fn(),
124
+ get: vi.fn(),
125
+ } as unknown as DurableObjectNamespace,
126
+ })
127
+
128
+ describe('Security: LIKE Wildcard Escaping in Search', () => {
129
+ describe('NS (SQLite Provider)', () => {
130
+ let ns: NS
131
+ let mockSql: MockSqlStorage
132
+
133
+ beforeEach(() => {
134
+ mockSql = createMockSqlStorage()
135
+ const mockState = createMockState(mockSql)
136
+ const mockEnv = createMockEnv()
137
+ ns = new NS(mockState as unknown as DurableObjectState, mockEnv)
138
+ })
139
+
140
+ describe('percent (%) wildcard escaping', () => {
141
+ /**
142
+ * TEST 1: Search for literal "100%" should match exactly
143
+ *
144
+ * Without escaping: "100%" becomes "%100%%" in SQL LIKE
145
+ * This matches "100" followed by anything, not just literal "100%"
146
+ *
147
+ * This test should FAIL because % is not escaped.
148
+ */
149
+ it('should match literal percent sign, not treat it as wildcard', async () => {
150
+ // Create test data
151
+ await ns.create('Product', { name: '100% Complete', price: 50 })
152
+ await ns.create('Product', { name: '100 Items', price: 100 })
153
+ await ns.create('Product', { name: '100 Dollars', price: 100 })
154
+
155
+ // Search for literal "100%"
156
+ const results = await ns.search('100%')
157
+
158
+ // Should ONLY match "100% Complete", not "100 Items" or "100 Dollars"
159
+ expect(results.length).toBe(1)
160
+ expect((results[0].data as { name: string }).name).toBe('100% Complete')
161
+ })
162
+
163
+ /**
164
+ * TEST 2: Search with % at end should not match everything
165
+ *
166
+ * Searching for "sale%" should match literal "sale%" in data,
167
+ * not every record starting with "sale"
168
+ */
169
+ it('should not match all records starting with prefix when % is in query', async () => {
170
+ await ns.create('Promo', { code: 'SALE50' })
171
+ await ns.create('Promo', { code: 'SALE%OFF' })
172
+ await ns.create('Promo', { code: 'SALESDAY' })
173
+
174
+ const results = await ns.search('SALE%')
175
+
176
+ // Should only match "SALE%OFF" which contains literal "SALE%"
177
+ expect(results.length).toBe(1)
178
+ expect((results[0].data as { code: string }).code).toBe('SALE%OFF')
179
+ })
180
+ })
181
+
182
+ describe('underscore (_) wildcard escaping', () => {
183
+ /**
184
+ * TEST 3: Search for literal underscore should match exactly
185
+ *
186
+ * Without escaping: "test_name" matches "testXname", "test1name", etc.
187
+ * because _ matches any single character in SQL LIKE
188
+ *
189
+ * This test should FAIL because _ is not escaped.
190
+ */
191
+ it('should match literal underscore, not treat it as single-char wildcard', async () => {
192
+ await ns.create('User', { username: 'test_user' })
193
+ await ns.create('User', { username: 'testXuser' })
194
+ await ns.create('User', { username: 'test1user' })
195
+
196
+ const results = await ns.search('test_user')
197
+
198
+ // Should ONLY match "test_user", not "testXuser" or "test1user"
199
+ expect(results.length).toBe(1)
200
+ expect((results[0].data as { username: string }).username).toBe('test_user')
201
+ })
202
+
203
+ /**
204
+ * TEST 4: Multiple underscores should all be treated as literals
205
+ */
206
+ it('should match multiple literal underscores correctly', async () => {
207
+ await ns.create('Config', { key: 'app__config__key' })
208
+ await ns.create('Config', { key: 'appXXconfigXXkey' })
209
+ await ns.create('Config', { key: 'app12config34key' })
210
+
211
+ const results = await ns.search('app__config')
212
+
213
+ // Should only match the one with literal underscores
214
+ expect(results.length).toBe(1)
215
+ expect((results[0].data as { key: string }).key).toBe('app__config__key')
216
+ })
217
+ })
218
+
219
+ describe('backslash (\\) handling', () => {
220
+ /**
221
+ * TEST 5: Backslash in search query should work correctly
222
+ *
223
+ * Note: The search method searches the JSON-stringified data, not raw values.
224
+ * This means backslashes in data appear doubled in JSON (escaped).
225
+ * To search for "C:\Users" in data, we search for "C:\\Users" (JSON-encoded form).
226
+ *
227
+ * This test verifies backslash doesn't break the LIKE escape mechanism.
228
+ */
229
+ it('should handle backslash in search query correctly', async () => {
230
+ await ns.create('Path', { location: 'C:\\Users\\test' })
231
+ await ns.create('Path', { location: 'C:Userstest' })
232
+ await ns.create('Path', { location: '/home/test' })
233
+
234
+ // Search for the JSON-encoded form (backslashes are doubled in JSON)
235
+ // In JS: 'C:\\\\Users' is the string 'C:\\Users'
236
+ const results = await ns.search('C:\\\\Users')
237
+
238
+ // Should match the path with actual backslashes
239
+ expect(results.length).toBe(1)
240
+ expect((results[0].data as { location: string }).location).toBe('C:\\Users\\test')
241
+ })
242
+ })
243
+
244
+ describe('combined special characters', () => {
245
+ /**
246
+ * TEST 6: Query with both % and _ should match literally
247
+ */
248
+ it('should handle query with both % and _ as literals', async () => {
249
+ await ns.create('Template', { pattern: '100%_complete' })
250
+ await ns.create('Template', { pattern: '100XYcomplete' })
251
+ await ns.create('Template', { pattern: '100%Xcomplete' })
252
+
253
+ const results = await ns.search('100%_')
254
+
255
+ // Should only match the one with literal "100%_"
256
+ expect(results.length).toBe(1)
257
+ expect((results[0].data as { pattern: string }).pattern).toBe('100%_complete')
258
+ })
259
+
260
+ /**
261
+ * TEST 7: Complex pattern with all special chars
262
+ *
263
+ * Note: Backslash in data is JSON-encoded, so to find "path\100%_" we
264
+ * search for "path\\100%_" (the JSON-encoded representation).
265
+ */
266
+ it('should handle complex query with %, _, and backslash', async () => {
267
+ await ns.create('Data', { value: 'path\\100%_done' })
268
+ await ns.create('Data', { value: 'pathX100XXdone' })
269
+
270
+ // Search for the JSON-encoded form
271
+ // In JS: 'path\\\\100%_' is the string 'path\\100%_'
272
+ const results = await ns.search('path\\\\100%_')
273
+
274
+ // Should match the exact literal pattern
275
+ expect(results.length).toBe(1)
276
+ expect((results[0].data as { value: string }).value).toBe('path\\100%_done')
277
+ })
278
+ })
279
+
280
+ describe('search accuracy with special characters', () => {
281
+ /**
282
+ * TEST 8: Ensure search results are accurate, not overly broad
283
+ */
284
+ it('should return accurate results count with special characters', async () => {
285
+ // Create 10 items, only 1 should match
286
+ await ns.create('Item', { name: 'item_1' })
287
+ await ns.create('Item', { name: 'itemX1' })
288
+ await ns.create('Item', { name: 'itemA1' })
289
+ await ns.create('Item', { name: 'itemB1' })
290
+ await ns.create('Item', { name: 'item21' })
291
+ await ns.create('Item', { name: 'item31' })
292
+ await ns.create('Item', { name: 'item41' })
293
+ await ns.create('Item', { name: 'item51' })
294
+ await ns.create('Item', { name: 'item61' })
295
+ await ns.create('Item', { name: 'item71' })
296
+
297
+ const results = await ns.search('item_1')
298
+
299
+ // Should ONLY match "item_1", not all the others where _ would match any char
300
+ expect(results.length).toBe(1)
301
+ })
302
+ })
303
+ })
304
+
305
+ describe('MemoryProvider', () => {
306
+ let provider: MemoryProvider
307
+
308
+ beforeEach(() => {
309
+ provider = new MemoryProvider()
310
+ })
311
+
312
+ describe('search with special characters', () => {
313
+ /**
314
+ * MemoryProvider uses String.includes() which already treats
315
+ * special characters literally. These tests verify that behavior.
316
+ *
317
+ * Note: MemoryProvider doesn't use SQL LIKE, so it shouldn't have
318
+ * the wildcard problem. These tests document the expected behavior.
319
+ */
320
+
321
+ it('should match literal percent sign correctly', async () => {
322
+ await provider.create('Product', { name: '100% Complete' })
323
+ await provider.create('Product', { name: '100 Items' })
324
+ await provider.create('Product', { name: '100 Dollars' })
325
+
326
+ const results = await provider.search('100%')
327
+
328
+ // MemoryProvider uses includes(), so this should work
329
+ expect(results.length).toBe(1)
330
+ expect((results[0].data as { name: string }).name).toBe('100% Complete')
331
+ })
332
+
333
+ it('should match literal underscore correctly', async () => {
334
+ await provider.create('User', { username: 'test_user' })
335
+ await provider.create('User', { username: 'testXuser' })
336
+ await provider.create('User', { username: 'test1user' })
337
+
338
+ const results = await provider.search('test_user')
339
+
340
+ // MemoryProvider uses includes(), so this should work
341
+ expect(results.length).toBe(1)
342
+ expect((results[0].data as { username: string }).username).toBe('test_user')
343
+ })
344
+
345
+ it('should handle backslash correctly', async () => {
346
+ await provider.create('Path', { location: 'C:\\Users\\test' })
347
+ await provider.create('Path', { location: 'C:Userstest' })
348
+
349
+ // MemoryProvider searches JSON.stringify(data).toLowerCase().includes(query)
350
+ // Backslashes in data are doubled in JSON, so search for JSON-encoded form
351
+ // In JS: 'C:\\\\Users' is the string 'C:\\Users'
352
+ const results = await provider.search('C:\\\\Users')
353
+
354
+ expect(results.length).toBe(1)
355
+ expect((results[0].data as { location: string }).location).toBe('C:\\Users\\test')
356
+ })
357
+ })
358
+ })
359
+ })
@@ -0,0 +1,322 @@
1
+ /**
2
+ * Security Tests for JSON Path Traversal Prevention
3
+ *
4
+ * These tests expose the vulnerability in ns.ts where field names in the `where` clause
5
+ * are not properly validated, allowing JSON path traversal attacks.
6
+ *
7
+ * Issue: aip-gduw
8
+ * Phase: RED (tests should FAIL initially to prove vulnerability exists)
9
+ *
10
+ * Current vulnerable code (ns.ts ~line 598):
11
+ * sql += ` AND json_extract(data, '$.${key}') = ?`
12
+ *
13
+ * The validation function `validateOrderByField` allows:
14
+ * - __proto__ (matches /^[a-zA-Z_][a-zA-Z0-9_]*$/)
15
+ * - constructor (matches /^[a-zA-Z_][a-zA-Z0-9_]*$/)
16
+ *
17
+ * These should be rejected as they could enable prototype pollution or
18
+ * other security issues when the data is processed.
19
+ */
20
+
21
+ import { describe, it, expect, beforeEach, vi, type Mock } from 'vitest'
22
+ import { NS, type Env } from '../src/ns.js'
23
+ import { MemoryProvider } from '../src/memory-provider.js'
24
+
25
+ // Mock data storage for SQLite testing
26
+ type Row = Record<string, unknown>
27
+
28
+ interface MockSqlStorage {
29
+ exec: Mock<(...args: unknown[]) => { rowsWritten: number } & Iterable<Row>>
30
+ _tables: Map<string, Row[]>
31
+ }
32
+
33
+ const createMockSqlStorage = (): MockSqlStorage => {
34
+ const tables = new Map<string, Row[]>()
35
+ tables.set('nouns', [])
36
+ tables.set('verbs', [])
37
+ tables.set('things', [])
38
+ tables.set('actions', [])
39
+
40
+ const exec = vi.fn((...args: unknown[]) => {
41
+ const sql = args[0] as string
42
+
43
+ if (sql.includes('CREATE TABLE') || sql.includes('CREATE INDEX')) {
44
+ return { rowsWritten: 0, [Symbol.iterator]: () => [][Symbol.iterator]() }
45
+ }
46
+
47
+ if (sql.includes('INSERT INTO things')) {
48
+ const row: Row = {
49
+ id: args[1],
50
+ noun: args[2],
51
+ data: args[3],
52
+ created_at: args[4],
53
+ updated_at: args[5],
54
+ }
55
+ tables.get('things')!.push(row)
56
+ return { rowsWritten: 1, [Symbol.iterator]: () => [][Symbol.iterator]() }
57
+ }
58
+
59
+ if (sql.includes('SELECT * FROM things WHERE noun = ?')) {
60
+ const noun = args[1]
61
+ let results = tables.get('things')!.filter((t) => t.noun === noun)
62
+ return { rowsWritten: 0, [Symbol.iterator]: () => results[Symbol.iterator]() }
63
+ }
64
+
65
+ return { rowsWritten: 0, [Symbol.iterator]: () => [][Symbol.iterator]() }
66
+ })
67
+
68
+ return { exec, _tables: tables }
69
+ }
70
+
71
+ const createMockState = (mockSql: MockSqlStorage) => ({
72
+ storage: { sql: mockSql },
73
+ })
74
+
75
+ const createMockEnv = (): Env => ({
76
+ NS: {
77
+ idFromName: vi.fn(),
78
+ get: vi.fn(),
79
+ } as unknown as DurableObjectNamespace,
80
+ })
81
+
82
+ describe('Security: JSON Path Traversal Prevention', () => {
83
+ describe('NS (SQLite Provider)', () => {
84
+ let ns: NS
85
+ let mockSql: MockSqlStorage
86
+
87
+ beforeEach(() => {
88
+ mockSql = createMockSqlStorage()
89
+ const mockState = createMockState(mockSql)
90
+ const mockEnv = createMockEnv()
91
+ ns = new NS(mockState as unknown as DurableObjectState, mockEnv)
92
+ })
93
+
94
+ describe('where clause field validation', () => {
95
+ /**
96
+ * TEST 1: Dots in field names should be rejected
97
+ *
98
+ * A field name like "a.b" would traverse JSON paths:
99
+ * json_extract(data, '$.a.b') accesses data.a.b instead of data['a.b']
100
+ *
101
+ * This test should FAIL because dots ARE already blocked by the regex.
102
+ * (The regex /^[a-zA-Z_][a-zA-Z0-9_]*$/ does not allow dots)
103
+ */
104
+ it('should reject field names containing dots (JSON path traversal)', async () => {
105
+ await ns.create('User', { name: 'Alice', 'a.b': 'secret' })
106
+
107
+ await expect(ns.list('User', { where: { 'a.b': 'secret' } })).rejects.toThrow(
108
+ /invalid.*field|rejected|not allowed/i
109
+ )
110
+ })
111
+
112
+ /**
113
+ * TEST 2: __proto__ should be rejected
114
+ *
115
+ * __proto__ is a dangerous property name that can lead to prototype pollution.
116
+ * Even though we're working with JSON paths, allowing __proto__ as a field name
117
+ * could have security implications when the data is later processed.
118
+ *
119
+ * This test should FAIL because __proto__ matches the current regex.
120
+ * Note: We use JSON.parse to create the where object because object literal syntax
121
+ * { __proto__: value } doesn't create an actual property - it sets the prototype.
122
+ * In real attacks, __proto__ would come from parsed JSON (API requests).
123
+ */
124
+ it('should reject __proto__ field name (prototype pollution prevention)', async () => {
125
+ await ns.create('Config', { setting: 'value' })
126
+
127
+ // Simulate attack vector: __proto__ coming from JSON body (e.g., from HTTP request)
128
+ const maliciousWhere = JSON.parse('{"__proto__": "malicious"}')
129
+ await expect(ns.list('Config', { where: maliciousWhere })).rejects.toThrow(
130
+ /invalid.*field|rejected|not allowed|__proto__|prototype/i
131
+ )
132
+ })
133
+
134
+ /**
135
+ * TEST 3: constructor should be rejected
136
+ *
137
+ * constructor is another dangerous property name often used in prototype pollution.
138
+ *
139
+ * This test should FAIL because constructor matches the current regex.
140
+ */
141
+ it('should reject constructor field name (prototype pollution prevention)', async () => {
142
+ await ns.create('Config', { setting: 'value', constructor: 'malicious' })
143
+
144
+ await expect(ns.list('Config', { where: { constructor: 'malicious' } })).rejects.toThrow(
145
+ /invalid.*field|rejected|not allowed|constructor|prototype/i
146
+ )
147
+ })
148
+
149
+ /**
150
+ * TEST 4: Special JSON path characters should be rejected
151
+ *
152
+ * Characters like [ ] $ @ have special meaning in JSON path expressions
153
+ * and could potentially be used to construct malicious queries.
154
+ *
155
+ * These tests should PASS (chars ARE blocked by the current regex).
156
+ */
157
+ it('should reject field names with square brackets', async () => {
158
+ await ns.create('Data', { items: ['a', 'b'] })
159
+
160
+ await expect(ns.list('Data', { where: { 'items[0]': 'a' } })).rejects.toThrow(
161
+ /invalid.*field|rejected|not allowed/i
162
+ )
163
+ })
164
+
165
+ it('should reject field names with $ character', async () => {
166
+ await ns.create('Data', { value: 42 })
167
+
168
+ await expect(ns.list('Data', { where: { $value: 42 } })).rejects.toThrow(
169
+ /invalid.*field|rejected|not allowed/i
170
+ )
171
+ })
172
+
173
+ it('should reject field names with @ character', async () => {
174
+ await ns.create('Data', { value: 42 })
175
+
176
+ await expect(ns.list('Data', { where: { '@value': 42 } })).rejects.toThrow(
177
+ /invalid.*field|rejected|not allowed/i
178
+ )
179
+ })
180
+
181
+ /**
182
+ * TEST 5: prototype should be rejected
183
+ *
184
+ * Another dangerous prototype-related property name.
185
+ *
186
+ * This test should FAIL because prototype matches the current regex.
187
+ */
188
+ it('should reject prototype field name', async () => {
189
+ await ns.create('Config', { setting: 'value', prototype: 'malicious' })
190
+
191
+ await expect(ns.list('Config', { where: { prototype: 'malicious' } })).rejects.toThrow(
192
+ /invalid.*field|rejected|not allowed|prototype/i
193
+ )
194
+ })
195
+
196
+ /**
197
+ * TEST 6: Valid field names should work
198
+ *
199
+ * Normal field names should continue to work correctly.
200
+ */
201
+ it('should allow valid alphanumeric field names', async () => {
202
+ await ns.create('User', { status: 'active', userName: 'alice', count_1: 5 })
203
+
204
+ // These should NOT throw
205
+ await expect(ns.list('User', { where: { status: 'active' } })).resolves.toBeDefined()
206
+ await expect(ns.list('User', { where: { userName: 'alice' } })).resolves.toBeDefined()
207
+ await expect(ns.list('User', { where: { count_1: 5 } })).resolves.toBeDefined()
208
+ })
209
+ })
210
+ })
211
+
212
+ describe('MemoryProvider', () => {
213
+ let provider: MemoryProvider
214
+
215
+ beforeEach(() => {
216
+ provider = new MemoryProvider()
217
+ })
218
+
219
+ describe('where clause field validation', () => {
220
+ /**
221
+ * MemoryProvider should also validate where clause fields.
222
+ * Currently it has NO validation at all.
223
+ */
224
+
225
+ it('should reject field names containing dots', async () => {
226
+ await provider.create('User', { name: 'Alice', 'a.b': 'secret' })
227
+
228
+ await expect(provider.list('User', { where: { 'a.b': 'secret' } })).rejects.toThrow(
229
+ /invalid.*field|rejected|not allowed/i
230
+ )
231
+ })
232
+
233
+ it('should reject __proto__ field name', async () => {
234
+ await provider.create('Config', { setting: 'value' })
235
+
236
+ // Simulate attack vector: __proto__ coming from JSON body (e.g., from HTTP request)
237
+ const maliciousWhere = JSON.parse('{"__proto__": "malicious"}')
238
+ await expect(provider.list('Config', { where: maliciousWhere })).rejects.toThrow(
239
+ /invalid.*field|rejected|not allowed|__proto__|prototype/i
240
+ )
241
+ })
242
+
243
+ it('should reject constructor field name', async () => {
244
+ await provider.create('Config', { setting: 'value' })
245
+
246
+ await expect(
247
+ provider.list('Config', { where: { constructor: 'malicious' } })
248
+ ).rejects.toThrow(/invalid.*field|rejected|not allowed|constructor|prototype/i)
249
+ })
250
+
251
+ it('should reject prototype field name', async () => {
252
+ await provider.create('Config', { setting: 'value' })
253
+
254
+ await expect(
255
+ provider.list('Config', { where: { prototype: 'malicious' } })
256
+ ).rejects.toThrow(/invalid.*field|rejected|not allowed|prototype/i)
257
+ })
258
+
259
+ it('should reject field names with special JSON path characters', async () => {
260
+ await provider.create('Data', { value: 42 })
261
+
262
+ await expect(provider.list('Data', { where: { 'items[0]': 'a' } })).rejects.toThrow(
263
+ /invalid.*field|rejected|not allowed/i
264
+ )
265
+ await expect(provider.list('Data', { where: { $value: 42 } })).rejects.toThrow(
266
+ /invalid.*field|rejected|not allowed/i
267
+ )
268
+ await expect(provider.list('Data', { where: { '@value': 42 } })).rejects.toThrow(
269
+ /invalid.*field|rejected|not allowed/i
270
+ )
271
+ })
272
+
273
+ it('should allow valid alphanumeric field names', async () => {
274
+ await provider.create('User', { status: 'active', userName: 'alice' })
275
+
276
+ // These should NOT throw
277
+ await expect(provider.list('User', { where: { status: 'active' } })).resolves.toBeDefined()
278
+ await expect(provider.list('User', { where: { userName: 'alice' } })).resolves.toBeDefined()
279
+ })
280
+ })
281
+ })
282
+
283
+ describe('HTTP API', () => {
284
+ let ns: NS
285
+ let mockSql: MockSqlStorage
286
+
287
+ beforeEach(() => {
288
+ mockSql = createMockSqlStorage()
289
+ const mockState = createMockState(mockSql)
290
+ const mockEnv = createMockEnv()
291
+ ns = new NS(mockState as unknown as DurableObjectState, mockEnv)
292
+ })
293
+
294
+ /**
295
+ * HTTP API should also reject dangerous field names in query parameters.
296
+ */
297
+ it('should reject __proto__ in HTTP where filter', async () => {
298
+ await ns.create('Config', { setting: 'value' })
299
+
300
+ const request = new Request(
301
+ 'https://example.com/things?noun=Config&where=' + encodeURIComponent('__proto__=malicious')
302
+ )
303
+ const response = await ns.fetch(request)
304
+
305
+ // Should return 400 Bad Request, not 200
306
+ expect(response.status).toBe(400)
307
+ })
308
+
309
+ it('should reject constructor in HTTP where filter', async () => {
310
+ await ns.create('Config', { setting: 'value' })
311
+
312
+ const request = new Request(
313
+ 'https://example.com/things?noun=Config&where=' +
314
+ encodeURIComponent('constructor=malicious')
315
+ )
316
+ const response = await ns.fetch(request)
317
+
318
+ // Should return 400 Bad Request, not 200
319
+ expect(response.status).toBe(400)
320
+ })
321
+ })
322
+ })
package/tsconfig.json ADDED
@@ -0,0 +1,10 @@
1
+ {
2
+ "extends": "../../tsconfig.base.json",
3
+ "compilerOptions": {
4
+ "rootDir": "src",
5
+ "outDir": "dist",
6
+ "types": ["@cloudflare/workers-types"]
7
+ },
8
+ "include": ["src/**/*"],
9
+ "exclude": ["node_modules", "dist", "**/*.test.ts", "**/*.spec.ts"]
10
+ }