fix(security): remediate CodeQL alerts

This commit is contained in:
Vincent Koc
2026-04-30 00:08:14 -07:00
parent a093b5b2de
commit 7c5bf1c675
4 changed files with 65 additions and 28 deletions

View File

@@ -63,6 +63,15 @@ describe("sanitizeForPlainText", () => {
expect(sanitizeForPlainText('<a href="https://example.com">link</a>')).toBe("link");
});
it("keeps stripping tags exposed by malformed tag text", () => {
const sanitized = sanitizeForPlainText(
"before <<script>script>alert(1)</<script>script> after",
);
expect(sanitized).toBe("before alert(1) after");
expect(sanitized).not.toContain("<script");
});
it("strips known internal runtime scaffolding tags including underscore names", () => {
expect(sanitizeForPlainText("ok <previous_response>null</previous_response> done")).toBe(
"ok done",

View File

@@ -25,6 +25,17 @@ const INTERNAL_RUNTIME_SCAFFOLDING_TAG_RE = new RegExp(
`<\\s*\\/?\\s*(?:${INTERNAL_RUNTIME_SCAFFOLDING_TAG_PATTERN})\\b[^>]*>`,
"gi",
);
const HTML_TAG_RE = /<\/?[a-z][a-z0-9_-]*\b[^>]*>/gi;
function stripRemainingHtmlTags(text: string): string {
let previous: string;
let current = text;
do {
previous = current;
current = current.replace(HTML_TAG_RE, "");
} while (current !== previous);
return current;
}
export function stripInternalRuntimeScaffolding(text: string): string {
return text
@@ -42,29 +53,25 @@ export function stripInternalRuntimeScaffolding(text: string): string {
* prose (e.g. `a < b`).
*/
export function sanitizeForPlainText(text: string): string {
return (
stripInternalRuntimeScaffolding(text)
// Preserve angle-bracket autolinks as plain URLs before tag stripping.
.replace(/<((?:https?:\/\/|mailto:)[^<>\s]+)>/gi, "$1")
// Line breaks
.replace(/<br\s*\/?>/gi, "\n")
// Block elements → newlines
.replace(/<\/?(p|div)>/gi, "\n")
// Bold → WhatsApp/Signal bold
.replace(/<(b|strong)>(.*?)<\/\1>/gi, "*$2*")
// Italic → WhatsApp/Signal italic
.replace(/<(i|em)>(.*?)<\/\1>/gi, "_$2_")
// Strikethrough → WhatsApp/Signal strikethrough
.replace(/<(s|strike|del)>(.*?)<\/\1>/gi, "~$2~")
// Inline code
.replace(/<code>(.*?)<\/code>/gi, "`$1`")
// Headings → bold text with newline
.replace(/<h[1-6][^>]*>(.*?)<\/h[1-6]>/gi, "\n*$1*\n")
// List items → bullet points
.replace(/<li[^>]*>(.*?)<\/li>/gi, "• $1\n")
// Strip remaining HTML tags (require tag-like structure: <word...>)
.replace(/<\/?[a-z][a-z0-9_-]*\b[^>]*>/gi, "")
// Collapse 3+ consecutive newlines into 2
.replace(/\n{3,}/g, "\n\n")
);
const converted = stripInternalRuntimeScaffolding(text)
// Preserve angle-bracket autolinks as plain URLs before tag stripping.
.replace(/<((?:https?:\/\/|mailto:)[^<>\s]+)>/gi, "$1")
// Line breaks
.replace(/<br\s*\/?>/gi, "\n")
// Block elements → newlines
.replace(/<\/?(p|div)>/gi, "\n")
// Bold → WhatsApp/Signal bold
.replace(/<(b|strong)>(.*?)<\/\1>/gi, "*$2*")
// Italic → WhatsApp/Signal italic
.replace(/<(i|em)>(.*?)<\/\1>/gi, "_$2_")
// Strikethrough → WhatsApp/Signal strikethrough
.replace(/<(s|strike|del)>(.*?)<\/\1>/gi, "~$2~")
// Inline code
.replace(/<code>(.*?)<\/code>/gi, "`$1`")
// Headings → bold text with newline
.replace(/<h[1-6][^>]*>(.*?)<\/h[1-6]>/gi, "\n*$1*\n")
// List items → bullet points
.replace(/<li[^>]*>(.*?)<\/li>/gi, "• $1\n");
return stripRemainingHtmlTags(converted).replace(/\n{3,}/g, "\n\n");
}

View File

@@ -52,6 +52,8 @@ describe("safeEqualSecret", () => {
["secret-token", "secret-token", true],
["secret-token", "secret-tokEn", false],
["short", "much-longer", false],
["", "", true],
["", "secret", false],
[undefined, "secret", false],
["secret", undefined, false],
[null, "secret", false],

View File

@@ -1,4 +1,13 @@
import { createHash, timingSafeEqual } from "node:crypto";
import { timingSafeEqual } from "node:crypto";
function padSecretBytes(bytes: Buffer, length: number): Buffer {
if (bytes.length === length) {
return bytes;
}
const padded = Buffer.alloc(length);
bytes.copy(padded);
return padded;
}
export function safeEqualSecret(
provided: string | undefined | null,
@@ -7,6 +16,16 @@ export function safeEqualSecret(
if (typeof provided !== "string" || typeof expected !== "string") {
return false;
}
const hash = (s: string) => createHash("sha256").update(s).digest();
return timingSafeEqual(hash(provided), hash(expected));
const providedBytes = Buffer.from(provided, "utf8");
const expectedBytes = Buffer.from(expected, "utf8");
const byteLength = Math.max(providedBytes.length, expectedBytes.length);
if (byteLength === 0) {
return true;
}
return (
timingSafeEqual(
padSecretBytes(providedBytes, byteLength),
padSecretBytes(expectedBytes, byteLength),
) && providedBytes.length === expectedBytes.length
);
}