fix: reject control characters in manifest URLs

This commit is contained in:
2026-07-20 18:02:16 +02:00
parent bc0135e528
commit 187871aae4
7 changed files with 152 additions and 8 deletions

View File

@@ -5,6 +5,7 @@ import {
loadToolboxContext,
parseToolboxApp,
parseToolboxCatalog,
resolveWebUrl,
type ToolboxAppManifest,
} from "../src/index.js";
import { describe, expect, it, vi } from "vitest";
@@ -179,6 +180,21 @@ describe("same-origin discovery", () => {
});
describe("loading and links", () => {
it.each([
["TAB", "\t"],
["LF", "\n"],
["CR", "\r"],
["C0", "\u0001"],
["DEL", "\u007f"],
])("rejects %s in URL helper string inputs", (_name, control) => {
expect(() =>
resolveWebUrl(`./before${control}after`, "https://tools.example.test/"),
).toThrow(/control characters/u);
expect(() =>
resolveWebUrl("./tool", `https://tools.example.test/${control}`),
).toThrow(/control characters/u);
});
it("loads a catalog, resolves manifests and URLs, and supports external apps", async () => {
const fetch = vi.fn(
async (input: string | URL | Request, init?: RequestInit) => {

View File

@@ -95,6 +95,17 @@ const validCatalog = (): Record<string, unknown> => ({
apps: [{ manifest: "./apps/example/toolbox-app.json", enabled: true }],
});
const asciiUrlControls = [
...Array.from({ length: 0x20 }, (_, codePoint) => codePoint),
0x7f,
].map(
(codePoint) =>
[
`U+${codePoint.toString(16).toUpperCase().padStart(4, "0")}`,
String.fromCodePoint(codePoint),
] as const,
);
describe("canonical schema and runtime parser parity", () => {
const acceptedApps: Array<[string, () => Record<string, unknown>]> = [
["the baseline app", validApp],
@@ -170,6 +181,48 @@ describe("canonical schema and runtime parser parity", () => {
expectParity(validateApp, parseToolboxApp, fixture(), false);
});
it.each(asciiUrlControls)(
"rejects %s embedded in an app URL reference",
(_label, control) => {
expectParity(
validateApp,
parseToolboxApp,
{ ...validApp(), entry: `./before${control}after` },
false,
);
},
);
it.each([
["TAB", "icon", "\t"],
["LF", "privacy URL", "\n"],
["CR", "repository URL", "\r"],
["DEL", "action URL", "\u007f"],
] as const)("rejects %s in an app %s", (_name, field, control) => {
const fixture = validApp();
if (field === "icon") fixture.icon = `./icon${control}.svg`;
if (field === "privacy URL") {
fixture.privacy = {
processing: "local",
fileUploads: false,
telemetry: false,
url: `./privacy${control}.html`,
};
}
if (field === "repository URL") {
fixture.source = {
repository: `https://example.test/source${control}code`,
license: "MIT",
};
}
if (field === "action URL") {
fixture.actions = [
{ id: "docs", label: "Docs", url: `./docs${control}.html` },
];
}
expectParity(validateApp, parseToolboxApp, fixture, false);
});
const acceptedCatalogs: Array<[string, () => Record<string, unknown>]> = [
["the baseline catalog", validCatalog],
[
@@ -258,4 +311,45 @@ describe("canonical schema and runtime parser parity", () => {
it.each(rejectedCatalogs)("rejects %s", (_label, fixture) => {
expectParity(validateCatalog, parseToolboxCatalog, fixture(), false);
});
it.each(asciiUrlControls)(
"rejects %s embedded in a catalog URL reference",
(_label, control) => {
expectParity(
validateCatalog,
parseToolboxCatalog,
{ ...validCatalog(), home: `./before${control}after` },
false,
);
},
);
it.each([
["TAB", "manifest", "\t"],
["LF", "external entry", "\n"],
["CR", "home", "\r"],
["DEL", "manifest", "\u007f"],
] as const)("rejects %s in a catalog %s URL", (_name, field, control) => {
const fixture = validCatalog();
if (field === "home") fixture.home = `./home${control}page`;
if (field === "manifest") {
fixture.apps = [
{
manifest: `./apps/example${control}/toolbox-app.json`,
enabled: true,
},
];
}
if (field === "external entry") {
fixture.apps = [
{
name: "External",
entry: `https://example.test/tool${control}page`,
launch: "new-tab",
enabled: true,
},
];
}
expectParity(validateCatalog, parseToolboxCatalog, fixture, false);
});
});