Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions PROVIDER_PACKS.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,9 +24,9 @@ The schema records `archiveUrl` as provenance. It is not permission for a runtim

An icon ID has the form `<provider>:<slug>`, such as `aws:s3`, `gcp:cloud-run`, or `azure:storage-accounts`. The prefix must equal `provider.id`. Each icon preserves a stable subject, official product name, recommended Stack node kind, upstream archive path, processed local SVG path, integer view box, original and processed SHA-256 hashes, and an ordered transformation log.

The importer may perform only visual-preservation transformations needed for safe standalone SVG, such as removing metadata, converting stylesheet declarations to equivalent presentation attributes, removing unused identifiers, or normalizing XML. Recoloring, cropping, flipping, rotation, distortion, product substitution, or aspect-ratio changes are outside the contract.
The importer may perform only visual-preservation transformations needed for safe standalone SVG, such as removing metadata, converting stylesheet declarations to equivalent presentation attributes, removing unused identifiers, namespacing referenced gradient identifiers to prevent collisions, or normalizing XML. Recoloring, cropping, flipping, rotation, distortion, product substitution, or aspect-ratio changes are outside the contract.

An empty transformation list requires identical original and processed hashes. A changed hash requires at least one declared transformation. The processed SVG must pass the same script, event-handler, external-reference, executable URL, and viewport safety checks as core assets.
An empty transformation list requires identical original and processed hashes. A changed hash requires at least one declared transformation. The processed SVG must pass the same script, event-handler, external-reference, executable URL, and viewport safety checks as core assets. Gradients may use only locally declared, `stack-`-namespaced identifiers; stylesheets and external references remain forbidden.

## Terms and output

Expand Down
1 change: 1 addition & 0 deletions crates/stack-theme/schema/provider-pack.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -232,6 +232,7 @@
"remove-metadata",
"inline-styles",
"remove-unused-identifiers",
"namespace-identifiers",
"normalize-xml"
]
},
Expand Down
1 change: 1 addition & 0 deletions crates/stack-theme/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -445,6 +445,7 @@ pub enum ProviderPackTransformation {
RemoveMetadata,
InlineStyles,
RemoveUnusedIdentifiers,
NamespaceIdentifiers,
NormalizeXml,
}

Expand Down
1 change: 1 addition & 0 deletions packages/theme/catalog.generated.js

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions packages/theme/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,7 @@ export type ProviderPackTransformation =
| "remove-metadata"
| "inline-styles"
| "remove-unused-identifiers"
| "namespace-identifiers"
| "normalize-xml";

export interface ProviderPack {
Expand Down
1 change: 1 addition & 0 deletions packages/theme/schema/provider-pack.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -232,6 +232,7 @@
"remove-metadata",
"inline-styles",
"remove-unused-identifiers",
"namespace-identifiers",
"normalize-xml"
]
},
Expand Down
1 change: 1 addition & 0 deletions schemas/provider-pack.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -232,6 +232,7 @@
"remove-metadata",
"inline-styles",
"remove-unused-identifiers",
"namespace-identifiers",
"normalize-xml"
]
},
Expand Down
76 changes: 74 additions & 2 deletions scripts/catalog-lib.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -14,17 +14,23 @@ export const repositoryRoot = path.resolve(
const allowedElements = new Set([
"circle",
"desc",
"defs",
"ellipse",
"g",
"line",
"linearGradient",
"path",
"polygon",
"polyline",
"radialGradient",
"rect",
"stop",
"svg",
"title",
]);

const gradientElements = new Set(["linearGradient", "radialGradient"]);

const allowedAttributes = new Set([
"aria-hidden",
"clip-rule",
Expand All @@ -34,7 +40,9 @@ const allowedAttributes = new Set([
"fill",
"fill-rule",
"height",
"id",
"opacity",
"offset",
"points",
"r",
"role",
Expand All @@ -44,7 +52,11 @@ const allowedAttributes = new Set([
"stroke-linecap",
"stroke-linejoin",
"stroke-width",
"stop-color",
"stop-opacity",
"transform",
"gradientTransform",
"gradientUnits",
"viewBox",
"width",
"x",
Expand Down Expand Up @@ -106,7 +118,14 @@ async function requireFile(filePath, label) {
}
}

function validateAttribute(element, name, value, assetPath) {
function validateAttribute(
element,
name,
value,
assetPath,
declaredIdentifiers,
referencedIdentifiers,
) {
if (/^on/i.test(name)) {
fail(`${assetPath}: event handler attribute ${name} is forbidden`);
}
Expand All @@ -122,6 +141,27 @@ function validateAttribute(element, name, value, assetPath) {
}
return;
}
if (name === "id") {
if (!gradientElements.has(element)) {
fail(`${assetPath}: only gradient elements may declare identifiers`);
}
if (!/^stack-[a-z0-9][a-z0-9-]{0,126}$/.test(value)) {
fail(`${assetPath}: gradient identifier must use the stack- namespace`);
}
if (declaredIdentifiers.has(value)) {
fail(`${assetPath}: duplicate identifier ${value}`);
}
declaredIdentifiers.add(value);
return;
}
if (/url\s*\(/i.test(value)) {
const localReference = value.match(/^url\(#(stack-[a-z0-9][a-z0-9-]{0,126})\)$/);
if ((name !== "fill" && name !== "stroke") || localReference === null) {
fail(`${assetPath}: external or executable reference in ${name} is forbidden`);
}
referencedIdentifiers.add(localReference[1]);
return;
}
if (/url\s*\(|javascript:|data:|https?:\/\/|\/\//i.test(value)) {
fail(`${assetPath}: external or executable reference in ${name} is forbidden`);
}
Expand All @@ -134,6 +174,8 @@ export function validateSvgText(svg, assetPath, expectedViewBox) {

const parser = new SaxesParser({ xmlns: false });
const elementStack = [];
const declaredIdentifiers = new Set();
const referencedIdentifiers = new Set();
let rootCount = 0;
let rootNamespace;
let rootViewBox;
Expand All @@ -153,8 +195,28 @@ export function validateSvgText(svg, assetPath, expectedViewBox) {
} else if (tag.name === "svg") {
fail(`${assetPath}: nested svg elements are forbidden`);
}
const parent = elementStack.at(-1);
if (tag.name === "defs" && parent !== "svg") {
fail(`${assetPath}: defs must be a direct child of svg`);
}
if (gradientElements.has(tag.name) && parent !== "defs") {
fail(`${assetPath}: gradient elements must be direct children of defs`);
}
if (tag.name === "stop" && !gradientElements.has(parent)) {
fail(`${assetPath}: stop must be a direct child of a gradient`);
}
if (parent === "defs" && !gradientElements.has(tag.name)) {
fail(`${assetPath}: defs may contain only gradients`);
}
for (const [name, value] of Object.entries(tag.attributes)) {
validateAttribute(tag.name, name, value, assetPath);
validateAttribute(
tag.name,
name,
value,
assetPath,
declaredIdentifiers,
referencedIdentifiers,
);
}
elementStack.push(tag.name);
});
Expand Down Expand Up @@ -188,6 +250,16 @@ export function validateSvgText(svg, assetPath, expectedViewBox) {
if (rootNamespace !== "http://www.w3.org/2000/svg") {
fail(`${assetPath}: root must declare the canonical SVG namespace`);
}
for (const identifier of referencedIdentifiers) {
if (!declaredIdentifiers.has(identifier)) {
fail(`${assetPath}: local reference ${identifier} is not declared`);
}
}
for (const identifier of declaredIdentifiers) {
if (!referencedIdentifiers.has(identifier)) {
fail(`${assetPath}: identifier ${identifier} is unused`);
}
}

const actualViewBox = rootViewBox
?.trim()
Expand Down
32 changes: 32 additions & 0 deletions tests/catalog.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,38 @@ test("provider asset changes require a transformation record", async () => {
);
});

test("safe namespaced local gradients are accepted", () => {
validateSvgText(
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><defs><linearGradient id="stack-acme-object-storage-gradient" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stop-color="#fff"/><stop offset="1" stop-color="#000"/></linearGradient></defs><path d="M0 0h24v24H0z" fill="url(#stack-acme-object-storage-gradient)"/></svg>',
"safe-local-gradient.svg",
[0, 0, 24, 24],
);
});

test("undeclared local gradients are rejected", () => {
assert.throws(
() =>
validateSvgText(
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M0 0h24v24H0z" fill="url(#stack-missing)"/></svg>',
"missing-local-gradient.svg",
[0, 0, 24, 24],
),
/local reference stack-missing is not declared/,
);
});

test("stylesheets remain forbidden in provider SVG", () => {
assert.throws(
() =>
validateSvgText(
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><style>.icon { fill: red }</style><path class="icon" d="M0 0h24v24H0z"/></svg>',
"stylesheet.svg",
[0, 0, 24, 24],
),
/element style is not allowed/,
);
});

for (const [name, pattern] of [
["unsafe-script.svg", /element script is not allowed/],
["unsafe-event.svg", /event handler attribute onload is forbidden/],
Expand Down