fix(cli): strip terminal control characters from API content

Library titles, descriptions, and doc snippets are crowdsourced. Sanitize
them at the API boundary so a malicious entry cannot inject ANSI/OSC
escape sequences into the user's terminal via ctx7 docs or ctx7 library.

Reported by Syed Anas Mohiuddin.

Claude-Session: https://claude.ai/code/session_01XXTv4m4tzcTPQYfa9DvGsv
This commit is contained in:
enesgules
2026-09-07 15:48:56 +03:00
parent a37d30cf14
commit 368bc7de54
3 changed files with 38 additions and 4 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"ctx7": patch
---
Strip terminal control characters from crowdsourced API content (library titles, descriptions, docs) before printing, preventing ANSI/OSC escape sequence injection in `ctx7 docs` and `ctx7 library` output. Reported by Syed Anas Mohiuddin.
@@ -0,0 +1,13 @@
import { describe, expect, it } from "vitest";
import { stripControlChars } from "../utils/api.js";
describe("stripControlChars", () => {
it("removes escape sequences from nested API content, keeps newlines and tabs", () => {
const evil = "\x1b]52;c;ZWNobyBwd25k\x07\x1b[2J\x1b[Htitle\r\n\tok\x9b1m";
expect(stripControlChars(evil)).toBe("]52;c;ZWNobyBwd25k[2J[Htitle\n\tok1m");
expect(stripControlChars({ a: [evil, 1, null], b: { c: evil } })).toEqual({
a: ["]52;c;ZWNobyBwd25k[2J[Htitle\n\tok1m", 1, null],
b: { c: "]52;c;ZWNobyBwd25k[2J[Htitle\n\tok1m" },
});
});
});
+20 -4
View File
@@ -16,6 +16,22 @@ import { VERSION } from "../constants.js";
let baseUrl = "https://context7.com";
// Library metadata and docs are crowdsourced. Strip terminal control characters
// (C0 except \t\n, DEL, C1) so a malicious entry cannot inject escape sequences.
const CONTROL_CHARS = /[\x00-\x08\x0B-\x1F\x7F-\x9F]/g;
export function stripControlChars<T>(value: T): T {
if (typeof value === "string") return value.replace(CONTROL_CHARS, "") as T;
if (Array.isArray(value)) return value.map(stripControlChars) as T;
if (value && typeof value === "object") {
return Object.fromEntries(
Object.entries(value).map(([k, v]) => [k, stripControlChars(v)])
) as T;
}
return value;
}
export function getBaseUrl(): string {
return baseUrl;
}
@@ -113,7 +129,7 @@ export async function searchLibraries(
headers["Authorization"] = `Bearer ${accessToken}`;
}
const response = await fetch(`${baseUrl}/api/v2/libs/search?${params}`, { headers });
return (await response.json()) as LibrarySearchResponse;
return stripControlChars((await response.json()) as LibrarySearchResponse);
}
export async function getSkillQuota(accessToken: string): Promise<SkillQuotaResponse> {
@@ -306,7 +322,7 @@ export async function resolveLibrary(
};
}
return (await response.json()) as LibrarySearchResponse;
return stripControlChars((await response.json()) as LibrarySearchResponse);
}
export interface GetContextOptions {
@@ -354,8 +370,8 @@ export async function getLibraryContext(
}
if (options?.type === "txt") {
return await response.text();
return stripControlChars(await response.text());
}
return (await response.json()) as ContextResponse;
return stripControlChars((await response.json()) as ContextResponse);
}