UNPKG

harden-react-markdown

Version:

A security-focused wrapper for react-markdown that filters URLs based on allowed prefixes

129 lines (128 loc) 7.01 kB
"use strict"; "use client"; var __assign = (this && this.__assign) || function () { __assign = Object.assign || function(t) { for (var s, i = 1, n = arguments.length; i < n; i++) { s = arguments[i]; for (var p in s) if (Object.prototype.hasOwnProperty.call(s, p)) t[p] = s[p]; } return t; }; return __assign.apply(this, arguments); }; var __rest = (this && this.__rest) || function (s, e) { var t = {}; for (var p in s) if (Object.prototype.hasOwnProperty.call(s, p) && e.indexOf(p) < 0) t[p] = s[p]; if (s != null && typeof Object.getOwnPropertySymbols === "function") for (var i = 0, p = Object.getOwnPropertySymbols(s); i < p.length; i++) { if (e.indexOf(p[i]) < 0 && Object.prototype.propertyIsEnumerable.call(s, p[i])) t[p[i]] = s[p[i]]; } return t; }; Object.defineProperty(exports, "__esModule", { value: true }); exports.default = hardenReactMarkdown; var jsx_runtime_1 = require("react/jsx-runtime"); var react_1 = require("react"); function hardenReactMarkdown(MarkdownComponent) { return function HardenedReactMarkdown(props) { var _a = props.defaultOrigin, defaultOrigin = _a === void 0 ? "" : _a, _b = props.allowedLinkPrefixes, allowedLinkPrefixes = _b === void 0 ? [] : _b, _c = props.allowedImagePrefixes, allowedImagePrefixes = _c === void 0 ? [] : _c, userComponents = props.components, reactMarkdownProps = __rest(props, ["defaultOrigin", "allowedLinkPrefixes", "allowedImagePrefixes", "components"]); // Only require defaultOrigin if we have specific prefixes (not wildcard only) var hasSpecificLinkPrefixes = allowedLinkPrefixes.length && !allowedLinkPrefixes.every(function (p) { return p === "*"; }); var hasSpecificImagePrefixes = allowedImagePrefixes.length && !allowedImagePrefixes.every(function (p) { return p === "*"; }); if (!defaultOrigin && (hasSpecificLinkPrefixes || hasSpecificImagePrefixes)) { throw new Error("defaultOrigin is required when allowedLinkPrefixes or allowedImagePrefixes are provided"); } var parseUrl = function (url) { if (typeof url !== "string") return null; try { // Try to parse as absolute URL first var urlObject = new URL(url); return urlObject; } catch (error) { // If that fails and we have a defaultOrigin, try with it if (defaultOrigin) { try { var urlObject = new URL(url, defaultOrigin); return urlObject; } catch (error) { return null; } } return null; } }; var isPathRelativeUrl = function (url) { if (typeof url !== "string") return false; return url.startsWith("/"); }; var transformUrl = function (url, allowedPrefixes) { if (!url) return null; // Check for wildcard - allow all URLs if (allowedPrefixes.includes("*")) { var inputWasRelative_1 = isPathRelativeUrl(url); var urlString_1 = parseUrl(url); if (urlString_1) { if (inputWasRelative_1) { return urlString_1.pathname + urlString_1.search + urlString_1.hash; } return urlString_1.href; } return null; } // If the input is path relative, we output a path relative URL as well, // however, we always run the same checks on an absolute URL and we // always rescronstruct the output from the parsed URL to ensure that // the output is always a valid URL. var inputWasRelative = isPathRelativeUrl(url); var urlString = parseUrl(url); if (urlString && allowedPrefixes.some(function (prefix) { return urlString.href.startsWith(prefix); })) { if (inputWasRelative) { return urlString.pathname + urlString.search + urlString.hash; } return urlString.href; } return null; }; var hardenedComponents = { a: function (_a) { var href = _a.href, children = _a.children, node = _a.node, props = __rest(_a, ["href", "children", "node"]); var transformedUrl = transformUrl(href, allowedLinkPrefixes); if (transformedUrl !== null) { // If user provided a custom 'a' component, use it with the transformed URL if (userComponents === null || userComponents === void 0 ? void 0 : userComponents.a) { return (0, react_1.createElement)(userComponents.a, __assign({ href: transformedUrl, children: children, node: node, target: "_blank", rel: "noopener noreferrer" }, props)); } // Otherwise use default anchor with security attributes return ((0, jsx_runtime_1.jsx)("a", __assign({ href: transformedUrl }, props, { target: "_blank", rel: "noopener noreferrer", children: children }))); } return ((0, jsx_runtime_1.jsxs)("span", { className: "text-gray-500", title: "Blocked URL: ".concat(href), children: [children, " [blocked]"] })); }, img: function (_a) { var src = _a.src, alt = _a.alt, node = _a.node, props = __rest(_a, ["src", "alt", "node"]); var transformedUrl = transformUrl(src, allowedImagePrefixes); if (transformedUrl !== null) { // If user provided a custom 'img' component, use it with the transformed URL if (userComponents === null || userComponents === void 0 ? void 0 : userComponents.img) { return (0, react_1.createElement)(userComponents.img, __assign({ src: transformedUrl, alt: alt, node: node }, props)); } // Otherwise use default img return (0, jsx_runtime_1.jsx)("img", __assign({ src: transformedUrl, alt: alt }, props)); } return ((0, jsx_runtime_1.jsxs)("span", { className: "inline-block bg-gray-200 dark:bg-gray-700 text-gray-600 dark:text-gray-400 px-3 py-1 rounded text-sm", children: ["[Image blocked: ", alt || "No description", "]"] })); }, }; var mergedComponents = __assign(__assign({}, userComponents), hardenedComponents); var componentProps = __assign(__assign({}, reactMarkdownProps), { components: mergedComponents }); return (0, react_1.createElement)(MarkdownComponent, componentProps); }; }