Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/strip-control-chars-cli.md
Original file line number Diff line number Diff line change
@@ -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.
13 changes: 13 additions & 0 deletions packages/cli/src/__tests__/strip-control-chars.test.ts
Original file line number Diff line number Diff line change
@@ -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" },
});
});
});
24 changes: 20 additions & 4 deletions packages/cli/src/utils/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down Expand Up @@ -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> {
Expand Down Expand Up @@ -306,7 +322,7 @@ export async function resolveLibrary(
};
}

return (await response.json()) as LibrarySearchResponse;
return stripControlChars((await response.json()) as LibrarySearchResponse);
}

export interface GetContextOptions {
Expand Down Expand Up @@ -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);
}
Loading