Files
Ricardo 3c8a4b2b53 fix: security hardening (SSRF, ReDoS, XSS, open redirect)
- Add SSRF blocklist to media proxy (block private/internal IPs)
- Escape regex in searchItems() to prevent ReDoS
- Sanitize webmention content.html before storage (XSS prevention)
- Return 404 instead of redirect on failed media proxy (open redirect fix)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 14:40:12 +01:00

332 lines
8.2 KiB
JavaScript

/**
* Webmention verification
* @module webmention/verifier
*/
import { mf2 } from "microformats-parser";
import sanitizeHtml from "sanitize-html";
/**
* Sanitize HTML options (matches normalizer.js)
*/
const SANITIZE_OPTIONS = {
allowedTags: [
"a", "abbr", "b", "blockquote", "br", "code", "em", "figcaption",
"figure", "h1", "h2", "h3", "h4", "h5", "h6", "hr", "i", "img",
"li", "ol", "p", "pre", "s", "span", "strike", "strong", "sub",
"sup", "table", "tbody", "td", "th", "thead", "tr", "u", "ul",
"video", "audio", "source",
],
allowedAttributes: {
a: ["href", "title", "rel"],
img: ["src", "alt", "title", "width", "height"],
video: ["src", "poster", "controls", "width", "height"],
audio: ["src", "controls"],
source: ["src", "type"],
"*": ["class"],
},
allowedSchemes: ["http", "https", "mailto"],
};
/**
* Verify a webmention
* @param {string} source - Source URL
* @param {string} target - Target URL
* @returns {Promise<object>} Verification result
*/
export async function verifyWebmention(source, target) {
try {
// Fetch the source URL
const response = await fetch(source, {
headers: {
Accept: "text/html, application/xhtml+xml",
"User-Agent": "Indiekit Microsub/1.0 (+https://getindiekit.com)",
},
redirect: "follow",
});
if (!response.ok) {
return {
verified: false,
error: `Source returned ${response.status}`,
};
}
const content = await response.text();
const finalUrl = response.url;
// Check if source links to target
if (!containsLink(content, target)) {
return {
verified: false,
error: "Source does not link to target",
};
}
// Parse microformats
const parsed = mf2(content, { baseUrl: finalUrl });
const entry = findEntry(parsed, target);
if (!entry) {
// Still valid, just no h-entry context
return {
verified: true,
type: "mention",
author: undefined,
content: undefined,
};
}
// Determine webmention type
const mentionType = detectMentionType(entry, target);
// Extract author
const author = extractAuthor(entry, parsed);
// Extract content
const webmentionContent = extractContent(entry);
return {
verified: true,
type: mentionType,
author,
content: webmentionContent,
url: getFirst(entry.properties.url) || source,
published: getFirst(entry.properties.published),
};
} catch (error) {
return {
verified: false,
error: `Verification failed: ${error.message}`,
};
}
}
/**
* Check if content contains a link to target
* @param {string} content - HTML content
* @param {string} target - Target URL to find
* @returns {boolean} Whether the link exists
*/
function containsLink(content, target) {
// Normalize target URL for matching
const normalizedTarget = target.replace(/\/$/, "");
// Check for href attribute containing target
const hrefPattern = new RegExp(
`href=["']${escapeRegex(normalizedTarget)}/?["']`,
"i",
);
if (hrefPattern.test(content)) {
return true;
}
// Also check without quotes (some edge cases)
return content.includes(target) || content.includes(normalizedTarget);
}
/**
* Find the h-entry that references the target
* @param {object} parsed - Parsed microformats
* @param {string} target - Target URL
* @returns {object|undefined} The h-entry or undefined
*/
function findEntry(parsed, target) {
const normalizedTarget = target.replace(/\/$/, "");
for (const item of parsed.items) {
// Check if this entry references the target
if (
item.type?.includes("h-entry") &&
entryReferencesTarget(item, normalizedTarget)
) {
return item;
}
// Check children
if (item.children) {
for (const child of item.children) {
if (
child.type?.includes("h-entry") &&
entryReferencesTarget(child, normalizedTarget)
) {
return child;
}
}
}
}
// Return first h-entry as fallback
for (const item of parsed.items) {
if (item.type?.includes("h-entry")) {
return item;
}
}
return;
}
/**
* Check if an entry references the target URL
* @param {object} entry - h-entry object
* @param {string} target - Normalized target URL
* @returns {boolean} Whether the entry references the target
*/
function entryReferencesTarget(entry, target) {
const properties = entry.properties || {};
// Check interaction properties
const interactionProperties = [
"in-reply-to",
"like-of",
"repost-of",
"bookmark-of",
];
for (const property of interactionProperties) {
const values = properties[property] || [];
for (const value of values) {
const url =
typeof value === "string" ? value : value?.properties?.url?.[0];
if (url && normalizeUrl(url) === target) {
return true;
}
}
}
return false;
}
/**
* Detect the type of webmention
* @param {object} entry - h-entry object
* @param {string} target - Target URL
* @returns {string} Mention type
*/
function detectMentionType(entry, target) {
const properties = entry.properties || {};
const normalizedTarget = target.replace(/\/$/, "");
// Check for specific interaction types
if (matchesTarget(properties["like-of"], normalizedTarget)) {
return "like";
}
if (matchesTarget(properties["repost-of"], normalizedTarget)) {
return "repost";
}
if (matchesTarget(properties["bookmark-of"], normalizedTarget)) {
return "bookmark";
}
if (matchesTarget(properties["in-reply-to"], normalizedTarget)) {
return "reply";
}
return "mention";
}
/**
* Check if any value in array matches target
* @param {Array} values - Array of values
* @param {string} target - Target URL to match
* @returns {boolean} Whether any value matches
*/
function matchesTarget(values, target) {
if (!values || values.length === 0) return false;
for (const value of values) {
const url = typeof value === "string" ? value : value?.properties?.url?.[0];
if (url && normalizeUrl(url) === target) {
return true;
}
}
return false;
}
/**
* Extract author from entry or page
* @param {object} entry - h-entry object
* @param {object} parsed - Full parsed microformats
* @returns {object|undefined} Author object
*/
function extractAuthor(entry, parsed) {
const author = getFirst(entry.properties?.author);
if (typeof author === "string") {
return { name: author };
}
if (author?.type?.includes("h-card")) {
return {
type: "card",
name: getFirst(author.properties?.name),
url: getFirst(author.properties?.url),
photo: getFirst(author.properties?.photo),
};
}
// Try to find author from page's h-card
const hcard = parsed.items.find((item) => item.type?.includes("h-card"));
if (hcard) {
return {
type: "card",
name: getFirst(hcard.properties?.name),
url: getFirst(hcard.properties?.url),
photo: getFirst(hcard.properties?.photo),
};
}
return;
}
/**
* Extract content from entry
* @param {object} entry - h-entry object
* @returns {object|undefined} Content object
*/
function extractContent(entry) {
const content = getFirst(entry.properties?.content);
if (!content) {
const summary = getFirst(entry.properties?.summary);
const name = getFirst(entry.properties?.name);
return summary || name ? { text: summary || name } : undefined;
}
if (typeof content === "string") {
return { text: content };
}
return {
text: content.value,
html: content.html ? sanitizeHtml(content.html, SANITIZE_OPTIONS) : undefined,
};
}
/**
* Get first item from array
* @param {Array|*} value - Value or array
* @returns {*} First value
*/
function getFirst(value) {
return Array.isArray(value) ? value[0] : value;
}
/**
* Normalize URL for comparison
* @param {string} url - URL to normalize
* @returns {string} Normalized URL
*/
function normalizeUrl(url) {
return url.replace(/\/$/, "");
}
/**
* Escape special regex characters
* @param {string} string - String to escape
* @returns {string} Escaped string
*/
function escapeRegex(string) {
return string.replaceAll(/[$()*+.?[\\\]^{|}]/g, String.raw`\$&`);
}