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
JavaScript
;
"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);
};
}