mirror of
https://github.com/svemagie/indiekit-endpoint-microsub.git
synced 2026-04-02 15:35:00 +02:00
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>
This commit is contained in:
@@ -7,6 +7,60 @@ import crypto from "node:crypto";
|
|||||||
|
|
||||||
import { getCache, setCache } from "../cache/redis.js";
|
import { getCache, setCache } from "../cache/redis.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Private/internal IP ranges that should never be fetched (SSRF protection)
|
||||||
|
*/
|
||||||
|
const BLOCKED_HOSTNAMES = new Set(["localhost", "0.0.0.0"]);
|
||||||
|
const BLOCKED_IP_PREFIXES = [
|
||||||
|
"127.", // Loopback
|
||||||
|
"10.", // Private Class A
|
||||||
|
"192.168.", // Private Class C
|
||||||
|
"169.254.", // Link-local
|
||||||
|
"0.", // Current network
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a hostname resolves to a private/internal address
|
||||||
|
* @param {string} urlString - URL to check
|
||||||
|
* @returns {boolean} True if the URL targets a private/internal address
|
||||||
|
*/
|
||||||
|
export function isPrivateUrl(urlString) {
|
||||||
|
try {
|
||||||
|
const parsed = new URL(urlString);
|
||||||
|
const hostname = parsed.hostname;
|
||||||
|
|
||||||
|
// Block known private hostnames
|
||||||
|
if (BLOCKED_HOSTNAMES.has(hostname)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Block IPv6 loopback
|
||||||
|
if (hostname === "::1" || hostname === "[::1]") {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Block private IPv4 ranges
|
||||||
|
for (const prefix of BLOCKED_IP_PREFIXES) {
|
||||||
|
if (hostname.startsWith(prefix)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Block 172.16.0.0/12 (172.16.x.x - 172.31.x.x)
|
||||||
|
const match172 = hostname.match(/^172\.(\d+)\./);
|
||||||
|
if (match172) {
|
||||||
|
const second = Number.parseInt(match172[1], 10);
|
||||||
|
if (second >= 16 && second <= 31) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
} catch {
|
||||||
|
return true; // Invalid URLs are blocked
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const MAX_SIZE = 2 * 1024 * 1024; // 2MB max image size
|
const MAX_SIZE = 2 * 1024 * 1024; // 2MB max image size
|
||||||
const CACHE_TTL = 4 * 60 * 60; // 4 hours
|
const CACHE_TTL = 4 * 60 * 60; // 4 hours
|
||||||
const ALLOWED_TYPES = new Set([
|
const ALLOWED_TYPES = new Set([
|
||||||
@@ -99,6 +153,12 @@ export function proxyItemImages(item, baseUrl) {
|
|||||||
* @returns {Promise<object|null>} Cached image data or null
|
* @returns {Promise<object|null>} Cached image data or null
|
||||||
*/
|
*/
|
||||||
export async function fetchImage(redis, url) {
|
export async function fetchImage(redis, url) {
|
||||||
|
// Block private/internal URLs (defense-in-depth)
|
||||||
|
if (isPrivateUrl(url)) {
|
||||||
|
console.error(`[Microsub] Media proxy blocked private URL: ${url}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const cacheKey = `media:${hashUrl(url)}`;
|
const cacheKey = `media:${hashUrl(url)}`;
|
||||||
|
|
||||||
// Try cache first
|
// Try cache first
|
||||||
@@ -194,6 +254,11 @@ export async function handleMediaProxy(request, response) {
|
|||||||
return response.status(400).send("Invalid URL");
|
return response.status(400).send("Invalid URL");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Block requests to private/internal networks (SSRF protection)
|
||||||
|
if (isPrivateUrl(url)) {
|
||||||
|
return response.status(403).send("URL not allowed");
|
||||||
|
}
|
||||||
|
|
||||||
// Get Redis client from application
|
// Get Redis client from application
|
||||||
const { application } = request.app.locals;
|
const { application } = request.app.locals;
|
||||||
const redis = application.redis;
|
const redis = application.redis;
|
||||||
@@ -202,8 +267,7 @@ export async function handleMediaProxy(request, response) {
|
|||||||
const imageData = await fetchImage(redis, url);
|
const imageData = await fetchImage(redis, url);
|
||||||
|
|
||||||
if (!imageData) {
|
if (!imageData) {
|
||||||
// Redirect to original URL as fallback
|
return response.status(404).send("Image not available");
|
||||||
return response.redirect(url);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set cache headers
|
// Set cache headers
|
||||||
|
|||||||
@@ -602,7 +602,11 @@ export async function searchItems(application, channelId, query, limit = 20) {
|
|||||||
typeof channelId === "string" ? new ObjectId(channelId) : channelId;
|
typeof channelId === "string" ? new ObjectId(channelId) : channelId;
|
||||||
|
|
||||||
// Use regex search (consider adding text index for better performance)
|
// Use regex search (consider adding text index for better performance)
|
||||||
const regex = new RegExp(query, "i");
|
const escapedQuery = query.replaceAll(
|
||||||
|
/[$()*+.?[\\\]^{|}]/g,
|
||||||
|
String.raw`\$&`,
|
||||||
|
);
|
||||||
|
const regex = new RegExp(escapedQuery, "i");
|
||||||
const items = await collection
|
const items = await collection
|
||||||
.find({
|
.find({
|
||||||
channelId: objectId,
|
channelId: objectId,
|
||||||
|
|||||||
@@ -4,6 +4,29 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { mf2 } from "microformats-parser";
|
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
|
* Verify a webmention
|
||||||
@@ -276,7 +299,7 @@ function extractContent(entry) {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
text: content.value,
|
text: content.value,
|
||||||
html: content.html,
|
html: content.html ? sanitizeHtml(content.html, SANITIZE_OPTIONS) : undefined,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user