@@ -270,6 +270,179 @@ it("Can attach/dettach database", () => {
270270 db2 . delete ( ) ;
271271} ) ;
272272
273+ it ( "Neutralizes SQL injection payload in attach alias" , ( ) => {
274+ if ( isTurso ( ) ) {
275+ return ;
276+ }
277+ const db = open ( {
278+ name : "attachInjectionTest.sqlite" ,
279+ encryptionKey : "test" ,
280+ } ) ;
281+ let db2 = open ( {
282+ name : "attachInjectionTest2.sqlite" ,
283+ encryptionKey : "test" ,
284+ } ) ;
285+ db2 . close ( ) ;
286+
287+ // Pre-fix, `opsqlite_execute` walked remainingStatement, so the trailing
288+ // `ATTACH ... AS pwned` would have run as a second prepared statement.
289+ // Post-fix the alias is passed via parameter binding (`ATTACH DATABASE ?
290+ // AS ?`), so the whole payload is a single TEXT value used as the
291+ // schema-name; neither `pwned` nor `evil` ends up attached. The
292+ // `:memory:` target in the payload keeps the proof side-effect-free
293+ // across sandboxes in case the pre-fix code path is ever reintroduced.
294+ const maliciousAlias = "evil; ATTACH DATABASE ':memory:' AS pwned; --" ;
295+
296+ db . attach ( {
297+ secondaryDbFileName : "attachInjectionTest2.sqlite" ,
298+ alias : maliciousAlias ,
299+ } ) ;
300+
301+ let pwnedAttached = false ;
302+ try {
303+ db . executeSync ( "SELECT 1 FROM pwned.sqlite_master LIMIT 1;" ) ;
304+ pwnedAttached = true ;
305+ } catch {
306+ pwnedAttached = false ;
307+ }
308+ expect ( pwnedAttached ) . toBe ( false ) ;
309+
310+ let evilAttached = false ;
311+ try {
312+ db . executeSync ( "SELECT 1 FROM evil.sqlite_master LIMIT 1;" ) ;
313+ evilAttached = true ;
314+ } catch {
315+ evilAttached = false ;
316+ }
317+ expect ( evilAttached ) . toBe ( false ) ;
318+
319+ db . detach ( maliciousAlias ) ;
320+
321+ db . delete ( ) ;
322+
323+ db2 = open ( {
324+ name : "attachInjectionTest2.sqlite" ,
325+ encryptionKey : "test" ,
326+ } ) ;
327+ db2 . delete ( ) ;
328+ } ) ;
329+
330+ it ( "Neutralizes SQL injection payload in attach path" , ( ) => {
331+ if ( isTurso ( ) ) {
332+ return ;
333+ }
334+ // `opsqlite_get_db_path` just concatenates location + filename, so any
335+ // quote in the filename used to escape the surrounding string literal
336+ // in `ATTACH DATABASE '...'`. Post-fix the path is passed via parameter
337+ // binding, so the embedded quote is just data — no SQL involvement.
338+ const quirkyFileName = "attach'Injection.sqlite" ;
339+ const db = open ( {
340+ name : "attachPathHostDb.sqlite" ,
341+ encryptionKey : "test" ,
342+ } ) ;
343+ const db2 = open ( {
344+ name : quirkyFileName ,
345+ encryptionKey : "test" ,
346+ } ) ;
347+ db2 . close ( ) ;
348+
349+ db . attach ( {
350+ secondaryDbFileName : quirkyFileName ,
351+ alias : "quirky" ,
352+ } ) ;
353+ db . executeSync ( "DROP TABLE IF EXISTS quirky.canary;" ) ;
354+ db . executeSync (
355+ "CREATE TABLE IF NOT EXISTS quirky.canary (id INTEGER PRIMARY KEY);" ,
356+ ) ;
357+ db . executeSync ( "INSERT INTO quirky.canary (id) VALUES (1);" ) ;
358+ const rows = db . executeSync ( "SELECT id FROM quirky.canary;" ) . rows ;
359+ expect ( rows [ 0 ] ! . id ) . toBe ( 1 ) ;
360+
361+ db . detach ( "quirky" ) ;
362+ db . delete ( ) ;
363+
364+ open ( { name : quirkyFileName , encryptionKey : "test" } ) . delete ( ) ;
365+ } ) ;
366+
367+ it ( "Detach with injection payload does not execute trailing SQL" , ( ) => {
368+ if ( isTurso ( ) ) {
369+ return ;
370+ }
371+ const db = open ( {
372+ name : "detachInjectionTest.sqlite" ,
373+ encryptionKey : "test" ,
374+ } ) ;
375+ const secondary = open ( {
376+ name : "detachInjectionTest2.sqlite" ,
377+ encryptionKey : "test" ,
378+ } ) ;
379+ secondary . close ( ) ;
380+
381+ db . executeSync ( "DROP TABLE IF EXISTS canary;" ) ;
382+ db . executeSync ( "CREATE TABLE canary (id INTEGER PRIMARY KEY);" ) ;
383+ db . executeSync ( "INSERT INTO canary (id) VALUES (42);" ) ;
384+
385+ // Attach a *real* schema named `safe` so the leading DETACH actually
386+ // resolves pre-fix. The trailing `DROP TABLE canary; --` would then
387+ // run as a second prepared statement and the canary row would be gone.
388+ // Post-fix the alias is passed via parameter binding, so the whole
389+ // payload is the schema-name to detach; nothing matches. The core
390+ // proof is that `canary` survives — backends differ on whether a
391+ // missing-schema DETACH errors (sqlite/sqlcipher) or returns cleanly
392+ // (libsql), so we don't assert on the throw shape.
393+ db . attach ( {
394+ secondaryDbFileName : "detachInjectionTest2.sqlite" ,
395+ alias : "safe" ,
396+ } ) ;
397+
398+ try {
399+ db . detach ( "safe; DROP TABLE canary; --" ) ;
400+ } catch {
401+ // Ignored — only the canary check below is load-bearing.
402+ }
403+
404+ const rows = db . executeSync ( "SELECT id FROM canary;" ) . rows ;
405+ expect ( rows . length ) . toBe ( 1 ) ;
406+ expect ( rows [ 0 ] ! . id ) . toBe ( 42 ) ;
407+
408+ // Best-effort cleanup of `safe`. On backends where the malicious
409+ // detach above did not throw, libsql may also have detached the
410+ // `safe` schema (treating the bound text as a literal alias-name
411+ // that didn't match) or left it attached; either way, swallow the
412+ // error so cleanup doesn't fail the test.
413+ try {
414+ db . detach ( "safe" ) ;
415+ } catch {
416+ // Already detached or never attached — ignore.
417+ }
418+ db . delete ( ) ;
419+
420+ open ( { name : "detachInjectionTest2.sqlite" , encryptionKey : "test" } ) . delete ( ) ;
421+ } ) ;
422+
423+ if ( isSQLCipher ( ) ) {
424+ it ( "Encryption key with single quote survives a round-trip" , ( ) => {
425+ // Prior code embedded the key directly into `PRAGMA key = '<key>'`,
426+ // so a quote in the key would either error out or silently set a
427+ // truncated key. Post-fix the key is set via `sqlite3_key_v2`,
428+ // which takes a binary buffer + length — preserving the key
429+ // exactly, so reopening with the same key still decrypts.
430+ const trickyKey = "p'a''ss\"wrd" ;
431+ const dbName = "pragmaKeyInjectionTest.sqlite" ;
432+
433+ let db = open ( { name : dbName , encryptionKey : trickyKey } ) ;
434+ db . executeSync ( "DROP TABLE IF EXISTS secret;" ) ;
435+ db . executeSync ( "CREATE TABLE secret (value TEXT);" ) ;
436+ db . executeSync ( "INSERT INTO secret (value) VALUES ('ok');" ) ;
437+ db . close ( ) ;
438+
439+ db = open ( { name : dbName , encryptionKey : trickyKey } ) ;
440+ const rows = db . executeSync ( "SELECT value FROM secret;" ) . rows ;
441+ expect ( rows [ 0 ] ! . value ) . toBe ( "ok" ) ;
442+ db . delete ( ) ;
443+ } ) ;
444+ }
445+
273446it ( "Can get db path" , ( ) => {
274447 const db = open ( {
275448 name : "pathTest.sqlite" ,
0 commit comments