fix: reject control characters in manifest URLs
This commit is contained in:
9
packages/contract/src/ascii.ts
Normal file
9
packages/contract/src/ascii.ts
Normal 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;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user