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 ToolboxRequirements,
|
||||||
type ToolboxSource,
|
type ToolboxSource,
|
||||||
} from "./types.js";
|
} from "./types.js";
|
||||||
|
import { containsAsciiControl } from "./ascii.js";
|
||||||
|
|
||||||
type UnknownRecord = Record<string, unknown>;
|
type UnknownRecord = Record<string, unknown>;
|
||||||
|
|
||||||
@@ -108,6 +109,10 @@ function referenceAt(
|
|||||||
): string | undefined {
|
): string | undefined {
|
||||||
const value = stringAt(object, key, path, issues, { optional });
|
const value = stringAt(object, key, path, issues, { optional });
|
||||||
if (value === undefined) return undefined;
|
if (value === undefined) return undefined;
|
||||||
|
if (containsAsciiControl(value)) {
|
||||||
|
issues.push(`${path}.${key} must not contain ASCII control characters`);
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
if (value.includes("\\")) {
|
if (value.includes("\\")) {
|
||||||
issues.push(`${path}.${key} must use URL path separators`);
|
issues.push(`${path}.${key} must use URL path separators`);
|
||||||
return undefined;
|
return undefined;
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
type ResolvedToolboxPrivacy,
|
type ResolvedToolboxPrivacy,
|
||||||
type ToolboxAppManifest,
|
type ToolboxAppManifest,
|
||||||
} from "./types.js";
|
} from "./types.js";
|
||||||
|
import { containsAsciiControl } from "./ascii.js";
|
||||||
|
|
||||||
const WEB_PROTOCOLS = new Set(["http:", "https:"]);
|
const WEB_PROTOCOLS = new Set(["http:", "https:"]);
|
||||||
|
|
||||||
@@ -13,6 +14,15 @@ export function resolveWebUrl(
|
|||||||
reference: string | URL,
|
reference: string | URL,
|
||||||
base?: string | URL,
|
base?: string | URL,
|
||||||
): 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;
|
let url: URL;
|
||||||
try {
|
try {
|
||||||
url = base === undefined ? new URL(reference) : new URL(reference, base);
|
url = base === undefined ? new URL(reference) : new URL(reference, base);
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
loadToolboxContext,
|
loadToolboxContext,
|
||||||
parseToolboxApp,
|
parseToolboxApp,
|
||||||
parseToolboxCatalog,
|
parseToolboxCatalog,
|
||||||
|
resolveWebUrl,
|
||||||
type ToolboxAppManifest,
|
type ToolboxAppManifest,
|
||||||
} from "../src/index.js";
|
} from "../src/index.js";
|
||||||
import { describe, expect, it, vi } from "vitest";
|
import { describe, expect, it, vi } from "vitest";
|
||||||
@@ -179,6 +180,21 @@ describe("same-origin discovery", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("loading and links", () => {
|
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 () => {
|
it("loads a catalog, resolves manifests and URLs, and supports external apps", async () => {
|
||||||
const fetch = vi.fn(
|
const fetch = vi.fn(
|
||||||
async (input: string | URL | Request, init?: RequestInit) => {
|
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 }],
|
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", () => {
|
describe("canonical schema and runtime parser parity", () => {
|
||||||
const acceptedApps: Array<[string, () => Record<string, unknown>]> = [
|
const acceptedApps: Array<[string, () => Record<string, unknown>]> = [
|
||||||
["the baseline app", validApp],
|
["the baseline app", validApp],
|
||||||
@@ -170,6 +181,48 @@ describe("canonical schema and runtime parser parity", () => {
|
|||||||
expectParity(validateApp, parseToolboxApp, fixture(), false);
|
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>]> = [
|
const acceptedCatalogs: Array<[string, () => Record<string, unknown>]> = [
|
||||||
["the baseline catalog", validCatalog],
|
["the baseline catalog", validCatalog],
|
||||||
[
|
[
|
||||||
@@ -258,4 +311,45 @@ describe("canonical schema and runtime parser parity", () => {
|
|||||||
it.each(rejectedCatalogs)("rejects %s", (_label, fixture) => {
|
it.each(rejectedCatalogs)("rejects %s", (_label, fixture) => {
|
||||||
expectParity(validateCatalog, parseToolboxCatalog, fixture(), false);
|
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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -164,6 +164,11 @@
|
|||||||
{
|
{
|
||||||
"$ref": "#/$defs/nonEmptyString"
|
"$ref": "#/$defs/nonEmptyString"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"type": "string",
|
||||||
|
"description": "URL references must not contain C0 control characters or DEL.",
|
||||||
|
"pattern": "^[^\\u0000-\\u001F\\u007F]*$"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"description": "Backslashes are not URL path separators in toolbox documents.",
|
"description": "Backslashes are not URL path separators in toolbox documents.",
|
||||||
@@ -172,17 +177,17 @@
|
|||||||
{
|
{
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"description": "Explicit schemes are limited to HTTP and HTTPS; relative references remain valid.",
|
"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",
|
"type": "string",
|
||||||
"description": "An explicit HTTP(S) authority must not be empty.",
|
"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",
|
"type": "string",
|
||||||
"description": "HTTP(S) authority references must not contain credentials.",
|
"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",
|
"type": "string",
|
||||||
"description": "Repository URLs must be absolute HTTP(S) URLs.",
|
"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",
|
"type": "string",
|
||||||
"description": "Absolute repository URLs must not contain credentials.",
|
"description": "Absolute repository URLs must not contain credentials.",
|
||||||
"pattern": "^[\\t\\n\\r ]*(?![Hh][Tt][Tt][Pp][Ss]?:/*[^/?#]*@)"
|
"pattern": "^ *(?![Hh][Tt][Tt][Pp][Ss]?:/*[^/?#]*@)"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -84,6 +84,11 @@
|
|||||||
{
|
{
|
||||||
"$ref": "#/$defs/nonEmptyString"
|
"$ref": "#/$defs/nonEmptyString"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"type": "string",
|
||||||
|
"description": "URL references must not contain C0 control characters or DEL.",
|
||||||
|
"pattern": "^[^\\u0000-\\u001F\\u007F]*$"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"description": "Backslashes are not URL path separators in toolbox documents.",
|
"description": "Backslashes are not URL path separators in toolbox documents.",
|
||||||
@@ -92,17 +97,17 @@
|
|||||||
{
|
{
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"description": "Explicit schemes are limited to HTTP and HTTPS; relative references remain valid.",
|
"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",
|
"type": "string",
|
||||||
"description": "An explicit HTTP(S) authority must not be empty.",
|
"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",
|
"type": "string",
|
||||||
"description": "HTTP(S) authority references must not contain credentials.",
|
"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]:/+|/+)[^/?#]*@)"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user