diff --git a/packages/contract/src/ascii.ts b/packages/contract/src/ascii.ts new file mode 100644 index 0000000..2dea789 --- /dev/null +++ b/packages/contract/src/ascii.ts @@ -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; +} diff --git a/packages/contract/src/parse.ts b/packages/contract/src/parse.ts index 40d2241..9b54683 100644 --- a/packages/contract/src/parse.ts +++ b/packages/contract/src/parse.ts @@ -14,6 +14,7 @@ import { type ToolboxRequirements, type ToolboxSource, } from "./types.js"; +import { containsAsciiControl } from "./ascii.js"; type UnknownRecord = Record; @@ -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; diff --git a/packages/contract/src/urls.ts b/packages/contract/src/urls.ts index ae42d32..e6ce949 100644 --- a/packages/contract/src/urls.ts +++ b/packages/contract/src/urls.ts @@ -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); diff --git a/packages/contract/test/contract.test.ts b/packages/contract/test/contract.test.ts index 23581b3..c3c00a5 100644 --- a/packages/contract/test/contract.test.ts +++ b/packages/contract/test/contract.test.ts @@ -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) => { diff --git a/packages/contract/test/schema-parity.test.ts b/packages/contract/test/schema-parity.test.ts index b5fb6a1..e878139 100644 --- a/packages/contract/test/schema-parity.test.ts +++ b/packages/contract/test/schema-parity.test.ts @@ -95,6 +95,17 @@ const validCatalog = (): Record => ({ 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]> = [ ["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]> = [ ["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); + }); }); diff --git a/schemas/toolbox-app.v1.schema.json b/schemas/toolbox-app.v1.schema.json index 5c41bcc..ebf9ace 100644 --- a/schemas/toolbox-app.v1.schema.json +++ b/schemas/toolbox-app.v1.schema.json @@ -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]?:/*[^/?#]*@)" } ] }, diff --git a/schemas/toolbox-catalog.v1.schema.json b/schemas/toolbox-catalog.v1.schema.json index a74e914..d4c3723 100644 --- a/schemas/toolbox-catalog.v1.schema.json +++ b/schemas/toolbox-catalog.v1.schema.json @@ -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]:/+|/+)[^/?#]*@)" } ] },