simple list of pds servers with open registration

Add SSRF protection and XSS hardening for ingestion pipeline

Validate PDS URLs on ingest (https-only, no private IPs, no bare IPs,
no single-label hostnames). Guard enrichment against DNS rebinding by
checking resolved IPs. Sanitize href attributes to block javascript:
URIs and harden esc() with single-quote escaping.

+231 -4
+6 -3
backend/routes/pages.ts
··· 6 6 } from "../database/queries.ts"; 7 7 import { countryFlag } from "../../shared/constants.ts"; 8 8 import type { PdsServer } from "../../shared/types.ts"; 9 + import { isSafeHref } from "../../shared/url-validation.ts"; 9 10 10 11 const pages = new Hono(); 11 12 ··· 257 258 ): string { 258 259 const { score, breakdown } = trust; 259 260 const hostname = new URL(s.url).hostname; 260 - const flag = s.country_code ? countryFlag(s.country_code) + " " : ""; 261 + const safeUrl = isSafeHref(s.url) ? s.url : "#"; 262 + const flag = s.country_code ? esc(countryFlag(s.country_code)) + " " : ""; 261 263 const country = s.country_name || ""; 262 264 263 265 let versionClass = "version-outdated"; ··· 287 289 esc(breakdown) 288 290 }">${score}%</span></td> 289 291 <td class="hostname"><a href="${ 290 - esc(s.url) 292 + esc(safeUrl) 291 293 }" target="_blank" rel="noopener">${esc(hostname)}</a></td> 292 294 <td class="hide-mobile">${flag}${esc(country)}</td> 293 295 <td><span class="version-badge ${versionClass}">${ ··· 306 308 .replace(/&/g, "&amp;") 307 309 .replace(/</g, "&lt;") 308 310 .replace(/>/g, "&gt;") 309 - .replace(/"/g, "&quot;"); 311 + .replace(/"/g, "&quot;") 312 + .replace(/'/g, "&#x27;"); 310 313 } 311 314 312 315 export { pages };
+10 -1
backend/services/pds-enricher.ts
··· 4 4 HealthResponse, 5 5 ServerToEnrich, 6 6 } from "../../shared/types.ts"; 7 + import { isPrivateIp, isValidPdsUrl } from "../../shared/url-validation.ts"; 7 8 import { batchGeoLookup, resolveIp } from "./geo-resolver.ts"; 8 9 9 10 export type EnrichmentResult = { ··· 100 101 url: string, 101 102 existingIp?: string | null, 102 103 ): Promise<EnrichmentResult> { 104 + if (!isValidPdsUrl(url)) { 105 + throw new Error(`Refusing to enrich invalid URL: ${url}`); 106 + } 107 + 103 108 const result = emptyResult(url); 104 109 105 - // Reuse existing IP or resolve fresh 110 + // Reuse existing IP or resolve fresh, then verify it's not private 106 111 if (existingIp) { 107 112 result.ipAddress = existingIp; 108 113 } else { 109 114 const hostname = new URL(url).hostname; 110 115 result.ipAddress = await resolveIp(hostname); 116 + } 117 + 118 + if (result.ipAddress && isPrivateIp(result.ipAddress)) { 119 + throw new Error(`Refusing to contact private IP for ${url}`); 111 120 } 112 121 113 122 // Fetch health, describeServer, and listRepos concurrently
+9
backend/services/pds-fetcher.ts
··· 1 1 import { STATE_JSON_URL } from "../../shared/constants.ts"; 2 2 import type { StateJson } from "../../shared/types.ts"; 3 + import { isValidPdsUrl } from "../../shared/url-validation.ts"; 3 4 4 5 export type FilteredPds = { 5 6 url: string; ··· 37 38 const data: StateJson = await resp.json(); 38 39 const results: FilteredPds[] = []; 39 40 41 + let skipped = 0; 40 42 for (const [url, entry] of Object.entries(data.pdses)) { 43 + if (!isValidPdsUrl(url)) { 44 + skipped++; 45 + continue; 46 + } 41 47 const isOpen = !entry.inviteCodeRequired && !entry.errorAt; 42 48 results.push({ 43 49 url, ··· 46 52 errorAt: entry.errorAt ?? null, 47 53 isOpen, 48 54 }); 55 + } 56 + if (skipped > 0) { 57 + console.warn(`Skipped ${skipped} PDS URLs that failed validation`); 49 58 } 50 59 51 60 return { pdsList: results, etag: etag ?? null };
+91
shared/url-validation.ts
··· 1 + /** 2 + * URL and IP validation for PDS ingestion. 3 + * Prevents SSRF by enforcing https: and rejecting private/reserved IPs. 4 + */ 5 + 6 + /** IPv4 ranges that must never be contacted (SSRF protection) */ 7 + const PRIVATE_IP_RANGES: Array<{ prefix: string; mask: number }> = [ 8 + // Loopback 9 + { prefix: "127.", mask: 0 }, 10 + // 10.0.0.0/8 11 + { prefix: "10.", mask: 0 }, 12 + // 172.16.0.0/12 13 + { prefix: "172.", mask: 16 }, 14 + // 192.168.0.0/16 15 + { prefix: "192.168.", mask: 0 }, 16 + // Link-local 17 + { prefix: "169.254.", mask: 0 }, 18 + // Current network 19 + { prefix: "0.", mask: 0 }, 20 + ]; 21 + 22 + /** Cloud metadata IPs to block explicitly */ 23 + const BLOCKED_IPS = new Set([ 24 + "169.254.169.254", // AWS/GCP/Azure metadata 25 + "100.100.100.200", // Alibaba Cloud metadata 26 + "fd00:ec2::254", // AWS IMDSv2 IPv6 27 + ]); 28 + 29 + /** Check if an IP address is in a private/reserved range */ 30 + export function isPrivateIp(ip: string): boolean { 31 + if (BLOCKED_IPS.has(ip)) return true; 32 + 33 + // IPv6 private ranges 34 + if (ip.startsWith("::1") || ip.startsWith("fc") || ip.startsWith("fd") || 35 + ip.startsWith("fe80")) { 36 + return true; 37 + } 38 + 39 + // IPv4 checks 40 + for (const range of PRIVATE_IP_RANGES) { 41 + if (!ip.startsWith(range.prefix)) continue; 42 + 43 + // 172.16.0.0/12: second octet must be 16-31 44 + if (range.prefix === "172." && range.mask === 16) { 45 + const secondOctet = parseInt(ip.split(".")[1], 10); 46 + if (secondOctet >= 16 && secondOctet <= 31) return true; 47 + continue; 48 + } 49 + 50 + return true; 51 + } 52 + 53 + return false; 54 + } 55 + 56 + /** Validate a PDS URL is safe to store and fetch */ 57 + export function isValidPdsUrl(url: string): boolean { 58 + let parsed: URL; 59 + try { 60 + parsed = new URL(url); 61 + } catch { 62 + return false; 63 + } 64 + 65 + // Must be https 66 + if (parsed.protocol !== "https:") return false; 67 + 68 + // No userinfo (user:pass@host) 69 + if (parsed.username || parsed.password) return false; 70 + 71 + // Hostname must not be an IP literal pointing to private range 72 + if (isPrivateIp(parsed.hostname)) return false; 73 + 74 + // Reject bare IPs — PDS servers should have real hostnames 75 + if (/^\d{1,3}(\.\d{1,3}){3}$/.test(parsed.hostname)) return false; 76 + 77 + // Must have a valid hostname with at least one dot (no localhost etc.) 78 + if (!parsed.hostname.includes(".")) return false; 79 + 80 + return true; 81 + } 82 + 83 + /** Validate a URL is safe to render in an href attribute */ 84 + export function isSafeHref(url: string): boolean { 85 + try { 86 + const parsed = new URL(url); 87 + return parsed.protocol === "https:" || parsed.protocol === "http:"; 88 + } catch { 89 + return false; 90 + } 91 + }
+115
shared/url-validation_test.ts
··· 1 + import { 2 + assertEquals, 3 + } from "https://deno.land/std@0.224.0/assert/mod.ts"; 4 + import { 5 + isPrivateIp, 6 + isSafeHref, 7 + isValidPdsUrl, 8 + } from "./url-validation.ts"; 9 + 10 + // --- isPrivateIp --- 11 + 12 + Deno.test("isPrivateIp rejects loopback", () => { 13 + assertEquals(isPrivateIp("127.0.0.1"), true); 14 + assertEquals(isPrivateIp("127.0.1.1"), true); 15 + }); 16 + 17 + Deno.test("isPrivateIp rejects RFC 1918 ranges", () => { 18 + assertEquals(isPrivateIp("10.0.0.1"), true); 19 + assertEquals(isPrivateIp("10.255.255.255"), true); 20 + assertEquals(isPrivateIp("172.16.0.1"), true); 21 + assertEquals(isPrivateIp("172.31.255.255"), true); 22 + assertEquals(isPrivateIp("192.168.0.1"), true); 23 + assertEquals(isPrivateIp("192.168.1.100"), true); 24 + }); 25 + 26 + Deno.test("isPrivateIp allows 172.x outside /12 range", () => { 27 + assertEquals(isPrivateIp("172.15.0.1"), false); 28 + assertEquals(isPrivateIp("172.32.0.1"), false); 29 + }); 30 + 31 + Deno.test("isPrivateIp rejects link-local", () => { 32 + assertEquals(isPrivateIp("169.254.1.1"), true); 33 + }); 34 + 35 + Deno.test("isPrivateIp rejects cloud metadata IPs", () => { 36 + assertEquals(isPrivateIp("169.254.169.254"), true); 37 + assertEquals(isPrivateIp("100.100.100.200"), true); 38 + }); 39 + 40 + Deno.test("isPrivateIp rejects 0.x.x.x", () => { 41 + assertEquals(isPrivateIp("0.0.0.0"), true); 42 + }); 43 + 44 + Deno.test("isPrivateIp rejects IPv6 private ranges", () => { 45 + assertEquals(isPrivateIp("::1"), true); 46 + assertEquals(isPrivateIp("fc00::1"), true); 47 + assertEquals(isPrivateIp("fd12::1"), true); 48 + assertEquals(isPrivateIp("fe80::1"), true); 49 + }); 50 + 51 + Deno.test("isPrivateIp allows public IPs", () => { 52 + assertEquals(isPrivateIp("1.1.1.1"), false); 53 + assertEquals(isPrivateIp("8.8.8.8"), false); 54 + assertEquals(isPrivateIp("93.184.216.34"), false); 55 + assertEquals(isPrivateIp("203.0.113.1"), false); 56 + }); 57 + 58 + // --- isValidPdsUrl --- 59 + 60 + Deno.test("isValidPdsUrl accepts valid https PDS URLs", () => { 61 + assertEquals(isValidPdsUrl("https://pds.example.com"), true); 62 + assertEquals(isValidPdsUrl("https://my.pds.server.org"), true); 63 + assertEquals(isValidPdsUrl("https://bsky.social"), true); 64 + }); 65 + 66 + Deno.test("isValidPdsUrl rejects non-https protocols", () => { 67 + assertEquals(isValidPdsUrl("http://pds.example.com"), false); 68 + assertEquals(isValidPdsUrl("ftp://pds.example.com"), false); 69 + assertEquals(isValidPdsUrl("javascript:alert(1)"), false); 70 + assertEquals(isValidPdsUrl("data:text/html,<h1>hi</h1>"), false); 71 + }); 72 + 73 + Deno.test("isValidPdsUrl rejects bare IP addresses", () => { 74 + assertEquals(isValidPdsUrl("https://1.2.3.4"), false); 75 + assertEquals(isValidPdsUrl("https://192.168.1.1"), false); 76 + }); 77 + 78 + Deno.test("isValidPdsUrl rejects private IP hostnames", () => { 79 + assertEquals(isValidPdsUrl("https://127.0.0.1"), false); 80 + assertEquals(isValidPdsUrl("https://10.0.0.1"), false); 81 + }); 82 + 83 + Deno.test("isValidPdsUrl rejects localhost and single-label hosts", () => { 84 + assertEquals(isValidPdsUrl("https://localhost"), false); 85 + assertEquals(isValidPdsUrl("https://intranet"), false); 86 + }); 87 + 88 + Deno.test("isValidPdsUrl rejects URLs with userinfo", () => { 89 + assertEquals(isValidPdsUrl("https://user:pass@pds.example.com"), false); 90 + assertEquals(isValidPdsUrl("https://admin@pds.example.com"), false); 91 + }); 92 + 93 + Deno.test("isValidPdsUrl rejects malformed URLs", () => { 94 + assertEquals(isValidPdsUrl("not-a-url"), false); 95 + assertEquals(isValidPdsUrl(""), false); 96 + assertEquals(isValidPdsUrl("://missing-scheme"), false); 97 + }); 98 + 99 + // --- isSafeHref --- 100 + 101 + Deno.test("isSafeHref allows https and http URLs", () => { 102 + assertEquals(isSafeHref("https://example.com"), true); 103 + assertEquals(isSafeHref("http://example.com"), true); 104 + }); 105 + 106 + Deno.test("isSafeHref rejects dangerous URI schemes", () => { 107 + assertEquals(isSafeHref("javascript:alert(1)"), false); 108 + assertEquals(isSafeHref("data:text/html,<h1>xss</h1>"), false); 109 + assertEquals(isSafeHref("vbscript:msgbox"), false); 110 + }); 111 + 112 + Deno.test("isSafeHref rejects invalid URLs", () => { 113 + assertEquals(isSafeHref("not-a-url"), false); 114 + assertEquals(isSafeHref(""), false); 115 + });