diff --git a/app/api/og/route.tsx b/app/api/og/route.tsx index 116059d..0b4aed3 100644 --- a/app/api/og/route.tsx +++ b/app/api/og/route.tsx @@ -1,5 +1,6 @@ import { ImageResponse } from "next/og"; import { NextRequest } from "next/server"; +import { isPrivateIP, BLOCKED_HOSTNAMES } from "@/lib/validation/url"; export const runtime = "edge"; @@ -66,6 +67,29 @@ export async function GET(request: NextRequest) { title = title.substring(0, 77) + "..."; } + // Validate articleImage to prevent SSRF attacks + // Reject any image URL pointing to private/internal IPs or blocked hostnames + if (articleImage) { + try { + const imageUrl = new URL(articleImage); + + // Only allow http/https protocols + if (imageUrl.protocol !== "http:" && imageUrl.protocol !== "https:") { + articleImage = ""; + } else { + const imageHost = imageUrl.hostname; + + // Check if hostname is a private IP address or blocked hostname + if (isPrivateIP(imageHost) || BLOCKED_HOSTNAMES.has(imageHost)) { + articleImage = ""; // Clear potentially malicious image URL + } + } + } catch { + // Invalid URL format, clear the image + articleImage = ""; + } + } + // Load fonts with error handling let syneBold: ArrayBuffer; let interRegular: ArrayBuffer; @@ -122,6 +146,7 @@ export async function GET(request: NextRequest) { > {/* Article image as background (if available) */} {articleImage && ( + // eslint-disable-next-line @next/next/no-img-element e.stopPropagation()} > + {/* eslint-disable-next-line @next/next/no-img-element */} { expect(() => normalizeUrl("http://127.0.0.1/test")).toThrow("Cannot summarize SMRY URLs"); }); + describe("edge-case hosts", () => { + it("allows URLs with userinfo", () => { + const result = normalizeUrl("https://user:pass@example.com/path"); + expect(result).toBe("https://user:pass@example.com/path"); + }); + + it("allows public IPv6 literals", () => { + const result = normalizeUrl("https://[2606:4700:4700::1111]/"); + expect(result).toBe("https://[2606:4700:4700::1111]/"); + }); + + it("blocks IPv6 loopback", () => { + expect(() => normalizeUrl("http://[::1]/")).toThrow( + "Cannot summarize SMRY URLs" + ); + }); + + it("blocks IPv6 unique local addresses", () => { + expect(() => normalizeUrl("http://[fd00::1]/")).toThrow( + "Cannot access internal or private network addresses." + ); + }); + + it("blocks IPv4-mapped private IPv6 addresses", () => { + expect(() => normalizeUrl("http://[::ffff:10.0.0.1]/")).toThrow( + "Cannot access internal or private network addresses." + ); + }); + + it("blocks IPv4-mapped hex-hextet private IPv6 addresses", () => { + expect(() => normalizeUrl("http://[::ffff:a00:1]/")).toThrow( + "Cannot access internal or private network addresses." + ); + }); + + it("blocks expanded IPv4-mapped private IPv6 addresses", () => { + expect(() => normalizeUrl("http://[0:0:0:0:0:ffff:10.0.0.1]/")).toThrow( + "Cannot access internal or private network addresses." + ); + }); + + it("allows IPv4-mapped public IPv6 addresses", () => { + const result = normalizeUrl("http://[::ffff:8.8.8.8]/"); + expect(result).toBe("http://[::ffff:8.8.8.8]/"); + }); + + it("allows IPv4-mapped hex-hextet public IPv6 addresses", () => { + // ::ffff:808:808 == ::ffff:8.8.8.8 + const result = normalizeUrl("http://[::ffff:808:808]/"); + expect(result).toBe("http://[::ffff:808:808]/"); + }); + + it("blocks cloud metadata endpoints", () => { + expect(() => normalizeUrl("http://169.254.169.254/latest/meta-data/")).toThrow( + "Cannot summarize SMRY URLs" + ); + }); + }); + describe("duplicate protocol handling", () => { it("removes duplicate protocols at start", () => { const result = normalizeUrl("https://https://example.com"); diff --git a/lib/validation/url.ts b/lib/validation/url.ts index ae3c9e5..f594419 100644 --- a/lib/validation/url.ts +++ b/lib/validation/url.ts @@ -39,11 +39,14 @@ const BLOCKED_HOSTNAMES = new Set([ "kubernetes.default.svc", ]); +// Export for use in OG route and other security checks +export { BLOCKED_HOSTNAMES }; + /** * Check if an IP address is in a private/internal range. * This catches SSRF attempts using raw IP addresses. */ -function isPrivateIP(hostname: string): boolean { +export function isPrivateIP(hostname: string): boolean { // Remove brackets from IPv6 const cleanHost = hostname.replace(/^\[|\]$/g, ""); @@ -82,6 +85,32 @@ function isPrivateIP(hostname: string): boolean { if (mappedMatch) { return isPrivateIP(mappedMatch[1]); } + + // IPv4-mapped IPv6 expressed as hex hextets (e.g., ::ffff:a00:1) + if (lower.startsWith("::ffff:")) { + const tail = lower.slice("::ffff:".length); + const parts = tail.split(":"); + if (parts.length === 2 && parts.every((part) => /^[0-9a-f]{1,4}$/.test(part))) { + const high = parseInt(parts[0], 16); + const low = parseInt(parts[1], 16); + const ipv4 = [ + (high >> 8) & 0xff, + high & 0xff, + (low >> 8) & 0xff, + low & 0xff, + ].join("."); + return isPrivateIP(ipv4); + } + } + + // Handle expanded IPv4-mapped IPv6 forms (e.g., 0:0:0:0:0:ffff:10.0.0.1) + if (lower.includes("ffff:")) { + const lastColon = lower.lastIndexOf(":"); + const tail = lastColon >= 0 ? lower.slice(lastColon + 1) : ""; + if (tail.includes(".") && /^\d{1,3}(?:\.\d{1,3}){3}$/.test(tail)) { + return isPrivateIP(tail); + } + } } return false; diff --git a/package.json b/package.json index f15b523..d5c0e47 100644 --- a/package.json +++ b/package.json @@ -25,6 +25,7 @@ "pages:deploy": "bunx wrangler pages deploy .vercel/output/static", "prepare": "husky || true", "check-env": "bun scripts/check-env.ts", + "check-i18n": "bun scripts/check-i18n.ts", "check-deps": "knip --dependencies --no-config-hints --exclude unlisted", "email:changelog": "bun scripts/send-changelog-email.ts", "email:changelog:dry": "bun scripts/send-changelog-email.ts --dry-run" diff --git a/scripts/check-i18n.ts b/scripts/check-i18n.ts new file mode 100644 index 0000000..d026436 --- /dev/null +++ b/scripts/check-i18n.ts @@ -0,0 +1,117 @@ +#!/usr/bin/env bun +/** + * Verify all locale JSON files match the key structure of messages/en.json. + * Run with: bun run check-i18n + */ + +import { readFileSync, readdirSync, existsSync } from "fs"; +import { join } from "path"; + +type ShapeType = "object" | "array" | "leaf"; + +type ShapeMap = Map; + +function readJson(filePath: string): unknown { + const raw = readFileSync(filePath, "utf-8"); + return JSON.parse(raw); +} + +function collectShape(value: unknown, prefix: string, shape: ShapeMap): void { + if (Array.isArray(value)) { + if (prefix) shape.set(prefix, "array"); + return; + } + + if (value && typeof value === "object") { + if (prefix) shape.set(prefix, "object"); + for (const [key, child] of Object.entries(value)) { + const next = prefix ? `${prefix}.${key}` : key; + collectShape(child, next, shape); + } + return; + } + + if (prefix) shape.set(prefix, "leaf"); +} + +function compareShapes(base: ShapeMap, target: ShapeMap) { + const missing: string[] = []; + const extra: string[] = []; + const typeMismatches: string[] = []; + + for (const [key, baseType] of base.entries()) { + const targetType = target.get(key); + if (!targetType) { + missing.push(key); + continue; + } + if (baseType !== targetType) { + typeMismatches.push(`${key} (${baseType} -> ${targetType})`); + } + } + + for (const key of target.keys()) { + if (!base.has(key)) extra.push(key); + } + + return { missing, extra, typeMismatches }; +} + +const projectRoot = process.cwd(); +const messagesDir = join(projectRoot, "messages"); +const baseFile = join(messagesDir, "en.json"); + +if (!existsSync(baseFile)) { + console.error(`Base locale file not found: ${baseFile}`); + process.exit(1); +} + +const baseJson = readJson(baseFile); +const baseShape: ShapeMap = new Map(); +collectShape(baseJson, "", baseShape); + +const localeFiles = readdirSync(messagesDir) + .filter((file) => file.endsWith(".json")) + .filter((file) => file !== "en.json") + .sort(); + +let hasIssues = false; + +for (const file of localeFiles) { + const fullPath = join(messagesDir, file); + const localeJson = readJson(fullPath); + const localeShape: ShapeMap = new Map(); + collectShape(localeJson, "", localeShape); + + const { missing, extra, typeMismatches } = compareShapes(baseShape, localeShape); + + if (missing.length || extra.length || typeMismatches.length) { + hasIssues = true; + console.log(`\n${file}:`); + if (missing.length) { + console.log(` Missing (${missing.length})`); + missing.forEach((key) => { + console.log(` - ${key}`); + }); + } + if (extra.length) { + console.log(` Extra (${extra.length})`); + extra.forEach((key) => { + console.log(` - ${key}`); + }); + } + if (typeMismatches.length) { + console.log(` Type mismatches (${typeMismatches.length})`); + typeMismatches.forEach((key) => { + console.log(` - ${key}`); + }); + } + } +} + +if (hasIssues) { + console.error("\nI18n check failed. Align locale keys with messages/en.json."); + process.exit(1); +} + +console.log("I18n check passed. All locale keys match messages/en.json."); diff --git a/server/index.test.ts b/server/index.test.ts index cea60a3..7411863 100644 --- a/server/index.test.ts +++ b/server/index.test.ts @@ -67,7 +67,7 @@ describe("Elysia API Server", () => { ); // May return 200 or 500 depending on external service - just verify route is hit expect([200, 500]).toContain(response.status); - }); + }, { timeout: 15000 }); it("should accept valid smry-slow source", async () => { const response = await app.handle( @@ -75,7 +75,7 @@ describe("Elysia API Server", () => { ); // Just verify route accepts the source expect([200, 500]).toContain(response.status); - }); + }, { timeout: 15000 }); it("should accept valid wayback source", async () => { // Note: Wayback Machine can be slow, so we just verify the route accepts the parameter @@ -104,7 +104,8 @@ describe("Elysia API Server", () => { ); // May return 200 or 500 depending on ClickHouse availability - expect([200, 500]).toContain(response.status); + // May return 200/500 (ClickHouse) or 401 if admin auth is enabled + expect([200, 500, 401]).toContain(response.status); if (response.status === 200) { const body = await response.json(); @@ -120,7 +121,7 @@ describe("Elysia API Server", () => { const response = await app.handle( new Request("http://localhost/api/admin?range=1h") ); - expect([200, 500]).toContain(response.status); + expect([200, 500, 401]).toContain(response.status); if (response.status === 200) { const body = await response.json(); @@ -132,7 +133,7 @@ describe("Elysia API Server", () => { const response = await app.handle( new Request("http://localhost/api/admin?range=7d") ); - expect([200, 500]).toContain(response.status); + expect([200, 500, 401]).toContain(response.status); if (response.status === 200) { const body = await response.json(); @@ -144,7 +145,7 @@ describe("Elysia API Server", () => { const response = await app.handle( new Request("http://localhost/api/admin?hostname=example.com&source=smry-fast&outcome=success") ); - expect([200, 500]).toContain(response.status); + expect([200, 500, 401]).toContain(response.status); if (response.status === 200) { const body = await response.json(); @@ -159,7 +160,7 @@ describe("Elysia API Server", () => { const response = await app.handle( new Request("http://localhost/api/admin?urlSearch=test") ); - expect([200, 500]).toContain(response.status); + expect([200, 500, 401]).toContain(response.status); if (response.status === 200) { const body = await response.json(); @@ -251,7 +252,7 @@ describe("Article Route Integration", () => { expect(body.article.length).toBeGreaterThan(0); expect(body.article.htmlContent).toBeDefined(); // Original HTML for "Original" tab } - }); + }, { timeout: 15000 }); it("should return article with htmlContent for Original tab", async () => { const response = await app.handle( @@ -265,7 +266,7 @@ describe("Article Route Integration", () => { expect(typeof body.article.htmlContent).toBe("string"); expect(body.article.htmlContent.length).toBeGreaterThan(0); } - }); + }, { timeout: 15000 }); }); describe("HTML Content for Original View", () => { @@ -292,7 +293,7 @@ describe("HTML Content for Original View", () => { expect(body.article.htmlContent).toContain(""); expect(body.article.htmlContent).toContain(""); - }); + }, { timeout: 15000 }); it("should return htmlContent with complete HTML structure", async () => { // htmlContent should contain the original HTML @@ -308,7 +309,7 @@ describe("HTML Content for Original View", () => { expect(body.article.htmlContent).toContain(""); expect(body.article.htmlContent).toContain(""); - }); + }, { timeout: 15000 }); it("should include htmlContent in cache hit response", async () => { // First request to populate cache @@ -327,6 +328,6 @@ describe("HTML Content for Original View", () => { expect(body.article).toBeDefined(); expect(body.article.htmlContent).toBeDefined(); expect(body.article.htmlContent.length).toBeGreaterThan(0); - }); + }, { timeout: 30000 }); }); diff --git a/server/routes/gravity.ts b/server/routes/gravity.ts index bda961f..6edac4f 100644 --- a/server/routes/gravity.ts +++ b/server/routes/gravity.ts @@ -150,12 +150,12 @@ export const gravityRoutes = new Elysia({ prefix: "/api" }) .post( "/px", async ({ body, set }) => { - const { type, sessionId, hostname, brandName, adTitle, adText, clickUrl, impUrl, cta, favicon, deviceType, os, browser, adProvider } = body; + const { type, sessionId, hostname, brandName, adTitle, adText, clickUrl, impUrl, cta, favicon, deviceType, os, browser, adProvider: _adProvider } = body; // Derive provider from impUrl prefix only — never trust client-sent adProvider // for forwarding decisions (prevents spoofing to skip Gravity billing) const isZeroClick = impUrl?.startsWith("zeroclick://") ?? false; - // adProvider from client is used only for ClickHouse logging, not forwarding logic + // _adProvider from client is accepted but intentionally unused const provider = isZeroClick ? "zeroclick" : "gravity"; // For impressions with impUrl, forward to the appropriate provider