From 22b31ab841ee6abfd10c3ff54ae6dd3ce943d74c Mon Sep 17 00:00:00 2001 From: Duy Luong <33130695+lmduy2612@users.noreply.github.com> Date: Fri, 2 Oct 2026 16:04:29 +0700 Subject: [PATCH] fix(security): escape LIKE pattern wildcards in admin user search and courses (#533) --- src/modules/admin/admin-users.service.ts | 5 +++-- src/modules/courses/course.service.ts | 3 ++- src/utils/sanitize.ts | 8 ++++++++ tests/unit/utils/sanitize.test.ts | 26 ++++++++++++++++++++++++ 4 files changed, 39 insertions(+), 3 deletions(-) diff --git a/src/modules/admin/admin-users.service.ts b/src/modules/admin/admin-users.service.ts index c2aae7c..fd90a00 100644 --- a/src/modules/admin/admin-users.service.ts +++ b/src/modules/admin/admin-users.service.ts @@ -9,6 +9,7 @@ import { auditLogs, } from "../../database/schema.js"; import { NotFoundError, ValidationError } from "../../utils/errors.js"; +import { escapeLikePattern } from "../../utils/sanitize.js"; import { auditLog } from "../../audit/index.js"; import { logger } from "../../utils/logger.js"; import { @@ -37,8 +38,8 @@ export class AdminUsersService { const conditions = search ? [ or( - ilike(users.stellarAddress, `%${search}%`), - ilike(users.displayName, `%${search}%`), + ilike(users.stellarAddress, `%${escapeLikePattern(search)}%`), + ilike(users.displayName, `%${escapeLikePattern(search)}%`), )!, ] : []; diff --git a/src/modules/courses/course.service.ts b/src/modules/courses/course.service.ts index 5bee916..1e5751d 100644 --- a/src/modules/courses/course.service.ts +++ b/src/modules/courses/course.service.ts @@ -33,6 +33,7 @@ import { PASSING_PERCENTAGE } from "../quizzes/quiz.types.js"; import { auditLog } from "../../audit/index.js"; import { dispatchWebhook } from "../../services/webhook-dispatcher.js"; import { waitlistService } from "./waitlist.service.js"; +import { escapeLikePattern } from "../../utils/sanitize.js"; import { cacheGet, cacheSet, @@ -209,7 +210,7 @@ export class CourseService { conditions.push(eq(courses.difficulty, query.difficulty)); } if (search) { - const escaped = search.replace(/[%_]/g, "\\$&"); + const escaped = escapeLikePattern(search); conditions.push( or( ilike(courses.title, `%${escaped}%`), diff --git a/src/utils/sanitize.ts b/src/utils/sanitize.ts index 9954a8e..64477e8 100644 --- a/src/utils/sanitize.ts +++ b/src/utils/sanitize.ts @@ -20,3 +20,11 @@ export function sanitizeText(input: string): string { export function sanitizeQuizFeedback(input: string): string { return sanitizeHtml(input, STRICT_OPTIONS); } + +/** + * Escape special LIKE pattern characters (%, _, and \) with backslashes + * to prevent wildcard injection in SQL/ORM queries (#533). + */ +export function escapeLikePattern(pattern: string): string { + return pattern.replace(/[%_\\]/g, "\\$&"); +} diff --git a/tests/unit/utils/sanitize.test.ts b/tests/unit/utils/sanitize.test.ts index 67627ad..762df3a 100644 --- a/tests/unit/utils/sanitize.test.ts +++ b/tests/unit/utils/sanitize.test.ts @@ -2,6 +2,7 @@ import { describe, it, expect } from "vitest"; import { sanitizeText, sanitizeQuizFeedback, + escapeLikePattern, } from "../../../src/utils/sanitize.js"; describe("sanitizeText", () => { @@ -36,3 +37,28 @@ describe("sanitizeQuizFeedback", () => { expect(out).toContain("Correct!"); }); }); + +describe("escapeLikePattern (#533)", () => { + it("escapes percent wildcards", () => { + expect(escapeLikePattern("100%")).toBe("100\\%"); + expect(escapeLikePattern("%admin%")).toBe("\\%admin\\%"); + }); + + it("escapes underscore wildcards", () => { + expect(escapeLikePattern("user_name")).toBe("user\\_name"); + expect(escapeLikePattern("___")).toBe("\\_\\_\\_"); + }); + + it("escapes backslashes", () => { + expect(escapeLikePattern("path\\to")).toBe("path\\\\to"); + }); + + it("handles mixed special characters", () => { + expect(escapeLikePattern("50%_discount\\deal")).toBe("50\\%\\_discount\\\\deal"); + }); + + it("leaves standard alphanumeric strings unchanged", () => { + expect(escapeLikePattern("GBZX...123 Stellar")).toBe("GBZX...123 Stellar"); + expect(escapeLikePattern("")).toBe(""); + }); +});