Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions app/api/og/route.tsx
Original file line number Diff line number Diff line change
@@ -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";

Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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
<img
src={articleImage}
alt=""
Expand Down
1 change: 1 addition & 0 deletions components/ui/image-lightbox.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,7 @@ export function ImageLightbox({
className="lightbox-figure"
onPointerDown={(e) => e.stopPropagation()}
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
key={image.src}
src={image.src}
Expand Down
59 changes: 59 additions & 0 deletions lib/validation/url.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,65 @@ describe("normalizeUrl", () => {
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");
Expand Down
31 changes: 30 additions & 1 deletion lib/validation/url.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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, "");

Expand Down Expand Up @@ -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;
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
117 changes: 117 additions & 0 deletions scripts/check-i18n.ts
Original file line number Diff line number Diff line change
@@ -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<string, ShapeType>;

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.");
Loading