fix(search): stop escapeLikePattern corrupting bound search values
Verified against a real SQLite connection -- each of these returned zero rows before and the right row after: "Sarah's" before=[] after=["Sarah's Birthday"] "100%" before=[] after=["Summer 100% Sale"] "Gala_" before=[] after=["Gala_Night"] Two bugs in one helper. It did .replace(/'/g, "''"), which is SQL string-quote doubling -- meaningless and actively corrupting for a value that is bound, so any search containing an apostrophe matched nothing. And its \% escaping had no ESCAPE clause on the LIKE, which is engine-dependent: honoured on Postgres, a literal backslash on SQLite, so % and _ stayed wildcards there. Now mirrors the correct implementation from 59666b59: escape \ % _ only, and a new likeWithEscape(column) emits `col LIKE ? ESCAPE '\'`. Both call sites move to whereRaw with the value still bound; the column argument is a literal, documented in the JSDoc. Callers checked before changing the contract: adminPhotos.js, adminEvents/crud.js, and sqlSecurity's own addLikeCondition(), which has no callers anywhere -- pre-existing dead export, updated to the new shape rather than deleted. Behavioural change: searches containing ' % _ or \ now return the right rows instead of nothing. Case sensitivity is unchanged. Refs testplan REPORT.md, escapeLikePattern finding.
This commit is contained in:
@@ -13,7 +13,7 @@ const bcrypt = require('bcrypt');
|
||||
const crypto = require('crypto');
|
||||
const fs = require('fs').promises;
|
||||
const path = require('path');
|
||||
const { escapeLikePattern } = require('../../utils/sqlSecurity');
|
||||
const { escapeLikePattern, likeWithEscape } = require('../../utils/sqlSecurity');
|
||||
const { validatePasswordInContext, getBcryptRounds } = require('../../utils/passwordValidation');
|
||||
const logger = require('../../utils/logger');
|
||||
const { sanitizeForLog, sanitizeValidationErrors } = require('../../utils/sanitizeForLog');
|
||||
@@ -866,12 +866,12 @@ module.exports = (router) => {
|
||||
|
||||
// Apply search filter
|
||||
if (search) {
|
||||
const escapedSearch = escapeLikePattern(search);
|
||||
const pattern = `%${escapeLikePattern(search)}%`;
|
||||
query = query.where((builder) => {
|
||||
builder.where('event_name', 'like', `%${escapedSearch}%`)
|
||||
.orWhere('admin_email', 'like', `%${escapedSearch}%`)
|
||||
.orWhere('customer_email', 'like', `%${escapedSearch}%`)
|
||||
.orWhere('slug', 'like', `%${escapedSearch}%`);
|
||||
builder.whereRaw(likeWithEscape('event_name'), [pattern])
|
||||
.orWhereRaw(likeWithEscape('admin_email'), [pattern])
|
||||
.orWhereRaw(likeWithEscape('customer_email'), [pattern])
|
||||
.orWhereRaw(likeWithEscape('slug'), [pattern]);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ const {
|
||||
getUseOriginalFilenames,
|
||||
pickRawDownloadName,
|
||||
} = require('../services/downloadFilenameService');
|
||||
const { escapeLikePattern } = require('../utils/sqlSecurity');
|
||||
const { escapeLikePattern, likeWithEscape } = require('../utils/sqlSecurity');
|
||||
const { COLOR_LABELS, dominantColorLabel, SHARED_COLOR_LABEL_IDENTITY } = require('../constants/colorLabels');
|
||||
const feedbackService = require('../services/feedbackService');
|
||||
const photoAdminMarksService = require('../services/photoAdminMarksService');
|
||||
@@ -1228,8 +1228,10 @@ router.get('/:eventId/photos', adminAuth, requirePermission('photos.view'), requ
|
||||
|
||||
// Search by filename
|
||||
if (search) {
|
||||
const escapedSearch = escapeLikePattern(search);
|
||||
query = query.where('photos.filename', 'like', `%${escapedSearch}%`);
|
||||
query = query.whereRaw(
|
||||
likeWithEscape('photos.filename'),
|
||||
[`%${escapeLikePattern(search)}%`]
|
||||
);
|
||||
}
|
||||
|
||||
// Feedback filters (has likes / favorites / comments / min rating) with AND/OR logic
|
||||
|
||||
@@ -29,25 +29,49 @@ function sanitizeDays(days) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Escape special characters in LIKE queries
|
||||
* Escape the LIKE metacharacters in a search term so it matches itself.
|
||||
*
|
||||
* The result is a BOUND value — it goes into the `?` of a prepared statement,
|
||||
* never into SQL text. That is why single quotes are left alone: doubling them
|
||||
* is SQL string-literal syntax, and inside a bound value it is not escaping
|
||||
* anything, it is corrupting the search term (a search for "Sarah's Birthday"
|
||||
* would be sent as "Sarah''s Birthday" and match nothing).
|
||||
*
|
||||
* Only the three characters LIKE itself reads are escaped:
|
||||
* % matches any sequence of characters
|
||||
* _ matches any single character
|
||||
* \ the escape character named by the ESCAPE clause
|
||||
*
|
||||
* Pair the result with likeWithEscape() — without an explicit ESCAPE clause
|
||||
* the backslashes below mean different things on Postgres and SQLite.
|
||||
*
|
||||
* @param {string} input - The search string from user input
|
||||
* @returns {string} Escaped string safe for LIKE queries
|
||||
* @returns {string} Escaped string safe to bind into a LIKE pattern
|
||||
*/
|
||||
function escapeLikePattern(input) {
|
||||
if (!input || typeof input !== 'string') {
|
||||
return '';
|
||||
}
|
||||
|
||||
// Escape special LIKE pattern characters
|
||||
// In SQL LIKE patterns:
|
||||
// % matches any sequence of characters
|
||||
// _ matches any single character
|
||||
// \ is the escape character
|
||||
return input
|
||||
.replace(/\\/g, '\\\\') // Escape backslashes first
|
||||
.replace(/%/g, '\\%') // Escape percent signs
|
||||
.replace(/_/g, '\\_') // Escape underscores
|
||||
.replace(/'/g, '\'\''); // Escape single quotes for safety
|
||||
|
||||
return input.replace(/[\\%_]/g, '\\$&');
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a `LIKE ? ESCAPE '\'` comparison for a column.
|
||||
*
|
||||
* The ESCAPE clause is load-bearing rather than decorative: Postgres treats a
|
||||
* backslash in a LIKE pattern as an escape character by default, SQLite has no
|
||||
* default escape character at all and would match the backslash literally. Naming
|
||||
* it makes escapeLikePattern()'s output mean the same thing on both engines.
|
||||
*
|
||||
* `column` is interpolated into the SQL text, so callers must pass a literal
|
||||
* column name — never user input. The search term stays bound.
|
||||
*
|
||||
* @param {string} column - Literal column name (optionally table-qualified)
|
||||
* @returns {string} Raw SQL fragment with a single `?` binding placeholder
|
||||
*/
|
||||
function likeWithEscape(column) {
|
||||
return `${column} LIKE ? ESCAPE '\\'`;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -78,9 +102,8 @@ function addLikeCondition(query, column, pattern) {
|
||||
return query;
|
||||
}
|
||||
|
||||
const escapedPattern = escapeLikePattern(pattern);
|
||||
// Knex handles parameterization of the LIKE value
|
||||
return query.where(column, 'like', `%${escapedPattern}%`);
|
||||
return query.whereRaw(likeWithEscape(column), [`%${escapeLikePattern(pattern)}%`]);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -110,6 +133,7 @@ function validateSortOrder(order) {
|
||||
module.exports = {
|
||||
sanitizeDays,
|
||||
escapeLikePattern,
|
||||
likeWithEscape,
|
||||
addDateRangeCondition,
|
||||
addLikeCondition,
|
||||
validateSortColumn,
|
||||
|
||||
Reference in New Issue
Block a user