diff --git a/.changeset/afraid-garlics-visit.md b/.changeset/afraid-garlics-visit.md new file mode 100644 index 0000000..1f82b95 --- /dev/null +++ b/.changeset/afraid-garlics-visit.md @@ -0,0 +1,6 @@ +--- +"@smoothtml/vite-plugin-sri": patch +--- + +Decode percent-encoded asset URLs before bundle lookup so encoded paths resolve +correctly diff --git a/.changeset/many-beans-cross.md b/.changeset/many-beans-cross.md new file mode 100644 index 0000000..1633aec --- /dev/null +++ b/.changeset/many-beans-cross.md @@ -0,0 +1,6 @@ +--- +"@smoothtml/vite-plugin-sri": minor +--- + +Add SRI hashes to elements that already have an `integrity` attribute, +preserving existing hashes. diff --git a/src/hash.ts b/src/hash.ts new file mode 100644 index 0000000..296cdf4 --- /dev/null +++ b/src/hash.ts @@ -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}-`)); +} diff --git a/src/html-infra.ts b/src/html-infra.ts new file mode 100644 index 0000000..3d10a9b --- /dev/null +++ b/src/html-infra.ts @@ -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 = { + '"': """, + "&": "&", +}; +export const escapeAttribute = (s: string) => + `"${s.replace(/["&]/g, (c) => ATTR_DQ_ESCAPES[c as AttrDqUnsafeChar])}"`; diff --git a/src/inject.ts b/src/inject.ts index 1b96aad..1899e76 100644 --- a/src/inject.ts +++ b/src/inject.ts @@ -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; @@ -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) { @@ -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( @@ -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), ); }); @@ -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)), + ); }