@tokenoftrust/storefront-runner 4.14.0 → 5.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/apps/storefront/src/config/kvTenantResolver.ts +15 -5
- package/package.json +1 -1
- package/packages/public-runtime/package.json +1 -0
- package/packages/public-runtime/src/hosted-image-media-types.ts +11 -11
- package/packages/public-runtime/src/image-release-gate.ts +2 -1
- package/packages/public-runtime/src/svg-sanitize.ts +54 -25
- package/packages/public-runtime/src/tenant-record-merge.d.mts +9 -0
- package/packages/public-runtime/src/tenant-record-merge.mjs +68 -0
- package/packages/public-runtime/tests/fixtures/malicious-svg-corpus.ts +79 -0
|
@@ -17,7 +17,8 @@
|
|
|
17
17
|
*/
|
|
18
18
|
import type { TenantConfig, TenantResolver } from "@tot/public-runtime";
|
|
19
19
|
import { withPlatformPolicy } from "@/config/tenants";
|
|
20
|
-
import { TENANT_RECORD_PREFIX } from "@/config/storedTenantRecord";
|
|
20
|
+
import { TENANT_RECORD_PREFIX, getStoredTenantRecord } from "@/config/storedTenantRecord";
|
|
21
|
+
import { mergeStoredTenantRecord } from "@tot/public-runtime/tenant-record-merge";
|
|
21
22
|
|
|
22
23
|
const DOMAIN_PREFIX = "domain:";
|
|
23
24
|
const TENANT_PREFIX = TENANT_RECORD_PREFIX;
|
|
@@ -76,16 +77,25 @@ export class KvTenantResolver implements TenantResolver {
|
|
|
76
77
|
}
|
|
77
78
|
|
|
78
79
|
/**
|
|
79
|
-
* Register (or update) a tenant in KV. Idempotent:
|
|
80
|
-
*
|
|
81
|
-
*
|
|
80
|
+
* Register (or update) a tenant in KV. Idempotent: merges the config into the stored
|
|
81
|
+
* `tenant:<id>` record (provisioned keys win, stored keys the config does not carry survive;
|
|
82
|
+
* see `@tot/public-runtime/tenant-record-merge`) and writes a domain->id index entry for each
|
|
83
|
+
* claimed hostname. The stored record is read uncached. A stored record that is not a JSON
|
|
84
|
+
* object throws before any write. Pass `makeDefault` to point the unknown-host fallback at
|
|
85
|
+
* this tenant. Call this from onboarding/migration.
|
|
82
86
|
*/
|
|
83
87
|
export async function putTenant(
|
|
84
88
|
kv: KVNamespace,
|
|
85
89
|
config: TenantConfig,
|
|
86
90
|
opts: { makeDefault?: boolean } = {},
|
|
87
91
|
): Promise<void> {
|
|
88
|
-
|
|
92
|
+
const key = TENANT_PREFIX + config.tenant_id;
|
|
93
|
+
const record = mergeStoredTenantRecord(
|
|
94
|
+
key,
|
|
95
|
+
await getStoredTenantRecord(kv, config.tenant_id),
|
|
96
|
+
config as unknown as Record<string, unknown>,
|
|
97
|
+
);
|
|
98
|
+
await kv.put(key, record);
|
|
89
99
|
for (const domain of config.domains) {
|
|
90
100
|
const host = cleanHost(domain);
|
|
91
101
|
if (host) await kv.put(DOMAIN_PREFIX + host, config.tenant_id);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tokenoftrust/storefront-runner",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "5.0.0",
|
|
4
4
|
"license": "SEE LICENSE IN LICENSE",
|
|
5
5
|
"description": "World-shareable storefront runner: multi-tenant renderer on Astro/Cloudflare. No control plane.",
|
|
6
6
|
"packageManager": "pnpm@11.9.0",
|
|
@@ -26,6 +26,7 @@
|
|
|
26
26
|
"./svg-sanitize": "./src/svg-sanitize.ts",
|
|
27
27
|
"./incremental-sha256": "./src/incremental-sha256.ts",
|
|
28
28
|
"./image-limits": "./src/image-limits.mjs",
|
|
29
|
+
"./tenant-record-merge": "./src/tenant-record-merge.mjs",
|
|
29
30
|
"./image-release-gate": "./src/image-release-gate.ts",
|
|
30
31
|
"./pool-asset-headers": "./src/pool-asset-headers.ts",
|
|
31
32
|
"./catalog-files": "./src/catalog-files.ts",
|
|
@@ -14,10 +14,10 @@
|
|
|
14
14
|
*
|
|
15
15
|
* - PINNED_ASSET_*: a tenant's locally-pinned bundle asset (/_a/<sha256>.<ext>,
|
|
16
16
|
* e.g. a favicon) -- a hash-verified, already-reviewed file. Includes SVG.
|
|
17
|
-
* - CAPTURABLE_RASTER_MEDIA_TYPES:
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
17
|
+
* - CAPTURABLE_RASTER_MEDIA_TYPES: the raster formats capture reads by their magic
|
|
18
|
+
* bytes. An SVG is never stored as fetched: capture and upload pass it through the
|
|
19
|
+
* one sanitizer (`svg-sanitize.ts`) and store only its output, which is what lets a
|
|
20
|
+
* sealed `remote` or `upload` entry carry `image/svg+xml` (`sealedMediaTypeAllowed`).
|
|
21
21
|
*/
|
|
22
22
|
|
|
23
23
|
export const PINNED_ASSET_EXTENSION_BY_MEDIA_TYPE: Readonly<Record<string, string>> = Object.freeze({
|
|
@@ -41,20 +41,20 @@ export const CAPTURABLE_RASTER_MEDIA_TYPES = Object.freeze([
|
|
|
41
41
|
|
|
42
42
|
export type CapturableRasterMediaType = (typeof CAPTURABLE_RASTER_MEDIA_TYPES)[number];
|
|
43
43
|
|
|
44
|
-
/** Comma-joined for an HTTP `Accept` header -- derived, never a second literal. */
|
|
45
|
-
export const CAPTURABLE_RASTER_ACCEPT_HEADER = CAPTURABLE_RASTER_MEDIA_TYPES.join(",");
|
|
46
|
-
|
|
47
44
|
/**
|
|
48
|
-
* An author upload may also be an SVG:
|
|
49
|
-
* (`svg-sanitize.ts`)
|
|
50
|
-
*
|
|
45
|
+
* An author upload or a remote capture may also be an SVG: both pass it through the sanitizer
|
|
46
|
+
* (`svg-sanitize.ts`) and store only its output, so a sealed `upload` or `remote` entry may carry
|
|
47
|
+
* it. A `local` entry is a repo file capture reads by raster magic, never an SVG.
|
|
51
48
|
*/
|
|
52
49
|
export const UPLOADABLE_MEDIA_TYPES = Object.freeze([...CAPTURABLE_RASTER_MEDIA_TYPES, "image/svg+xml"] as const);
|
|
53
50
|
|
|
54
51
|
export type UploadableMediaType = (typeof UPLOADABLE_MEDIA_TYPES)[number];
|
|
55
52
|
|
|
53
|
+
/** Comma-joined for a remote capture's HTTP `Accept` header -- derived, never a second literal. */
|
|
54
|
+
export const CAPTURE_ACCEPT_HEADER = UPLOADABLE_MEDIA_TYPES.join(",");
|
|
55
|
+
|
|
56
56
|
/** May a sealed original of provenance `kind` carry `mediaType`? Every reader of a manifest asks this. */
|
|
57
57
|
export function sealedMediaTypeAllowed(kind: unknown, mediaType: unknown): boolean {
|
|
58
|
-
const types: readonly string[] = kind === "upload" ? UPLOADABLE_MEDIA_TYPES : CAPTURABLE_RASTER_MEDIA_TYPES;
|
|
58
|
+
const types: readonly string[] = kind === "upload" || kind === "remote" ? UPLOADABLE_MEDIA_TYPES : CAPTURABLE_RASTER_MEDIA_TYPES;
|
|
59
59
|
return typeof mediaType === "string" && types.includes(mediaType);
|
|
60
60
|
}
|
|
@@ -145,7 +145,8 @@ function supportedPoolEntry(entry: string, facts: ReleaseImageFacts, formats: Po
|
|
|
145
145
|
if (!Object.hasOwn(formats.mediaTypeByExtension, ext)) return false;
|
|
146
146
|
if (ext !== ".svg") return true;
|
|
147
147
|
const bytes = facts.heldPoolBytes(entry);
|
|
148
|
-
// A captured SVG
|
|
148
|
+
// A captured SVG (a remote capture or an upload) entered the pool only as sanitizer output, which
|
|
149
|
+
// is a fixed point of the sanitizer (ADR 0038 §12).
|
|
149
150
|
if (!bytes) return facts.captured(entry);
|
|
150
151
|
const result = formats.sanitizeSvg(bytes);
|
|
151
152
|
return result.ok && !result.changed;
|
|
@@ -15,11 +15,17 @@
|
|
|
15
15
|
* `xml:space` and `xml:lang` are removed;
|
|
16
16
|
* - references (`href`, `xlink:href`, every `url()`): only a same-document fragment (`#id`), a pool
|
|
17
17
|
* object (`/_a/<sha256><ext>`) or an inline raster `data:` image. Any other reference is removed;
|
|
18
|
-
* - `<style>` text with an escape, `@import`, a non-pool `url()` or a script-
|
|
19
|
-
* refuses the image rather than being half-cleaned.
|
|
18
|
+
* - CSS: `<style>` text with an escape, a comment, `@import`, a non-pool `url()` or a script- or
|
|
19
|
+
* fetch-capable construct refuses the image rather than being half-cleaned. Every other kept
|
|
20
|
+
* attribute whose value could hold CSS (it contains `(` or `\`, as `fill`, `cursor` or `filter`
|
|
21
|
+
* can) passes the same CSS check or is removed. `<style>` text reads as the browser reads it:
|
|
22
|
+
* character data entity-decoded, a CDATA section raw.
|
|
20
23
|
*
|
|
21
24
|
* Output is the input with the removed spans cut out, byte for byte otherwise, so an already-clean
|
|
22
|
-
* SVG keeps its bytes and therefore its address.
|
|
25
|
+
* SVG keeps its bytes and therefore its address. Cutting a span out can join its neighbours into
|
|
26
|
+
* markup neither held (a `]]>`, a tag), so the output is read again and must come back unchanged:
|
|
27
|
+
* a sanitized SVG is a fixed point of this pass, which is what lets every later reader re-check it
|
|
28
|
+
* for free. Import-free.
|
|
23
29
|
*/
|
|
24
30
|
|
|
25
31
|
export const SVG_SANITIZER_ID = "svg-allowlist-1";
|
|
@@ -122,17 +128,22 @@ function urlTargets(css: string): string[] | null {
|
|
|
122
128
|
}
|
|
123
129
|
|
|
124
130
|
const UNSAFE_CSS = ["@import", "@font-face", "@namespace", "expression(", "javascript:", "vbscript:", "-moz-binding",
|
|
125
|
-
"behavior", "image-set(", "cross-fade(", "element(", "src("];
|
|
131
|
+
"behavior", "image-set(", "image(", "cross-fade(", "element(", "src("];
|
|
126
132
|
|
|
127
|
-
/**
|
|
133
|
+
/**
|
|
134
|
+
* Why `css` is unsafe, or null when it is safe to keep. A comment is refused outright rather than
|
|
135
|
+
* stripped: a CSS string can hold a `/*` the browser never reads as one, so stripping by pattern
|
|
136
|
+
* would hide the text between it and the next comment closer from this check while the browser
|
|
137
|
+
* applies it.
|
|
138
|
+
*/
|
|
128
139
|
function unsafeCss(css: string): string | null {
|
|
129
140
|
if (css.includes("\\")) return "it uses a CSS escape sequence";
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
const lower = stripped.toLowerCase();
|
|
141
|
+
if (css.includes("/*")) return "it has a CSS comment";
|
|
142
|
+
const lower = css.toLowerCase();
|
|
133
143
|
const construct = UNSAFE_CSS.find((item) => lower.includes(item));
|
|
134
144
|
if (construct) return `it uses \`${construct}\``;
|
|
135
|
-
|
|
145
|
+
if (/url[ \t\r\n\f]+\(/.test(lower)) return "it has a url() that cannot be read exactly";
|
|
146
|
+
const targets = urlTargets(css);
|
|
136
147
|
if (!targets) return "it has a url() that cannot be read exactly";
|
|
137
148
|
if (targets.some((target) => !isPoolSafeReference(target))) return "it references a url() outside this image or the pool";
|
|
138
149
|
return null;
|
|
@@ -158,14 +169,36 @@ export function sanitizeSvg(bytes: Uint8Array, limits: Partial<SvgSanitizeLimits
|
|
|
158
169
|
return refuse("not-utf8", "it is not UTF-8 text");
|
|
159
170
|
}
|
|
160
171
|
try {
|
|
161
|
-
|
|
172
|
+
const first = sanitizeText(source, bound);
|
|
173
|
+
if (!first.changed) return result(first, bytes);
|
|
174
|
+
const spliced = new Refusal("malformed", "removing its unsafe parts would form new markup");
|
|
175
|
+
let again: Pass;
|
|
176
|
+
try {
|
|
177
|
+
again = sanitizeText(first.output, bound);
|
|
178
|
+
} catch (error) {
|
|
179
|
+
if (error instanceof Refusal) throw spliced;
|
|
180
|
+
throw error;
|
|
181
|
+
}
|
|
182
|
+
if (again.changed) throw spliced;
|
|
183
|
+
return result(first, bytes);
|
|
162
184
|
} catch (error) {
|
|
163
185
|
if (error instanceof Refusal) return refuse(error.reason, error.detail);
|
|
164
186
|
throw error;
|
|
165
187
|
}
|
|
166
188
|
}
|
|
167
189
|
|
|
168
|
-
|
|
190
|
+
interface Pass { output: string; changed: boolean; removedElements: number; removedAttributes: number }
|
|
191
|
+
|
|
192
|
+
/** An unchanged document is answered as the very bytes it arrived as, so its address stands. */
|
|
193
|
+
function result(pass: Pass, input: Uint8Array): SvgSanitizeResult {
|
|
194
|
+
return {
|
|
195
|
+
ok: true, bytes: pass.changed ? new TextEncoder().encode(pass.output) : input,
|
|
196
|
+
changed: pass.changed, removedElements: pass.removedElements, removedAttributes: pass.removedAttributes,
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** One allowlist pass over `source`; throws {@link Refusal}. */
|
|
201
|
+
function sanitizeText(source: string, limits: SvgSanitizeLimits): Pass {
|
|
169
202
|
const out: string[] = [];
|
|
170
203
|
const stack: Open[] = [];
|
|
171
204
|
let i = 0;
|
|
@@ -200,6 +233,7 @@ function sanitizeText(source: string, limits: SvgSanitizeLimits): SvgSanitizeRes
|
|
|
200
233
|
if (end === -1) throw malformed("a CDATA section is not closed");
|
|
201
234
|
if (!dropped) {
|
|
202
235
|
if (!open?.style) throw malformed("a CDATA section appears outside <style>");
|
|
236
|
+
// A CDATA section is raw CSS to the browser: never entity-decoded.
|
|
203
237
|
open.style.css += source.slice(i + 9, end);
|
|
204
238
|
out.push(source.slice(i, end + 3));
|
|
205
239
|
}
|
|
@@ -223,9 +257,7 @@ function sanitizeText(source: string, limits: SvgSanitizeLimits): SvgSanitizeRes
|
|
|
223
257
|
const top = stack.pop();
|
|
224
258
|
if (!top || top.name !== match[1]) throw malformed(`</${match[1]}> does not close the open element`);
|
|
225
259
|
if (top.style) {
|
|
226
|
-
const
|
|
227
|
-
if (decoded === null) throw new Refusal("entity", "a <style> uses an undefined entity");
|
|
228
|
-
const unsafe = unsafeCss(decoded);
|
|
260
|
+
const unsafe = unsafeCss(top.style.css);
|
|
229
261
|
if (unsafe) throw new Refusal("unsafe-style", `a <style> element is unsafe: ${unsafe}`);
|
|
230
262
|
}
|
|
231
263
|
if (!top.dropped) out.push(match[0]);
|
|
@@ -297,9 +329,11 @@ function sanitizeText(source: string, limits: SvgSanitizeLimits): SvgSanitizeRes
|
|
|
297
329
|
out.push(text);
|
|
298
330
|
} else {
|
|
299
331
|
if (text.includes("]]>")) throw malformed("text contains `]]>`");
|
|
300
|
-
|
|
332
|
+
const decodedText = decodeEntities(text);
|
|
333
|
+
if (decodedText === null) throw new Refusal("entity", "its text uses an undefined entity");
|
|
301
334
|
if (!dropped) {
|
|
302
|
-
|
|
335
|
+
// Character data is entity-decoded before the browser reads it as CSS.
|
|
336
|
+
if (open?.style) open.style.css += decodedText;
|
|
303
337
|
out.push(text);
|
|
304
338
|
}
|
|
305
339
|
}
|
|
@@ -308,10 +342,7 @@ function sanitizeText(source: string, limits: SvgSanitizeLimits): SvgSanitizeRes
|
|
|
308
342
|
if (!rootSeen) throw new Refusal("not-svg", "it has no root element");
|
|
309
343
|
if (stack.length > 0) throw malformed(`<${stack[stack.length - 1]!.name}> is not closed`);
|
|
310
344
|
const output = out.join("");
|
|
311
|
-
return {
|
|
312
|
-
ok: true, bytes: new TextEncoder().encode(output), changed: output !== source,
|
|
313
|
-
removedElements, removedAttributes,
|
|
314
|
-
};
|
|
345
|
+
return { output, changed: output !== source, removedElements, removedAttributes };
|
|
315
346
|
}
|
|
316
347
|
|
|
317
348
|
/** Whether a kept element keeps this attribute (its decoded value). */
|
|
@@ -322,10 +353,8 @@ function keepAttribute(name: string, value: string, isRoot: boolean): boolean {
|
|
|
322
353
|
if (name.includes(":") && !ALLOWED_PREFIXED_ATTRIBUTES.has(name)) return false;
|
|
323
354
|
if (name === "href" || name === "xlink:href") return isPoolSafeReference(value);
|
|
324
355
|
if (/javascript:|vbscript:/i.test(value.replace(/[\s\u0000-\u001f]+/g, ""))) return false;
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
return !!targets && targets.every(isPoolSafeReference) && !/url[ \t\r\n\f]+\(/i.test(value);
|
|
329
|
-
}
|
|
356
|
+
// Presentation attributes (`fill`, `cursor`, `filter`, …) are CSS values too: one that could hold
|
|
357
|
+
// a function or an escape passes the same check as `style`.
|
|
358
|
+
if (name === "style" || value.includes("(") || value.includes("\\")) return unsafeCss(value) === null;
|
|
330
359
|
return true;
|
|
331
360
|
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export declare function mergeTenantRecord(
|
|
2
|
+
stored: Record<string, unknown>,
|
|
3
|
+
provisioned: Record<string, unknown>,
|
|
4
|
+
): Record<string, unknown>;
|
|
5
|
+
export declare function mergeStoredTenantRecord(
|
|
6
|
+
key: string,
|
|
7
|
+
storedRaw: string | null | undefined,
|
|
8
|
+
provisioned: Record<string, unknown>,
|
|
9
|
+
): string;
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Merge a provisioned tenant config into the `tenant:<id>` KV record already stored.
|
|
3
|
+
*
|
|
4
|
+
* Provisioners carry only the fields they own. The stored record also holds fields written by
|
|
5
|
+
* other paths (`storefront.mode`, `addOns.largeImages`), so a provisioner must read the stored
|
|
6
|
+
* record, merge, and write the result; it never replaces the record wholesale.
|
|
7
|
+
*
|
|
8
|
+
* Contract:
|
|
9
|
+
* - the provisioned config wins for every key it carries;
|
|
10
|
+
* - plain objects merge recursively; arrays and scalars from the provisioned config replace;
|
|
11
|
+
* - keys the provisioned config does not carry are preserved;
|
|
12
|
+
* - `__proto__`, `constructor` and `prototype` keys are dropped at every level;
|
|
13
|
+
* - no stored record: the provisioned config is written as given;
|
|
14
|
+
* - a stored value that is not a JSON object (unparseable, array, scalar, empty string) throws,
|
|
15
|
+
* naming the key, and the caller writes nothing.
|
|
16
|
+
*
|
|
17
|
+
* KV has no transaction. A provisioner that reads, merges and writes while the admin add-ons
|
|
18
|
+
* switch writes the same record can still lose one of the two writes; the merge narrows that
|
|
19
|
+
* window to one read-to-write interval and cannot close it.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
const UNSAFE_KEYS = new Set(["__proto__", "constructor", "prototype"]);
|
|
23
|
+
|
|
24
|
+
/** @param {unknown} value @returns {value is Record<string, unknown>} */
|
|
25
|
+
function isPlainObject(value) {
|
|
26
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* @param {Record<string, unknown>} stored
|
|
31
|
+
* @param {Record<string, unknown>} provisioned
|
|
32
|
+
* @returns {Record<string, unknown>}
|
|
33
|
+
*/
|
|
34
|
+
export function mergeTenantRecord(stored, provisioned) {
|
|
35
|
+
/** @type {Record<string, unknown>} */
|
|
36
|
+
const out = {};
|
|
37
|
+
for (const [key, value] of Object.entries(stored)) {
|
|
38
|
+
if (UNSAFE_KEYS.has(key)) continue;
|
|
39
|
+
out[key] = value;
|
|
40
|
+
}
|
|
41
|
+
for (const [key, value] of Object.entries(provisioned)) {
|
|
42
|
+
if (UNSAFE_KEYS.has(key)) continue;
|
|
43
|
+
const existing = out[key];
|
|
44
|
+
out[key] = isPlainObject(value) && isPlainObject(existing) ? mergeTenantRecord(existing, value) : value;
|
|
45
|
+
}
|
|
46
|
+
return out;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* The JSON to write at `key` for `provisioned`, given the raw stored value (`null` when absent).
|
|
51
|
+
* @param {string} key
|
|
52
|
+
* @param {string | null | undefined} storedRaw
|
|
53
|
+
* @param {Record<string, unknown>} provisioned
|
|
54
|
+
* @returns {string}
|
|
55
|
+
*/
|
|
56
|
+
export function mergeStoredTenantRecord(key, storedRaw, provisioned) {
|
|
57
|
+
if (storedRaw === null || storedRaw === undefined) return JSON.stringify(provisioned);
|
|
58
|
+
let stored;
|
|
59
|
+
try {
|
|
60
|
+
stored = JSON.parse(storedRaw);
|
|
61
|
+
} catch {
|
|
62
|
+
throw new Error(`stored KV record ${key} is not valid JSON; refusing to overwrite it`);
|
|
63
|
+
}
|
|
64
|
+
if (!isPlainObject(stored)) {
|
|
65
|
+
throw new Error(`stored KV record ${key} is not a JSON object; refusing to overwrite it`);
|
|
66
|
+
}
|
|
67
|
+
return JSON.stringify(mergeTenantRecord(stored, provisioned));
|
|
68
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The hostile-SVG corpus every SVG intake is tested against: the sanitizer itself, remote capture
|
|
3
|
+
* and author upload. Each case is either `neutralized` (the stored bytes keep the drawing with the
|
|
4
|
+
* attack removed) or refused with the sanitizer reason named. `assertInert` is the property every
|
|
5
|
+
* stored SVG must hold.
|
|
6
|
+
*/
|
|
7
|
+
import type { SvgRefusalReason } from "../../src/svg-sanitize";
|
|
8
|
+
|
|
9
|
+
const NS = 'xmlns="http://www.w3.org/2000/svg"';
|
|
10
|
+
const XLINK = 'xmlns:xlink="http://www.w3.org/1999/xlink"';
|
|
11
|
+
|
|
12
|
+
export interface MaliciousSvgCase {
|
|
13
|
+
label: string;
|
|
14
|
+
svg: string;
|
|
15
|
+
/** `neutralized`: accepted with the attack cut out; otherwise the refusal reason. */
|
|
16
|
+
expect: "neutralized" | SvgRefusalReason;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export const MALICIOUS_SVG_CORPUS: readonly MaliciousSvgCase[] = [
|
|
20
|
+
{ label: "script element", svg: `<svg ${NS}><script>alert(1)</script><rect/></svg>`, expect: "neutralized" },
|
|
21
|
+
{ label: "script with CDATA", svg: `<svg ${NS}><script><![CDATA[alert(1) && x < y]]></script></svg>`, expect: "neutralized" },
|
|
22
|
+
{ label: "namespaced XHTML script", svg: `<svg ${NS}><h:script xmlns:h="http://www.w3.org/1999/xhtml">alert(1)</h:script></svg>`, expect: "neutralized" },
|
|
23
|
+
{ label: "foreignObject with HTML", svg: `<svg ${NS}><foreignObject><iframe xmlns="http://www.w3.org/1999/xhtml" src="javascript:alert(1)"></iframe></foreignObject></svg>`, expect: "neutralized" },
|
|
24
|
+
{ label: "onload handler", svg: `<svg ${NS} onload="alert(1)"><rect/></svg>`, expect: "neutralized" },
|
|
25
|
+
{ label: "mixed-case handlers", svg: `<svg ${NS}><rect OnClick="alert(1)" onmouseover='x()'/></svg>`, expect: "neutralized" },
|
|
26
|
+
{ label: "javascript: href", svg: `<svg ${NS}><a href="javascript:alert(1)"><text>x</text></a></svg>`, expect: "neutralized" },
|
|
27
|
+
{ label: "entity-obfuscated javascript: xlink:href", svg: `<svg ${NS} ${XLINK}><a xlink:href="javascript:alert(1)"><rect/></a></svg>`, expect: "neutralized" },
|
|
28
|
+
{ label: "vbscript: in an attribute", svg: `<svg ${NS}><rect filter="vbscript:msgbox(1)"/></svg>`, expect: "neutralized" },
|
|
29
|
+
{ label: "data:text/html href", svg: `<svg ${NS}><a href="data:text/html,x"><rect/></a></svg>`, expect: "neutralized" },
|
|
30
|
+
{ label: "nested SVG data URI", svg: `<svg ${NS}><image href="data:image/svg+xml;base64,PHN2Zz48L3N2Zz4="/></svg>`, expect: "neutralized" },
|
|
31
|
+
{ label: "external href image", svg: `<svg ${NS}><image href="https://evil.example/track.png"/></svg>`, expect: "neutralized" },
|
|
32
|
+
{ label: "external xlink:href image", svg: `<svg ${NS} ${XLINK}><image xlink:href="https://evil.example/track.png"/></svg>`, expect: "neutralized" },
|
|
33
|
+
{ label: "external use sprite", svg: `<svg ${NS}><use href="https://evil.example/sprite.svg#a"/></svg>`, expect: "neutralized" },
|
|
34
|
+
{ label: "set rewrites href", svg: `<svg ${NS}><a href="#x"><set attributeName="href" to="javascript:alert(1)"/><rect/></a></svg>`, expect: "neutralized" },
|
|
35
|
+
{ label: "animate rewrites href", svg: `<svg ${NS}><a href="#x"><animate attributeName="href" values="javascript:alert(1)"/></a></svg>`, expect: "neutralized" },
|
|
36
|
+
{ label: "external fill url", svg: `<svg ${NS}><rect fill="url(https://evil.example/p.svg#a)"/></svg>`, expect: "neutralized" },
|
|
37
|
+
{ label: "external style attribute url", svg: `<svg ${NS}><rect style="fill:url('https://evil.example/x')"/></svg>`, expect: "neutralized" },
|
|
38
|
+
// Design-review CSS bypasses.
|
|
39
|
+
{ label: "escaped url in a presentation attribute", svg: `<svg ${NS}><rect fill="u\\72l(https://evil.example/x)"/></svg>`, expect: "neutralized" },
|
|
40
|
+
{ label: "-webkit-image-set in cursor", svg: `<svg ${NS}><rect cursor="-webkit-image-set("https://evil.example/c.png" 1x), auto"/></svg>`, expect: "neutralized" },
|
|
41
|
+
{ label: "image-set in a style attribute", svg: `<svg ${NS}><rect style="cursor:image-set('https://evil.example/c.png' 1x)"/></svg>`, expect: "neutralized" },
|
|
42
|
+
{ label: "comment opener hidden in a CSS string", svg: `<svg ${NS}><style>.a{font-family:"/*"}.b{fill:url(https://evil.example/x)}.c{x:"*/"}</style><rect class="b"/></svg>`, expect: "unsafe-style" },
|
|
43
|
+
{ label: "entity-encoded comment markers in CDATA", svg: `<svg ${NS}><style><![CDATA[/*]]>.b{fill:url(https://evil.example/x)}<![CDATA[*/]]></style><rect class="b"/></svg>`, expect: "unsafe-style" },
|
|
44
|
+
{ label: "entity-encoded comment around a CDATA url", svg: `<svg ${NS}><style><![CDATA[/* .b{fill:url(https://evil.example/x)} */]]></style></svg>`, expect: "unsafe-style" },
|
|
45
|
+
{ label: "removed span splices ]]>", svg: `<svg ${NS}><text>]]<script/>></text></svg>`, expect: "malformed" },
|
|
46
|
+
{ label: "style @import", svg: `<svg ${NS}><style>@import url(https://evil.example/x.css);</style></svg>`, expect: "unsafe-style" },
|
|
47
|
+
{ label: "style CSS escape", svg: `<svg ${NS}><style>.a{background:\\75rl(https://evil.example/x)}</style></svg>`, expect: "unsafe-style" },
|
|
48
|
+
{ label: "DOCTYPE internal subset (billion laughs)", svg: `<!DOCTYPE svg [<!ENTITY a "aaaa"><!ENTITY b "&a;&a;&a;&a;">]><svg ${NS}><text>&b;</text></svg>`, expect: "entity" },
|
|
49
|
+
{ label: "external entity", svg: `<!DOCTYPE svg [<!ENTITY x SYSTEM "file:///etc/passwd">]><svg ${NS}><text>&x;</text></svg>`, expect: "entity" },
|
|
50
|
+
{ label: "undefined entity", svg: `<svg ${NS}><text> </text></svg>`, expect: "entity" },
|
|
51
|
+
{ label: "processing instruction stylesheet", svg: `<svg ${NS}><?xml-stylesheet href="https://evil.example/x.css"?></svg>`, expect: "malformed" },
|
|
52
|
+
{ label: "root is HTML", svg: `<html xmlns="http://www.w3.org/1999/xhtml"><svg ${NS}/></html>`, expect: "not-svg" },
|
|
53
|
+
{ label: "element-count cap", svg: `<svg ${NS}>${"<rect/>".repeat(100_001)}</svg>`, expect: "too-complex" },
|
|
54
|
+
{ label: "depth cap", svg: `<svg ${NS}>${"<g>".repeat(300)}${"</g>".repeat(300)}</svg>`, expect: "too-complex" },
|
|
55
|
+
];
|
|
56
|
+
|
|
57
|
+
/** A clean logo as design tools export it: must round-trip byte-identical. */
|
|
58
|
+
export const BENIGN_LOGO =
|
|
59
|
+
`<?xml version="1.0" encoding="UTF-8"?>\n<svg ${NS} ${XLINK} viewBox="0 0 120 40" width="120" height="40">\n` +
|
|
60
|
+
` <title>Logo</title>\n <defs>\n <linearGradient id="g" x1="0" y1="0" x2="1" y2="1">` +
|
|
61
|
+
`<stop offset="0" stop-color="#ff6a00"/><stop offset="1" stop-color="#ee0979"/></linearGradient>\n` +
|
|
62
|
+
` <clipPath id="c"><rect width="120" height="40" rx="6"/></clipPath>\n` +
|
|
63
|
+
` <style>.wordmark{fill:url(#g);font-family:Helvetica,Arial,sans-serif;font-weight:700}</style>\n </defs>\n` +
|
|
64
|
+
` <g clip-path="url(#c)" transform="translate(2 2) rotate(0)">\n` +
|
|
65
|
+
` <path d="M0 0h36v36H0z" fill="url(#g)" stroke="#000" stroke-width="0.5"/>\n` +
|
|
66
|
+
` <use xlink:href="#c"/>\n <text x="44" y="26" class="wordmark">Vape & Co ©</text>\n </g>\n</svg>\n`;
|
|
67
|
+
|
|
68
|
+
/** Nothing in stored bytes can run script or reach outside the image and the pool. */
|
|
69
|
+
export function inertViolations(output: string): string[] {
|
|
70
|
+
const checks: Array<[string, RegExp]> = [
|
|
71
|
+
["script-capable element", /<script|<foreignObject|<iframe|<animate|<set[\s>/]|<handler|<listener|<h:script/i],
|
|
72
|
+
["event handler", /\son[a-z]+\s*=/i],
|
|
73
|
+
["script url", /javascript:|vbscript:|javascript/i],
|
|
74
|
+
["active data url", /data:text\/html|data:image\/svg/i],
|
|
75
|
+
["external reference", /https?:\/\/(?!www\.w3\.org\/(?:2000\/svg|1999\/xlink))|\/\/evil/i],
|
|
76
|
+
["css escape or image-set", /\\|image-set/i],
|
|
77
|
+
];
|
|
78
|
+
return checks.filter(([, pattern]) => pattern.test(output)).map(([name]) => name);
|
|
79
|
+
}
|