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