From daaecd2ebff58d10f2d3e6c4bad6b3f0c4db52eb Mon Sep 17 00:00:00 2001 From: Rishvic Pushpakaran Date: Fri, 5 Jun 2026 23:21:59 +0530 Subject: [PATCH 1/2] fix: decode asset urls before bundle lookup --- .changeset/afraid-garlics-visit.md | 6 ++++++ src/inject.ts | 22 +++++++++++++++++++--- 2 files changed, 25 insertions(+), 3 deletions(-) create mode 100644 .changeset/afraid-garlics-visit.md 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/src/inject.ts b/src/inject.ts index 1b96aad..19aae83 100644 --- a/src/inject.ts +++ b/src/inject.ts @@ -65,10 +65,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( From 527aa3e35343a14fe4bb2c248005975d8c07128d Mon Sep 17 00:00:00 2001 From: Rishvic Pushpakaran Date: Sat, 6 Jun 2026 02:41:25 +0530 Subject: [PATCH 2/2] feat: merge sri hash into existing integrity attr Previously any element that already declared an integrity attribute was skipped. Append the computed hash instead, preserving existing hashes and adding ours only when no equal-or-stronger algorithm is already present. --- .changeset/many-beans-cross.md | 6 +++ src/hash.ts | 14 ++++++ src/html-infra.ts | 17 +++++++ src/inject.ts | 91 ++++++++++++++++++++++++++-------- 4 files changed, 106 insertions(+), 22 deletions(-) create mode 100644 .changeset/many-beans-cross.md create mode 100644 src/hash.ts create mode 100644 src/html-infra.ts 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 19aae83..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) { @@ -98,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 appendOffset = html[startTagEndOffset - 2] === "/" ? 2 : 1; - s.appendRight( - startTagEndOffset - appendOffset, - ` integrity="${opts.hashAlgorithm}-${digest}"`, + + 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; + } + s.update( + integritySourceCodeLocation.startOffset + equalOffset + 1, + integritySourceCodeLocation.endOffset, + escapeAttribute(updatedIntegrityValue), ); }); @@ -128,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)), + ); }