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
6 changes: 6 additions & 0 deletions .changeset/afraid-garlics-visit.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@smoothtml/vite-plugin-sri": patch
---

Decode percent-encoded asset URLs before bundle lookup so encoded paths resolve
correctly
6 changes: 6 additions & 0 deletions .changeset/many-beans-cross.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@smoothtml/vite-plugin-sri": minor
---

Add SRI hashes to elements that already have an `integrity` attribute,
preserving existing hashes.
14 changes: 14 additions & 0 deletions src/hash.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
// SPDX-FileCopyrightText: 2026 Rishvic Pushpakaran
//
// SPDX-License-Identifier: Apache-2.0

const HASH_ORDER = ["sha256", "sha384", "sha512"] as const;
export type HashAlgorithm = (typeof HASH_ORDER)[number];

export function compareHash(a: HashAlgorithm, b: HashAlgorithm) {
return HASH_ORDER.indexOf(a) - HASH_ORDER.indexOf(b);
}

export function getComponentAlgo(component: string): HashAlgorithm | undefined {
return HASH_ORDER.find((h) => component.startsWith(`${h}-`));
}
17 changes: 17 additions & 0 deletions src/html-infra.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
// SPDX-FileCopyrightText: 2026 Rishvic Pushpakaran
//
// SPDX-License-Identifier: Apache-2.0

export const toAsciiLowercase = (s: string) =>
s.replace(/[A-Z]/g, (c) => c.toLowerCase());

export const splitAsciiWhitespace = (s: string) =>
s.split(/[\t\n\f\r ]+/).filter(Boolean);

type AttrDqUnsafeChar = '"' | "&";
const ATTR_DQ_ESCAPES: Record<AttrDqUnsafeChar, string> = {
'"': "&quot;",
"&": "&amp;",
};
export const escapeAttribute = (s: string) =>
`"${s.replace(/["&]/g, (c) => ATTR_DQ_ESCAPES[c as AttrDqUnsafeChar])}"`;
113 changes: 88 additions & 25 deletions src/inject.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,18 @@ import { createHash } from "crypto";
import path from "path";
import MagicString from "magic-string";
import colors from "picocolors";
import { compareHash, getComponentAlgo, type HashAlgorithm } from "./hash.ts";
import { nodeIsElement, traverseHtml } from "./html.ts";
import {
escapeAttribute,
splitAsciiWhitespace,
toAsciiLowercase,
} from "./html-infra.ts";
import type { OutputBundle } from "rolldown";
import type { Logger } from "vite";

export interface InjectSriOptions {
hashAlgorithm: "sha256" | "sha384" | "sha512";
hashAlgorithm: HashAlgorithm;
bundle: OutputBundle;
filename: string;
htmlPath: string;
Expand Down Expand Up @@ -44,11 +50,6 @@ export async function injectSri(
return;
}

// Skip elements with integrity attribute present.
if (attrs.some((attr) => attr.name === "integrity")) {
return;
}

const sourceAttrName = nodeName === "script" ? "src" : "href";
const sourceAttr = attrs.find((attr) => attr.name === sourceAttrName);
if (!sourceAttr) {
Expand All @@ -65,10 +66,26 @@ export async function injectSri(
}

// Sanitize empty pathnames, so paths are always absolute.
const basePath = baseUrl.pathname || "/";
const assetPath = assetUrl.pathname || "/";
let basePath: string, assetPath: string;
try {
basePath = decodeURI(baseUrl.pathname) || "/";
assetPath = decodeURI(assetUrl.pathname) || "/";
} catch (e) {
if (e instanceof URIError) {
opts.warn(
colors.yellow(
`\nInvalid URI (base: ${baseUrl.pathname}, asset: ${assetUrl.pathname}), skipping...`,
),
);
return;
}
// Unknown error, rethrow.
throw new Error("Error while decoding base URL and asset URL", {
cause: e,
});
}

const assetFilePath = path.relative(basePath, assetPath);
const assetFilePath = path.posix.relative(basePath, assetPath);
const asset = opts.bundle[assetFilePath];
if (!asset) {
opts.warn(
Expand All @@ -82,20 +99,69 @@ export async function injectSri(
const hash = createHash(opts.hashAlgorithm);
hash.update(data);
const digest = hash.digest("base64");
const integrityComponent = `${opts.hashAlgorithm}-${digest}`;

const startTagEndOffset = sourceCodeLocation?.startTag?.endOffset;
if (startTagEndOffset === undefined) {
opts.warn(
colors.yellow(
`Unable to find source code location of <${nodeName} ${sourceAttrName}="${sourceAttr.value}">`,
),
const integrityAttr = attrs.find((attr) => attr.name === "integrity");
if (!integrityAttr) {
const startTagEndOffset = sourceCodeLocation?.startTag?.endOffset;
if (startTagEndOffset === undefined) {
throw new Error(
"[vite-plugin-sri] internal error, no location set for start tag in element",
);
}
const appendOffset = html[startTagEndOffset - 2] === "/" ? 2 : 1;
s.appendRight(
startTagEndOffset - appendOffset,
` integrity=${escapeAttribute(integrityComponent)}`,
);
return;
}

const components = splitAsciiWhitespace(integrityAttr.value);

// Skip when our hash is already present, or when a stronger algorithm is.
// Per the SRI spec the browser validates against the strongest algorithm in
// the set and ignores the rest, so appending a weaker hash alongside a
// stronger one would have no effect.
if (
components.some((c) => {
if (c === integrityComponent) {
return true;
}
const h = getComponentAlgo(c);
return h !== undefined && compareHash(h, opts.hashAlgorithm) > 0;
})
) {
return;
}

const integritySourceCodeLocation =
node.sourceCodeLocation?.attrs?.["integrity"];
if (!integritySourceCodeLocation) {
throw new Error(
"[vite-plugin-sri] internal error, no location set for integrity attribute in element",
);
}

const updatedIntegrityValue = components
.concat([integrityComponent])
.join(" ");
const integrityString = s.slice(
integritySourceCodeLocation.startOffset,
integritySourceCodeLocation.endOffset,
);
const equalOffset = integrityString.search(/=/);
if (equalOffset < 0) {
s.appendRight(
integritySourceCodeLocation.endOffset,
`=${escapeAttribute(updatedIntegrityValue)}`,
);
return;
}
const appendOffset = html[startTagEndOffset - 2] === "/" ? 2 : 1;
s.appendRight(
startTagEndOffset - appendOffset,
` integrity="${opts.hashAlgorithm}-${digest}"`,
s.update(
integritySourceCodeLocation.startOffset + equalOffset + 1,
integritySourceCodeLocation.endOffset,
escapeAttribute(updatedIntegrityValue),
);
});

Expand All @@ -112,10 +178,7 @@ export async function injectSri(
*/
export function isSriEligibleRel(rel: string) {
const acceptedKeywords = ["stylesheet", "preload", "modulepreload"];
return rel
.split(/[\t\n\f\r ]+/)
.filter(Boolean)
.some((v) =>
acceptedKeywords.includes(v.replace(/[A-Z]/g, (c) => c.toLowerCase())),
);
return splitAsciiWhitespace(rel).some((v) =>
acceptedKeywords.includes(toAsciiLowercase(v)),
);
}