Archived
upstream fetch, magic-byte validation, persistent /data/media cache, immutable cache headers, CoverImage fallback component, og:image to absolute proxy URL. Ported from technews 6f08da3.
144 lines
5.6 KiB
TypeScript
144 lines
5.6 KiB
TypeScript
/**
|
|
* Image-proxy helper tests (offline — no network).
|
|
*
|
|
* Contract under test:
|
|
* - sniffImage: magic-byte → Content-Type for jpeg/png/gif/webp/avif/heic/bmp,
|
|
* null for HTML blockpages and junk (a hotlink guard returning 200 HTML
|
|
* must never be served as an image).
|
|
* - isSafeProxyTarget: public http(s) only — no loopback, RFC1918, link-local,
|
|
* IPv6 private, or non-web schemes; our own site origin rejected.
|
|
* - proxyImageUrl: remote http(s) → /images?url=... ; relative / data: /
|
|
* our-own-origin URLs pass through untouched.
|
|
*
|
|
* Runner: `tsx tests/image-proxy.test.ts` → TAP-style, exit 1 on failure.
|
|
*/
|
|
import assert from 'node:assert/strict';
|
|
|
|
process.env.NEXT_PUBLIC_SITE_URL = 'https://technews.krisforbes.ca';
|
|
|
|
import {
|
|
isSafeProxyTarget,
|
|
proxyImageUrl,
|
|
isPrivateHost,
|
|
} from '@/lib/image-mapping';
|
|
import { mediaKey, sniffImage } from '@/lib/images';
|
|
|
|
let passed = 0;
|
|
let failed = 0;
|
|
|
|
function check(name: string, fn: () => void): void {
|
|
try {
|
|
fn();
|
|
passed += 1;
|
|
console.log(`ok ${passed + failed} ${name}`);
|
|
} catch (err) {
|
|
failed += 1;
|
|
console.log(`not ok ${passed + failed} ${name}`);
|
|
console.log(` ${(err as Error).message}`);
|
|
}
|
|
}
|
|
|
|
const b = (b64: string) => Buffer.from(b64, 'base64');
|
|
// Minimal valid magic headers (payload irrelevant to the sniff).
|
|
const JPEG = b(
|
|
'/9j/4AAQSkZJRgABAQAAAQABAAD/2wBDAAMCAgMCAgMDAwMEAwMEBQgFBQQEBQoHBwYIDAoKBwYKDwMNDhgMEggRCwUNDAwTFBMSFBQUFxQVFRUUgAFMAAQHBgMCAwYHBgcKEA0HCAkKDw0NDhERCg0RHREKCA8VEg0RERoNDAwQGiYNDg8VIRUQNBMfISEYGRM0KhwjGhs0MioaIxwkIhgY',
|
|
);
|
|
const PNG = b('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAACklEQVR4nGMYAQCABQAB6V282gAAAABJRU5ErkJggg==');
|
|
const WEBP = b('UklGRlQAAABXRUJQVlA4IBoAAAAwAQCdASoBAAEAAUAmJaQAA3AA/vuUAAA=');
|
|
const AVIF = Buffer.concat([Buffer.alloc(4), Buffer.from('ftypavif', 'ascii')]);
|
|
const GIF = b('R0lGODlhAQABAIAAAP///wAAACH5BAEAAAAALAAAAAABAAEAAAICRAEAOw==');
|
|
const HTML = Buffer.from('<!doctype html><html><body><h1>Access Denied</h1><p>The referenced entity does not exist</p>');
|
|
|
|
check('sniff: jpeg magic → image/jpeg', () => {
|
|
assert.equal(sniffImage(JPEG), 'image/jpeg');
|
|
});
|
|
check('sniff: png magic → image/png', () => {
|
|
assert.equal(sniffImage(PNG), 'image/png');
|
|
});
|
|
check('sniff: webp magic → image/webp', () => {
|
|
assert.equal(sniffImage(WEBP), 'image/webp');
|
|
});
|
|
check('sniff: avif ftyp → image/avif', () => {
|
|
assert.equal(sniffImage(AVIF), 'image/avif');
|
|
});
|
|
check('sniff: gif magic → image/gif', () => {
|
|
assert.equal(sniffImage(GIF), 'image/gif');
|
|
});
|
|
check('sniff: HTML blockpage → null', () => {
|
|
assert.equal(sniffImage(HTML), null);
|
|
});
|
|
check('sniff: short/garbage buffer → null', () => {
|
|
assert.equal(sniffImage(Buffer.from('ab')), null);
|
|
});
|
|
|
|
check('ssrf: public https ok', () => {
|
|
assert.ok(isSafeProxyTarget('https://ichef.bbci.co.uk/ace/branded_news/x.jpg'));
|
|
});
|
|
check('ssrf: public http ok', () => {
|
|
assert.ok(isSafeProxyTarget('http://example.com/a.webp'));
|
|
});
|
|
check('ssrf: localhost blocked', () => {
|
|
assert.ok(!isSafeProxyTarget('http://localhost:3000/images?url=x'));
|
|
});
|
|
check('ssrf: 127/10/192.168/169.254 blocked', () => {
|
|
assert.ok(!isSafeProxyTarget('http://127.0.0.1/a'));
|
|
assert.ok(!isSafeProxyTarget('http://10.0.0.5/a'));
|
|
assert.ok(!isSafeProxyTarget('http://192.168.1.2/a'));
|
|
assert.ok(!isSafeProxyTarget('http://169.254.169.254/latest'));
|
|
});
|
|
check('ssrf: 172.16-31 blocked, 172.15/172.32 ok', () => {
|
|
assert.ok(!isSafeProxyTarget('http://172.17.0.1:11434/'));
|
|
assert.ok(!isSafeProxyTarget('http://172.16.0.9/'));
|
|
assert.ok(!isSafeProxyTarget('http://172.31.9.9/'));
|
|
assert.ok(isSafeProxyTarget('http://172.15.255.1/')); // below private range
|
|
assert.ok(isSafeProxyTarget('http://172.32.0.1/')); // above private range
|
|
});
|
|
check('ssrf: ipv6 loopback/link-local/ULA blocked, mapped private blocked', () => {
|
|
assert.ok(!isSafeProxyTarget('http://[::1]/a'));
|
|
assert.ok(!isSafeProxyTarget('http://[fe80::1]/a'));
|
|
assert.ok(!isSafeProxyTarget('http://[fd00::1]/a'));
|
|
assert.ok(!isSafeProxyTarget('http://[::ffff:127.0.0.1]/a'));
|
|
assert.ok(!isSafeProxyTarget('http://[::ffff:192.168.1.2]/a'));
|
|
assert.ok(!isSafeProxyTarget('http://[::]/a'));
|
|
});
|
|
check('ssrf: non-web schemes blocked', () => {
|
|
assert.ok(!isSafeProxyTarget('file:///etc/passwd'));
|
|
assert.ok(!isSafeProxyTarget('gopher://example.com'));
|
|
assert.ok(!isSafeProxyTarget('blob:https://x.com/abc'));
|
|
});
|
|
check('ssrf: our own origin rejected (no self-proxy)', () => {
|
|
assert.ok(!isSafeProxyTarget('https://technews.krisforbes.ca/article/x'));
|
|
});
|
|
check('ssrf: unparseable url → false', () => {
|
|
assert.ok(!isSafeProxyTarget('not a url'));
|
|
});
|
|
|
|
check('proxy: remote url → /images?url=...', () => {
|
|
const u = 'https://data-api.investing.com/trkd-images/abc.jpg';
|
|
assert.equal(proxyImageUrl(u), `/images?url=${encodeURIComponent(u)}`);
|
|
});
|
|
check('proxy: relative path passes through', () => {
|
|
assert.equal(proxyImageUrl('/covers/tech.svg'), null);
|
|
});
|
|
check('proxy: data URI passes through', () => {
|
|
assert.equal(proxyImageUrl('data:image/png;base64,AAA='), null);
|
|
});
|
|
check('proxy: own-origin url passes through', () => {
|
|
assert.equal(proxyImageUrl('https://technews.krisforbes.ca/logo.png'), null);
|
|
});
|
|
check('proxy: empty/blank → null', () => {
|
|
assert.equal(proxyImageUrl(null), null);
|
|
assert.equal(proxyImageUrl(' '), null);
|
|
});
|
|
check('key: stable + 40 hex chars', () => {
|
|
const k1 = mediaKey('https://x.com/a.jpg');
|
|
const k2 = mediaKey('https://x.com/a.jpg');
|
|
assert.equal(k1, k2);
|
|
assert.match(k1, /^[0-9a-f]{40}$/);
|
|
assert.notEqual(k1, mediaKey('https://x.com/b.jpg'));
|
|
});
|
|
|
|
const total = passed + failed;
|
|
console.log(`\n# tests ${total}, pass ${passed}, fail ${failed}`);
|
|
process.exit(failed === 0 ? 0 : 1);
|