diff --git a/.jules/sentinel.md b/.jules/sentinel.md index 3a5ef27423..f03130bdf6 100644 --- a/.jules/sentinel.md +++ b/.jules/sentinel.md @@ -7,3 +7,8 @@ **Vulnerability:** The daemon configuration file (`~/.agor/config.yaml`) and its parent directory (`~/.agor`) were created with default file permissions (e.g., `0o755`/`0o644`), which made them readable by other users on the system. This file stores extremely sensitive information such as API keys and master JWT secrets. **Learning:** Default Node.js filesystem operations (`fs.writeFile` and `fs.mkdir`) do not enforce strict permissions unless explicitly specified with a `mode` parameter. When handling sensitive files, relying on the system `umask` is insufficient. **Prevention:** Always specify `mode: 0o600` for sensitive files and `mode: 0o700` for their parent directories. Additionally, use `fs.chmod` to retroactively secure existing files and directories that might have been created with permissive defaults. + +## 2026-07-23 - [SQL Injection Risk via sql.raw in jsonExtract] +**Vulnerability:** The `jsonExtract` function in `packages/core/src/db/database-wrapper.ts` used `sql.raw` to insert JSON path keys into PostgreSQL `->` and `->>` operators without parameterization. +**Learning:** Using `sql.raw` directly injects strings into the SQL query, bypassing parameterization and creating a potential SQL injection vulnerability if any JSON path keys are derived from user input. +**Prevention:** In Drizzle ORM, never use `sql.raw()` to interpolate dynamic variables. To prevent SQL injection when working with PostgreSQL JSON operators (`->`, `->>`), always explicitly cast dynamic parameters to text (e.g., `sql\`->> (${key}::text)\``) to properly resolve the overloaded operator type. Use `sql.join` to safely combine these parameterized template literals when chaining multi-level paths. diff --git a/packages/core/src/db/database-wrapper.ts b/packages/core/src/db/database-wrapper.ts index d52d553e64..0f780ff0d3 100644 --- a/packages/core/src/db/database-wrapper.ts +++ b/packages/core/src/db/database-wrapper.ts @@ -105,15 +105,14 @@ export function jsonExtract(db: Database, column: SQL.Aliased | SQL | any, path: } else { // PostgreSQL: column->'path'->'to'->>'field' // Use -> for all but the last part (keeps as JSON), ->> for the last part (extracts as text) - // IMPORTANT: Use sql.raw() for JSON keys to avoid parameterization if (parts.length === 1) { // Single level: column->>'key' - return sql`${column}${sql.raw(`->>'${parts[0]}'`)}`; + return sql`${column} ->> (${parts[0]}::text)`; } else { // Multiple levels: column->'key1'->'key2'->>'key3' - const objectParts = parts.slice(0, -1).map((p) => sql.raw(`->'${p}'`)); + const objectParts = parts.slice(0, -1).map((p) => sql`-> (${p}::text)`); const lastPart = parts[parts.length - 1]; - return sql`${column}${sql.join(objectParts, sql``)}${sql.raw(`->>'${lastPart}'`)}`; + return sql`${column} ${sql.join(objectParts, sql` `)} ->> (${lastPart}::text)`; } } } diff --git a/test-drizzle.ts b/test-drizzle.ts new file mode 100644 index 0000000000..011347e604 --- /dev/null +++ b/test-drizzle.ts @@ -0,0 +1,9 @@ +import { sql } from 'drizzle-orm'; + +const key = 'test'; +console.log( + sql`->> (${key}::text)`.toQuery({ + escapeString: (str) => str, + escapeParam: (num, val) => `$${num}`, + }) +); diff --git a/test2.ts b/test2.ts new file mode 100644 index 0000000000..589aeb8b77 --- /dev/null +++ b/test2.ts @@ -0,0 +1,6 @@ +import { sql } from 'drizzle-orm'; + +const key = 'test'; +console.log( + sql`->'${key}'`.toQuery({ escapeString: (str) => str, escapeParam: (num, val) => `$${num}` }) +); diff --git a/test3.ts b/test3.ts new file mode 100644 index 0000000000..fe09e80a17 --- /dev/null +++ b/test3.ts @@ -0,0 +1,9 @@ +import { sql } from 'drizzle-orm'; + +const key = 'test'; +console.log( + sql`-> (${key}::text)`.toQuery({ + escapeString: (str) => str, + escapeParam: (num, val) => `$${num}`, + }) +); diff --git a/test4.ts b/test4.ts new file mode 100644 index 0000000000..bc8c5959c5 --- /dev/null +++ b/test4.ts @@ -0,0 +1,12 @@ +import { sql } from 'drizzle-orm'; + +const parts = ['genealogy', 'parent_session_id']; +const column = sql`sessions.data`; +const objectParts = parts.slice(0, -1).map((p) => sql`-> (${p}::text)`); +const lastPart = parts[parts.length - 1]; +console.log( + sql`${column} ${sql.join(objectParts, sql` `)} ->> (${lastPart}::text)`.toQuery({ + escapeString: (str) => str, + escapeParam: (num, val) => `$${num}`, + }) +);