@dintero/sri-to-dist
Version:
HTML tool for adding subresource integrity hashes
329 lines (328 loc) • 13.4 kB
JavaScript
;
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;