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

@@ -0,0 +1,9 @@
export function containsAsciiControl(value: string): boolean {
for (const character of value) {
const codePoint = character.codePointAt(0);
if (codePoint !== undefined && (codePoint <= 0x1f || codePoint === 0x7f)) {
return true;
}
}
return false;
}

View File

@@ -14,6 +14,7 @@ import {
type ToolboxRequirements,
type ToolboxSource,
} from "./types.js";
import { containsAsciiControl } from "./ascii.js";
type UnknownRecord = Record<string, unknown>;
@@ -108,6 +109,10 @@ function referenceAt(
): string | undefined {
const value = stringAt(object, key, path, issues, { optional });
if (value === undefined) return undefined;
if (containsAsciiControl(value)) {
issues.push(`${path}.${key} must not contain ASCII control characters`);
return undefined;
}
if (value.includes("\\")) {
issues.push(`${path}.${key} must use URL path separators`);
return undefined;

View File

@@ -6,6 +6,7 @@ import {
type ResolvedToolboxPrivacy,
type ToolboxAppManifest,
} from "./types.js";
import { containsAsciiControl } from "./ascii.js";
const WEB_PROTOCOLS = new Set(["http:", "https:"]);
@@ -13,6 +14,15 @@ export function resolveWebUrl(
reference: string | URL,
base?: string | URL,
): URL {
if (
(typeof reference === "string" && containsAsciiControl(reference)) ||
(typeof base === "string" && containsAsciiControl(base))
) {
throw new ToolboxError(
"invalid-url",
"URL strings must not contain ASCII control characters",
);
}
let url: URL;
try {
url = base === undefined ? new URL(reference) : new URL(reference, base);

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);
});
});

View File

@@ -164,6 +164,11 @@
{
"$ref": "#/$defs/nonEmptyString"
},
{
"type": "string",
"description": "URL references must not contain C0 control characters or DEL.",
"pattern": "^[^\\u0000-\\u001F\\u007F]*$"
},
{
"type": "string",
"description": "Backslashes are not URL path separators in toolbox documents.",
@@ -172,17 +177,17 @@
{
"type": "string",
"description": "Explicit schemes are limited to HTTP and HTTPS; relative references remain valid.",
"pattern": "^[\\t\\n\\r ]*(?:[Hh][Tt][Tt][Pp][Ss]?:|(?!(?:[A-Za-z][A-Za-z0-9+.-]*):))"
"pattern": "^ *(?:[Hh][Tt][Tt][Pp][Ss]?:|(?!(?:[A-Za-z][A-Za-z0-9+.-]*):))"
},
{
"type": "string",
"description": "An explicit HTTP(S) authority must not be empty.",
"pattern": "^(?![\\t\\n\\r ]*[Hh][Tt][Tt][Pp][Ss]?:/+(?:[?#]|$))"
"pattern": "^(?! *[Hh][Tt][Tt][Pp][Ss]?:/+(?:[?#]|$))"
},
{
"type": "string",
"description": "HTTP(S) authority references must not contain credentials.",
"pattern": "^[\\t\\n\\r ]*(?!(?:[Hh][Tt][Tt][Pp]:/*|[Hh][Tt][Tt][Pp][Ss]:/+|/+)[^/?#]*@)"
"pattern": "^ *(?!(?:[Hh][Tt][Tt][Pp]:/*|[Hh][Tt][Tt][Pp][Ss]:/+|/+)[^/?#]*@)"
}
]
},
@@ -194,12 +199,12 @@
{
"type": "string",
"description": "Repository URLs must be absolute HTTP(S) URLs.",
"pattern": "^[\\t\\n\\r ]*[Hh][Tt][Tt][Pp][Ss]?:/*[^\\s/?#]"
"pattern": "^ *[Hh][Tt][Tt][Pp][Ss]?:/*[^\\s/?#]"
},
{
"type": "string",
"description": "Absolute repository URLs must not contain credentials.",
"pattern": "^[\\t\\n\\r ]*(?![Hh][Tt][Tt][Pp][Ss]?:/*[^/?#]*@)"
"pattern": "^ *(?![Hh][Tt][Tt][Pp][Ss]?:/*[^/?#]*@)"
}
]
},

View File

@@ -84,6 +84,11 @@
{
"$ref": "#/$defs/nonEmptyString"
},
{
"type": "string",
"description": "URL references must not contain C0 control characters or DEL.",
"pattern": "^[^\\u0000-\\u001F\\u007F]*$"
},
{
"type": "string",
"description": "Backslashes are not URL path separators in toolbox documents.",
@@ -92,17 +97,17 @@
{
"type": "string",
"description": "Explicit schemes are limited to HTTP and HTTPS; relative references remain valid.",
"pattern": "^[\\t\\n\\r ]*(?:[Hh][Tt][Tt][Pp][Ss]?:|(?!(?:[A-Za-z][A-Za-z0-9+.-]*):))"
"pattern": "^ *(?:[Hh][Tt][Tt][Pp][Ss]?:|(?!(?:[A-Za-z][A-Za-z0-9+.-]*):))"
},
{
"type": "string",
"description": "An explicit HTTP(S) authority must not be empty.",
"pattern": "^(?![\\t\\n\\r ]*[Hh][Tt][Tt][Pp][Ss]?:/+(?:[?#]|$))"
"pattern": "^(?! *[Hh][Tt][Tt][Pp][Ss]?:/+(?:[?#]|$))"
},
{
"type": "string",
"description": "HTTP(S) authority references must not contain credentials.",
"pattern": "^[\\t\\n\\r ]*(?!(?:[Hh][Tt][Tt][Pp]:/*|[Hh][Tt][Tt][Pp][Ss]:/+|/+)[^/?#]*@)"
"pattern": "^ *(?!(?:[Hh][Tt][Tt][Pp]:/*|[Hh][Tt][Tt][Pp][Ss]:/+|/+)[^/?#]*@)"
}
]
},