UNPKG

@dintero/sri-to-dist

Version:

HTML tool for adding subresource integrity hashes

329 lines (328 loc) 13.4 kB
"use strict"; var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { if (k2 === undefined) k2 = k; var desc = Object.getOwnPropertyDescriptor(m, k); if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) { desc = { enumerable: true, get: function() { return m[k]; } }; } Object.defineProperty(o, k2, desc); }) : (function(o, m, k, k2) { if (k2 === undefined) k2 = k; o[k2] = m[k]; })); var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) { Object.defineProperty(o, "default", { enumerable: true, value: v }); }) : function(o, v) { o["default"] = v; }); var __importStar = (this && this.__importStar) || (function () { var ownKeys = function(o) { ownKeys = Object.getOwnPropertyNames || function (o) { var ar = []; for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k; return ar; }; return ownKeys(o); }; return function (mod) { if (mod && mod.__esModule) return mod; var result = {}; if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]); __setModuleDefault(result, mod); return result; }; })(); Object.defineProperty(exports, "__esModule", { value: true }); exports.toSriScriptTag = exports.toHtmlWithSri = exports.readLocalContent = exports.isSriTag = exports.handleUpdatedHtml = exports.getContent = exports.fetchRemoteContent = exports.extractLinkRel = exports.calculateSha384 = exports.alterTag = exports.toSriImportMap = exports.extractImports = exports.parseImportMap = exports.isImportMapTag = void 0; const node_crypto_1 = require("node:crypto"); const fs = __importStar(require("node:fs")); const path = __importStar(require("node:path")); const extractLinkRel = (scriptOrLinkTag) => { const re = /<link\s+[^>]*rel="([^"]*)"/; const match = scriptOrLinkTag.match(re); return match ? match[1] : undefined; }; exports.extractLinkRel = extractLinkRel; const isSriTag = (scriptOrLinkTag) => { const rel = extractLinkRel(scriptOrLinkTag); if (rel) { // Check link tags // Split by whitespace to handle multiple rel values const relValues = rel.split(/\s+/); // Check for critical resource types const criticalTypes = [ "style", "stylesheet", "preload", "modulepreload", ]; // If preload, check if it's preloading a style or script if (relValues.includes("preload")) { // Extract as attribute const asPattern = /as="([^"]*)"/; const asMatch = scriptOrLinkTag.match(asPattern); if (asMatch) { const asValue = asMatch[1]; return asValue === "style" || asValue === "script"; } if (relValues.includes("stylesheet")) { // handle case where rel contains both preload and stylesheet return true; } if (relValues.includes("style")) { // handle case where rel contains both preload and style return true; } // Do not calculate sri for other preload link tags return false; } // Check if link tag has sri rel for (const relValue of relValues) { if (criticalTypes.includes(relValue)) { return true; } } // Ignore other types of link tags return false; } // Check if it's a script tag if (scriptOrLinkTag.startsWith("<script")) { return true; } // Not a script tag return false; }; exports.isSriTag = isSriTag; const isImportMapTag = (scriptOrLinkTag) => { return (scriptOrLinkTag.startsWith("<script ") && scriptOrLinkTag.includes('type="importmap"')); }; exports.isImportMapTag = isImportMapTag; const parseImportMap = (scriptOrLinkTag) => { // Extract content between script tags const contentRegex = /<script[^>]*>([\s\S]*?)<\/script>/; const contentMatch = scriptOrLinkTag.match(contentRegex); if (!contentMatch?.[1]) { throw new Error(`Failed to parse import map for tag ${scriptOrLinkTag}`); } try { const content = contentMatch[1].trim(); // Parse the import map JSON const importMapJson = JSON.parse(content); if (typeof importMapJson !== "object" || Array.isArray(importMapJson)) { throw new Error(`Failed to parse import map for tag ${scriptOrLinkTag}`); } return importMapJson; } catch (_error) { throw new Error(`Failed to parse import map for tag ${scriptOrLinkTag}`); } }; exports.parseImportMap = parseImportMap; const extractImports = (importMapJson) => { const imports = importMapJson.imports ? Object.keys(importMapJson.imports).map((key) => { const src = importMapJson.imports[key]; return { src, oldHash: importMapJson.integrity?.[src], }; }) : []; return imports; }; exports.extractImports = extractImports; const toSriImportMap = (tag, importMapJson, newIntegrityMap) => { const newImportMap = JSON.stringify({ ...importMapJson, integrity: newIntegrityMap, }); return tag.replace(/<script([^>]*)>([\s\S]*?)<\/script>/, (_, attributes, _content) => { return `<script${attributes}>${newImportMap}</script>`; }); }; exports.toSriImportMap = toSriImportMap; const toHtmlWithSri = async (htmlContent, baseDir, baseUrl, noRemote, verify) => { // Find all script and link tags that should have integrity hashes const re = /<(script|link)\s+[^>]*(?:src|href)="?([^"]+)?"?[^>]*>(?:(.*?)<\/\1>)?|<script\s+[^>]*type="importmap"[^>]*>([\s\S]*?)<\/script>/g; let updatedHtml = htmlContent; const matches = htmlContent.matchAll(re); for (const match of matches) { const [scriptOrLinkTag, _, src] = match; // Skip non supported resources if (!isSriTag(scriptOrLinkTag)) { continue; } // Get content of script or link try { if ((0, exports.isImportMapTag)(scriptOrLinkTag)) { const importMapJson = (0, exports.parseImportMap)(scriptOrLinkTag); const imports = (0, exports.extractImports)(importMapJson); const newIntegrityMap = {}; for (const { src, oldHash } of imports) { const content = await getContent(src, baseDir, baseUrl, noRemote); const hashHex = calculateSha384(content); const integrity = `sha384-${hashHex}`; if (verify) { if (!oldHash) { throw new Error(`Missing hash for ${src}, expected ${integrity}`); } if (oldHash !== integrity) { throw new Error(`Invalid hash ${oldHash} for ${src}, expected ${integrity}`); } } newIntegrityMap[src] = integrity; } const sriImportMapTag = (0, exports.toSriImportMap)(scriptOrLinkTag, importMapJson, newIntegrityMap); updatedHtml = updatedHtml.replace(scriptOrLinkTag, sriImportMapTag); continue; } const content = await getContent(src, baseDir, baseUrl, noRemote); // Calculate SHA-384 hash and create integrity attribute value const hashHex = calculateSha384(content); const integrity = `sha384-${hashHex}`; if (verify) { const oldHash = getIntegrityFromTag(scriptOrLinkTag); if (!oldHash) { throw new Error(`Missing hash for ${src}, expected ${integrity}`); } if (oldHash !== integrity) { throw new Error(`Invalid hash ${oldHash} for ${src}, expected ${integrity}`); } } // Create new script tag with integrity const sriScriptTag = toSriScriptTag(scriptOrLinkTag, integrity); // Replace in HTML updatedHtml = updatedHtml.replace(scriptOrLinkTag, sriScriptTag); } catch (error) { console.error(`Warning: Failed to process ${src}: ${error}`); throw error; } } return updatedHtml; }; exports.toHtmlWithSri = toHtmlWithSri; const getIntegrityFromTag = (tag) => { const integrityPattern = /integrity="([^"]*)"/; const match = tag.match(integrityPattern); return match ? match[1] : undefined; }; const getContent = async (src, baseDir, baseUrl, noRemote) => { // Handle remote vs local content if (src.startsWith("http://") || src.startsWith("https://")) { // If remote_base_url is provided and src starts with it, treat it as local content if (baseUrl && src.startsWith(baseUrl)) { return readLocalContent(src, baseDir, baseUrl); } if (noRemote) { throw new Error("Remote sri resources not allowed"); } return fetchRemoteContent(src); } // For relative src, read from file return readLocalContent(src, baseDir, baseUrl || ""); }; exports.getContent = getContent; const fetchRemoteContent = async (src) => { try { // Using native fetch available in Node.js 18+ const response = await fetch(src); if (!response.ok) { throw new Error(`Failed to fetch: ${response.statusText}`); } // Check content type const contentType = response.headers.get("content-type") || ""; // Verify content type is appropriate for scripts or stylesheets const validTypes = [ "text/javascript", "application/javascript", "application/x-javascript", "text/css", "text/plain", ]; if (!validTypes.some((type) => contentType.includes(type))) { throw new Error(`Unexpected content type '${contentType}' for resource '${src}'`); } const arrayBuffer = await response.arrayBuffer(); return Buffer.from(arrayBuffer); } catch (error) { throw new Error(`Failed to fetch remote content from '${src}': ${error}`); } }; exports.fetchRemoteContent = fetchRemoteContent; const addTrailingSlashIfNotEmpty = (baseUrl) => { if (baseUrl === "") return baseUrl; return baseUrl.endsWith("/") ? baseUrl : `${baseUrl}/`; }; const toLocalPath = (src, baseDir, baseUrl) => { const baseWithTrailingSlash = addTrailingSlashIfNotEmpty(baseUrl); let localPath = src; if (src.startsWith(baseWithTrailingSlash)) { localPath = src.substring(baseWithTrailingSlash.length); } else if (src.startsWith(baseUrl)) { localPath = src.substring(baseUrl.length); } // Remove any slash from start at file path before reading local file const localPathWithoutStartingSlash = localPath.startsWith("/") ? localPath.substring(1) : localPath; return path.join(baseDir, localPathWithoutStartingSlash); }; const readLocalContent = (src, baseDir, baseUrl) => { const filePath = toLocalPath(src, baseDir, baseUrl); try { return fs.readFileSync(filePath); } catch (error) { throw new Error(`Failed to read file at path '${filePath}': ${error}`); } }; exports.readLocalContent = readLocalContent; const calculateSha384 = (content) => { const hash = (0, node_crypto_1.createHash)("sha384").update(content).digest(); return hash.toString("base64"); }; exports.calculateSha384 = calculateSha384; const handleUpdatedHtml = (stdout, outputPath, updatedHtml) => { if (!updatedHtml) return; if (outputPath) { fs.writeFileSync(outputPath, updatedHtml); } else { stdout.write(`${updatedHtml}\n`); } }; exports.handleUpdatedHtml = handleUpdatedHtml; const toSriScriptTag = (tag, integrity) => { // First handle the integrity attribute const integrityTag = alterTag(tag, "integrity", integrity); // Ensure crossorigin attribute is set return alterTag(integrityTag, "crossorigin", "anonymous"); }; exports.toSriScriptTag = toSriScriptTag; const alterTag = (tag, param, value) => { const hasParamRegex = new RegExp(`^<[^>]*\\s+${param}="[^"]*"`); const keyValue = `${param}="${value}"`; if (hasParamRegex.test(tag)) { // Replace param with new value if existing param is found in tag const replaceParamRegex = new RegExp(`${param}="[^"]*"`); return tag.replace(replaceParamRegex, keyValue); } const hasClosingTag = /<\/script[^>]*>$|<\/link[^>]*>$/.test(tag); if (hasClosingTag) { // Add param and value to the opening tag return tag.replace(/>(?!$)/, ` ${keyValue}>`); } if (tag.endsWith("/>")) { // Add param and value to self closing tag return tag.replace(/\/>$/, ` ${keyValue}/>`); } // No close tag and no self closing tag, add param and value to end of tag return tag.replace(/>$/, ` ${keyValue}>`); }; exports.alterTag = alterTag;