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.
- package/.turbo/turbo-build.log +4 -0
- package/CHANGELOG.md +25 -0
- package/LICENSE +21 -0
- package/README.md +476 -0
- package/dist/ai-database-adapter.d.ts +49 -0
- package/dist/ai-database-adapter.d.ts.map +1 -0
- package/dist/ai-database-adapter.js +89 -0
- package/dist/ai-database-adapter.js.map +1 -0
- package/dist/errors.d.ts +47 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +72 -0
- package/dist/errors.js.map +1 -0
- package/dist/http-schemas.d.ts +165 -0
- package/dist/http-schemas.d.ts.map +1 -0
- package/dist/http-schemas.js +55 -0
- package/dist/http-schemas.js.map +1 -0
- package/dist/index.d.ts +29 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +32 -0
- package/dist/index.js.map +1 -0
- package/dist/linguistic.d.ts +54 -0
- package/dist/linguistic.d.ts.map +1 -0
- package/dist/linguistic.js +226 -0
- package/dist/linguistic.js.map +1 -0
- package/dist/memory-provider.d.ts +46 -0
- package/dist/memory-provider.d.ts.map +1 -0
- package/dist/memory-provider.js +279 -0
- package/dist/memory-provider.js.map +1 -0
- package/dist/ns-client.d.ts +88 -0
- package/dist/ns-client.d.ts.map +1 -0
- package/dist/ns-client.js +253 -0
- package/dist/ns-client.js.map +1 -0
- package/dist/ns-exports.d.ts +23 -0
- package/dist/ns-exports.d.ts.map +1 -0
- package/dist/ns-exports.js +21 -0
- package/dist/ns-exports.js.map +1 -0
- package/dist/ns.d.ts +60 -0
- package/dist/ns.d.ts.map +1 -0
- package/dist/ns.js +818 -0
- package/dist/ns.js.map +1 -0
- package/dist/r2-persistence.d.ts +112 -0
- package/dist/r2-persistence.d.ts.map +1 -0
- package/dist/r2-persistence.js +252 -0
- package/dist/r2-persistence.js.map +1 -0
- package/dist/schema-validation.d.ts +80 -0
- package/dist/schema-validation.d.ts.map +1 -0
- package/dist/schema-validation.js +233 -0
- package/dist/schema-validation.js.map +1 -0
- package/dist/types.d.ts +184 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +26 -0
- package/dist/types.js.map +1 -0
- package/package.json +55 -0
- package/src/ai-database-adapter.test.ts +610 -0
- package/src/ai-database-adapter.ts +189 -0
- package/src/benchmark.test.ts +109 -0
- package/src/errors.ts +91 -0
- package/src/http-schemas.ts +67 -0
- package/src/index.ts +87 -0
- package/src/linguistic.test.ts +1107 -0
- package/src/linguistic.ts +253 -0
- package/src/memory-provider.ts +470 -0
- package/src/ns-client.test.ts +1360 -0
- package/src/ns-client.ts +342 -0
- package/src/ns-exports.ts +23 -0
- package/src/ns.test.ts +1381 -0
- package/src/ns.ts +1215 -0
- package/src/provider.test.ts +675 -0
- package/src/r2-persistence.test.ts +263 -0
- package/src/r2-persistence.ts +367 -0
- package/src/schema-validation.test.ts +167 -0
- package/src/schema-validation.ts +330 -0
- package/src/types.ts +252 -0
- package/test/action-status.test.ts +42 -0
- package/test/batch-limits.test.ts +165 -0
- package/test/docs.test.ts +48 -0
- package/test/errors.test.ts +148 -0
- package/test/http-validation.test.ts +401 -0
- package/test/ns-client-errors.test.ts +208 -0
- package/test/ns-namespace.test.ts +307 -0
- package/test/performance.test.ts +168 -0
- package/test/schema-validation-error.test.ts +213 -0
- package/test/schema-validation.test.ts +440 -0
- package/test/search-escaping.test.ts +359 -0
- package/test/security.test.ts +322 -0
- package/tsconfig.json +10 -0
- 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