Skip to content

fix(search): parameterize product search SQL query [vuln_id 51] - #343

Open
devin-ai-integration[bot] wants to merge 1 commit into
developfrom
devin/1789001006-vuln51-search-sqli
Open

devin-ai-integration[bot] wants to merge 1 commit into
developfrom
devin/1789001006-vuln51-search-sqli

Conversation

@devin-ai-integration

@devin-ai-integration devin-ai-integration Bot commented Sep 10, 2026

Copy link
Copy Markdown

Description

Remediates vuln_id 51 — "SQL injection in product search" (CWE-89), confirmed in triage against develop (6a93c4f).

Exploitable path: GET /rest/products/search?q=<payload>routes/search.ts searchProducts() interpolates req.query.q directly into a raw sequelize.query string; the only guard is a 200-char truncation. A payload like qwert'))+UNION+SELECT+id,email,password,'4','5','6','7','8','9'+FROM+Users-- breaks out of the LIKE '%...%' literal and dumps the Users table.

Fix: named Sequelize replacement, so input can never terminate the literal:

models.sequelize.query('SELECT * FROM Products WHERE ((name LIKE :criteria OR description LIKE :criteria) AND deletedAt IS NULL) ORDER BY name', { replacements: { criteria: `%${criteria}%` } })

The 200-char cap and 'undefined' handling are unchanged.

Behaviour change to watch: the intentional unionSqlInjectionChallenge / dbSchemaChallenge are no longer solvable via this endpoint. The API and Cypress tests that asserted the injection succeeds were flipped to assert it fails (regression tests); rsn/cache.json updated for the changed vuln-code-snippet.

Testing: regression tests in test/api/search.test.ts and test/cypress/e2e/search.spec.ts; an offline harness reproducing Sequelize's sqlite injectReplacements confirms no payload escapes the bound literal. The npm registry is blocked in this environment, so lint/tests rely on CI.

Resolved or fixed issue: none

AI Tool Disclosure

  • My contribution does not include any AI-generated content
  • My contribution includes AI-generated content, as disclosed below:
    • AI Tools: Devin
    • LLMs and versions: Devin (Cognition AI)
    • Prompts: Fix security finding vuln_id 51: SQL injection in product search (CWE-89); open a PR with regression test

Affirmation

Written by Devin

Devin-Org: engineering


Devin Review

…-89)

Signed-off-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Devin Review found 3 potential issues.

Devin Review

Comment thread routes/search.ts
let criteria: any = req.query.q === 'undefined' ? '' : req.query.q ?? ''
criteria = (criteria.length <= 200) ? criteria : criteria.substring(0, 200)
models.sequelize.query(`SELECT * FROM Products WHERE ((name LIKE '%${criteria}%' OR description LIKE '%${criteria}%') AND deletedAt IS NULL) ORDER BY name`) // vuln-code-snippet vuln-line unionSqlInjectionChallenge dbSchemaChallenge
models.sequelize.query('SELECT * FROM Products WHERE ((name LIKE :criteria OR description LIKE :criteria) AND deletedAt IS NULL) ORDER BY name', { replacements: { criteria: `%${criteria}%` } }) // vuln-code-snippet vuln-line unionSqlInjectionChallenge dbSchemaChallenge

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 Two injection challenges become unsolvable

When searchProducts parameterizes criteria, User Credentials and Database Schema lose their only exploit path. Their solvers require all credentials or schema definitions in the product results, so fresh installations can never complete either challenge.

Learn more

Both challenges remain active in the challenge catalog. Their only solve calls are in this handler: one compares the returned rows with every user credential, and the other compares them with every SQLite schema definition. Parameter binding prevents either dataset from entering the product result through the search term. No other code calls solve for these challenge keys.

Example: On a fresh installation, a player submits the documented UNION payload for User Credentials. The endpoint treats the payload as text and returns no rows. The solver never sees the users table, so the challenge remains unsolved regardless of later attempts.

Recommended fix: Preserve a documented, intentional exploit path for unionSqlInjectionChallenge and dbSchemaChallenge, or retire both challenges completely. Retirement must remove or disable their catalog entries, solver branches, coding-challenge metadata, configuration mappings, and affected tests so the scoreboard does not advertise impossible tasks.

Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Comment thread routes/search.ts
let criteria: any = req.query.q === 'undefined' ? '' : req.query.q ?? ''
criteria = (criteria.length <= 200) ? criteria : criteria.substring(0, 200)
models.sequelize.query(`SELECT * FROM Products WHERE ((name LIKE '%${criteria}%' OR description LIKE '%${criteria}%') AND deletedAt IS NULL) ORDER BY name`) // vuln-code-snippet vuln-line unionSqlInjectionChallenge dbSchemaChallenge
models.sequelize.query('SELECT * FROM Products WHERE ((name LIKE :criteria OR description LIKE :criteria) AND deletedAt IS NULL) ORDER BY name', { replacements: { criteria: `%${criteria}%` } }) // vuln-code-snippet vuln-line unionSqlInjectionChallenge dbSchemaChallenge

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Coding exercises teach obsolete remediation

After searchProducts became parameterized, both coding exercises still mark its secure query as vulnerable. The correct fixes teach another query, while the cache update suppresses the RSN mismatch.

Learn more

The coding-challenge snippet is generated from this block, and this line remains tagged as the vulnerable line for both challenges. The Fix It choices come from data/static/codefixes/; their designated correct choices still contain the old replacement form. The repository's RSN instructions require manually adapting those files when a vulnerable line changes, then regenerating the cache only after the source and choices are coherent. Updating the cache alone records the newly introduced mismatch as accepted.

Example: A learner opens either coding exercise after this change. Find It presents a parameterized query as the vulnerability. Fix It then calls a different %:criteria% query the correct repair, although that is not the deployed implementation.

Recommended fix: Decide whether these coding challenges remain applicable after retiring or redesigning the runtime challenges. If retained, update every corresponding codefix and explanation to compare coherent alternatives against the new snippet, then run npm run rsn and regenerate cache.json only after the manual changes.

Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Comment thread test/api/search.test.ts
Comment on lines +50 to +76
void it('GET product search treats a UNION SELECT payload as a literal search term', async () => {
const res = await request(app)
.get("/rest/products/search?q=')) union select id,'2','3',email,password,'6','7','8','9' from users--")
assert.equal(res.status, 200)
assert.ok(res.headers['content-type']?.includes('application/json'))

const adminMatch = res.body.data.find((item: any) =>
item.id === 1 && item.price === `admin@${config.get<string>('application.domain')}` && item.deluxePrice === security.hash('admin123')
)
assert.ok(adminMatch, 'Expected admin user in UNION SELECT results')

const jimMatch = res.body.data.find((item: any) =>
item.id === 2 && item.price === `jim@${config.get<string>('application.domain')}` && item.deluxePrice === security.hash('ncc-1701')
)
assert.ok(jimMatch, 'Expected jim user in UNION SELECT results')

const benderMatch = res.body.data.find((item: any) =>
item.id === 3 && item.price === `bender@${config.get<string>('application.domain')}`
)
assert.ok(benderMatch, 'Expected bender user in UNION SELECT results')

const bjoernMatch = res.body.data.find((item: any) =>
item.id === 4 && item.price === 'bjoern.kimminich@gmail.com' && item.deluxePrice === security.hash('bW9jLmxpYW1nQGhjaW5pbW1pay5ucmVvamI=')
)
assert.ok(bjoernMatch, 'Expected bjoern user in UNION SELECT results')

const cisoMatch = res.body.data.find((item: any) =>
item.id === 5 && item.price === `ciso@${config.get<string>('application.domain')}` && item.deluxePrice === security.hash('mDLx?94T~1CfVfZMzw@sJ9f?s3L6lbMqE70FfI8^54jbNikY5fymx7c!YbJb')
)
assert.ok(cisoMatch, 'Expected ciso user in UNION SELECT results')

const supportMatch = res.body.data.find((item: any) =>
item.id === 6 && item.price === `support@${config.get<string>('application.domain')}` && item.deluxePrice === security.hash('J6aVjTgOpRs@?5l!Zkq2AYnCE@RF$P')
)
assert.ok(supportMatch, 'Expected support user in UNION SELECT results')
assert.equal(res.body.data.length, 0)
})

void it('GET product search can create UNION SELECT with sqlite_master table and required column', async () => {
void it('GET product search cannot exfiltrate the users table via UNION SELECT', async () => {
const res = await request(app)
.get("/rest/products/search?q=')) union select sql,'2','3','4','5','6','7','8','9' from sqlite_master--")
.get("/rest/products/search?q=')) union select '1','2','3','4','5','6','7','8','9' from users--")
assert.equal(res.status, 200)
assert.ok(res.headers['content-type']?.includes('application/json'))
assert.equal(res.body.data.length, 0)

const basketItemsMatch = res.body.data.find((item: any) =>
item.id === 'CREATE TABLE `BasketItems` (`ProductId` INTEGER REFERENCES `Products` (`id`) ON DELETE CASCADE ON UPDATE CASCADE, `BasketId` INTEGER REFERENCES `Baskets` (`id`) ON DELETE CASCADE ON UPDATE CASCADE, `id` INTEGER PRIMARY KEY AUTOINCREMENT, `quantity` INTEGER, `createdAt` DATETIME NOT NULL, `updatedAt` DATETIME NOT NULL, UNIQUE (`ProductId`, `BasketId`))'
)
assert.ok(basketItemsMatch, 'Expected BasketItems CREATE TABLE in UNION SELECT results')

const sqliteSequenceMatch = res.body.data.find((item: any) =>
item.id === 'CREATE TABLE sqlite_sequence(name,seq)'
)
assert.ok(sqliteSequenceMatch, 'Expected sqlite_sequence CREATE TABLE in UNION SELECT results')
const dataString = JSON.stringify(res.body.data)
assert.ok(!dataString.includes(`admin@${config.get<string>('application.domain')}`))
assert.ok(!dataString.includes(security.hash('admin123')))
})

void it('GET product search cannot select logically deleted christmas special by default', async () => {
void it('GET product search cannot exfiltrate the database schema via UNION SELECT', async () => {
const res = await request(app)
.get('/rest/products/search?q=seasonal%20special%20offer')
.get("/rest/products/search?q=')) union select sql,'2','3','4','5','6','7','8','9' from sqlite_master--")
assert.equal(res.status, 200)
assert.ok(res.headers['content-type']?.includes('application/json'))
assert.equal(res.body.data.length, 0)
assert.ok(!JSON.stringify(res.body.data).includes('CREATE TABLE'))

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔍 Regression tests assume zero literal matches

The new tests require every payload to return zero products. A legitimate product containing that text fails them despite effective parameterization.

Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

0 participants