From 3ec5bcefe7fabb8b68fa92771e63293546282bb7 Mon Sep 17 00:00:00 2001 From: SashaMIT Date: Sun, 9 Aug 2026 16:00:05 +0700 Subject: [PATCH 1/2] fix(locksmith): block private URLs in OG/certification image fetch imageURLToDataURI bare-fetched metadata.image / event_cover_image on unauth OG routes. Reuse assertSafeCallbackUrl and disable redirects. --- locksmith/__tests__/utils/image.test.ts | 55 +++++++++++ .../__tests__/utils/safeCallbackUrl.test.ts | 34 +++++++ locksmith/src/utils/image.ts | 9 +- locksmith/src/utils/safeCallbackUrl.ts | 97 +++++++++++++++++++ 4 files changed, 193 insertions(+), 2 deletions(-) create mode 100644 locksmith/__tests__/utils/image.test.ts create mode 100644 locksmith/__tests__/utils/safeCallbackUrl.test.ts create mode 100644 locksmith/src/utils/safeCallbackUrl.ts diff --git a/locksmith/__tests__/utils/image.test.ts b/locksmith/__tests__/utils/image.test.ts new file mode 100644 index 00000000000..125cc7fa105 --- /dev/null +++ b/locksmith/__tests__/utils/image.test.ts @@ -0,0 +1,55 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' + +vi.mock('../../src/logger', () => ({ + default: { error: vi.fn(), info: vi.fn(), warn: vi.fn() }, +})) + +vi.mock('../../src/utils/lockIcon', () => ({ + default: { + lockIcon: () => '', + }, +})) + +const { imageURLToDataURI } = await import('../../src/utils/image') + +const originalFetch = global.fetch + +afterEach(() => { + global.fetch = originalFetch + vi.restoreAllMocks() +}) + +describe('imageURLToDataURI', () => { + it('fetches public https images', async () => { + global.fetch = vi.fn().mockResolvedValue({ + headers: { get: () => 'image/png' }, + arrayBuffer: async () => new Uint8Array([1, 2, 3]).buffer, + }) as unknown as typeof fetch + + const dataUri = await imageURLToDataURI('https://example.com/lock.png') + expect(dataUri).toContain('data:image/png;base64,') + expect(global.fetch).toHaveBeenCalledWith( + expect.stringContaining('https://example.com/lock.png'), + expect.objectContaining({ redirect: 'error' }) + ) + }) + + it('rejects private/link-local URLs and uses fallback', async () => { + global.fetch = vi.fn() + const fallback = 'data:image/svg+xml;base64,ZmFrZQ==' + const result = await imageURLToDataURI( + 'http://127.0.0.1/secret.png', + fallback + ) + expect(result).toBe(fallback) + expect(global.fetch).not.toHaveBeenCalled() + }) + + it('rejects private URLs without fallback', async () => { + global.fetch = vi.fn() + await expect( + imageURLToDataURI('http://169.254.169.254/latest/meta-data/') + ).rejects.toThrow(/not allowed/) + expect(global.fetch).not.toHaveBeenCalled() + }) +}) diff --git a/locksmith/__tests__/utils/safeCallbackUrl.test.ts b/locksmith/__tests__/utils/safeCallbackUrl.test.ts new file mode 100644 index 00000000000..b0e322d88aa --- /dev/null +++ b/locksmith/__tests__/utils/safeCallbackUrl.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from 'vitest' +import { + assertSafeCallbackUrl, + isBlockedIp, +} from '../../src/utils/safeCallbackUrl' + +describe('safeCallbackUrl', () => { + it('blocks loopback, private, link-local, and CGNAT IPv4', () => { + expect(isBlockedIp('127.0.0.1')).toBe(true) + expect(isBlockedIp('10.0.0.5')).toBe(true) + expect(isBlockedIp('192.168.1.1')).toBe(true) + expect(isBlockedIp('172.16.0.1')).toBe(true) + expect(isBlockedIp('169.254.169.254')).toBe(true) + expect(isBlockedIp('100.64.1.1')).toBe(true) + expect(isBlockedIp('8.8.8.8')).toBe(false) + }) + + it('rejects localhost and private literal callbacks', async () => { + await expect( + assertSafeCallbackUrl('http://localhost/callback') + ).rejects.toThrow(/not allowed/) + await expect( + assertSafeCallbackUrl('http://127.0.0.1/callback') + ).rejects.toThrow(/not allowed/) + await expect( + assertSafeCallbackUrl('http://169.254.169.254/latest/meta-data/') + ).rejects.toThrow(/not allowed/) + }) + + it('allows public https callbacks', async () => { + const url = await assertSafeCallbackUrl('https://example.com/websub') + expect(url).toContain('https://example.com/websub') + }) +}) diff --git a/locksmith/src/utils/image.ts b/locksmith/src/utils/image.ts index 8f5448eb7be..e74c4ff57f7 100644 --- a/locksmith/src/utils/image.ts +++ b/locksmith/src/utils/image.ts @@ -1,5 +1,6 @@ import logger from '../logger' import lockIcon from './lockIcon' +import { assertSafeCallbackUrl } from './safeCallbackUrl' export const imageUrlToBase64 = async (url: string, lockAddress: string) => { // Fallback to the lock icon if the image is not available @@ -12,12 +13,16 @@ export const imageUrlToBase64 = async (url: string, lockAddress: string) => { export const imageURLToDataURI = async (url: string, fallbackURL?: string) => { try { - const response = await fetch(url, { + // OG / certification paths fetch lock metadata.image (and event cover) on + // unauthenticated routes. Fail closed on private/link-local targets. + const safeUrl = await assertSafeCallbackUrl(url) + const response = await fetch(safeUrl, { method: 'GET', headers: { 'Content-Type': 'image/png', }, - }) + redirect: 'error', + } as RequestInit) const contentType = response.headers.get('content-type') const arrayBuffer = await response.arrayBuffer() const buffer = Buffer.from(arrayBuffer) diff --git a/locksmith/src/utils/safeCallbackUrl.ts b/locksmith/src/utils/safeCallbackUrl.ts new file mode 100644 index 00000000000..83f0e0fe537 --- /dev/null +++ b/locksmith/src/utils/safeCallbackUrl.ts @@ -0,0 +1,97 @@ +import { lookup } from 'dns/promises' +import net from 'net' + +/** + * Reject Locksmith outbound http(s) URLs that would hit private / link-local / + * metadata addresses (SSRF). Used for WebSub hub.callback verification and + * Apple Wallet pass thumbnail fetches (lock metadata.image) and OG/certification imageURLToDataURI fetches. + */ + +function ipv4ToInt(ip: string): number { + return ip.split('.').reduce((acc, octet) => (acc << 8) + Number(octet), 0) >>> 0 +} + +export function isBlockedIp(ip: string): boolean { + if (net.isIPv4(ip)) { + const n = ipv4ToInt(ip) + const inRange = (base: string, prefix: number) => { + const mask = prefix === 0 ? 0 : (~0 << (32 - prefix)) >>> 0 + return (n & mask) === (ipv4ToInt(base) & mask) + } + return ( + inRange('0.0.0.0', 8) || + inRange('10.0.0.0', 8) || + inRange('127.0.0.0', 8) || + inRange('169.254.0.0', 16) || + inRange('172.16.0.0', 12) || + inRange('192.168.0.0', 16) || + inRange('100.64.0.0', 10) + ) + } + + if (net.isIPv6(ip)) { + const normalized = ip.toLowerCase() + if (normalized === '::1' || normalized === '::') return true + if (normalized.startsWith('fc') || normalized.startsWith('fd')) return true + if ( + normalized.startsWith('fe8') || + normalized.startsWith('fe9') || + normalized.startsWith('fea') || + normalized.startsWith('feb') + ) { + return true + } + if (normalized.startsWith(':ffff:')) { + const v4 = normalized.slice(':ffff:'.length) + if (net.isIPv4(v4)) return isBlockedIp(v4) + } + } + + return false +} + +export async function assertSafeCallbackUrl(raw: string): Promise { + let parsed: URL + try { + parsed = new URL(raw) + } catch { + throw new Error(`callback has an invalid url "${raw}"`) + } + + if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') { + throw new Error(`callback protocol must be http(s), got "${parsed.protocol}"`) + } + + const host = parsed.hostname.replace(/^\[|\]$/g, '').toLowerCase() + if (!host) { + throw new Error('callback hostname is required') + } + if (host === 'localhost' || host.endsWith('.localhost') || host.endsWith('.local')) { + throw new Error(`callback host "${host}" is not allowed`) + } + + if (net.isIP(host)) { + if (isBlockedIp(host)) { + throw new Error(`callback host "${host}" is not allowed`) + } + } else { + let addresses: Array<{ address: string; family: number }> + try { + addresses = await lookup(host, { all: true, verbatim: true }) + } catch { + throw new Error(`callback host "${host}" could not be resolved`) + } + if (!addresses.length) { + throw new Error(`callback host "${host}" could not be resolved`) + } + for (const { address } of addresses) { + if (isBlockedIp(address)) { + throw new Error( + `callback host "${host}" resolves to blocked address "${address}"` + ) + } + } + } + + return parsed.toString() +} From 54bc63b48a9074a711b6b975dcc5681ae8e319a4 Mon Sep 17 00:00:00 2001 From: SashaMIT Date: Tue, 11 Aug 2026 10:52:52 +0700 Subject: [PATCH 2/2] fix(locksmith): block private tokenURI fetches on /image Unauthenticated GET /image/:network/:lock/:keyId fetched key.tokenURI and redirected to json.image with no SSRF guard. Reuse assertSafeCallbackUrl (and redirect:error) for both URLs, matching the OG/Apple siblings. --- locksmith/src/controllers/lockController.ts | 15 ++++++++++++--- locksmith/src/utils/safeCallbackUrl.ts | 5 +++-- 2 files changed, 15 insertions(+), 5 deletions(-) diff --git a/locksmith/src/controllers/lockController.ts b/locksmith/src/controllers/lockController.ts index 6b5b1ab31dc..4be61748a92 100644 --- a/locksmith/src/controllers/lockController.ts +++ b/locksmith/src/controllers/lockController.ts @@ -10,6 +10,7 @@ import { getLockIcon, getKeyIcon, } from '../operations/lockOperations' +import { assertSafeCallbackUrl } from '../utils/safeCallbackUrl' export const connectStripe = async (req: Request, res: Response) => { const { message } = JSON.parse(decodeURIComponent(req.query.data!.toString())) @@ -142,10 +143,18 @@ export const getTokenURIImage: RequestHandler<{ ) // If we have a tokenURI, we can fetch the image from the metadata if (key.tokenURI) { - const metadata = await fetch(key.tokenURI) + // Unauthenticated /image route: fail closed on private/link-local + // tokenURI (and the redirected image URL) the same way OG/Apple paths do. + const safeTokenURI = await assertSafeCallbackUrl(key.tokenURI) + const metadata = await fetch(safeTokenURI, { + redirect: 'error', + } as RequestInit) const json = await metadata.json() - response.redirect(json?.image) - return + if (typeof json?.image === 'string' && json.image.length > 0) { + const safeImage = await assertSafeCallbackUrl(json.image) + response.redirect(safeImage) + return + } } } diff --git a/locksmith/src/utils/safeCallbackUrl.ts b/locksmith/src/utils/safeCallbackUrl.ts index 83f0e0fe537..43df5f2f62b 100644 --- a/locksmith/src/utils/safeCallbackUrl.ts +++ b/locksmith/src/utils/safeCallbackUrl.ts @@ -3,8 +3,9 @@ import net from 'net' /** * Reject Locksmith outbound http(s) URLs that would hit private / link-local / - * metadata addresses (SSRF). Used for WebSub hub.callback verification and - * Apple Wallet pass thumbnail fetches (lock metadata.image) and OG/certification imageURLToDataURI fetches. + * metadata addresses (SSRF). Used for WebSub hub.callback verification, + * Apple Wallet pass thumbnail fetches (lock metadata.image), OG/certification + * imageURLToDataURI fetches, and unauthenticated /image tokenURI fetches. */ function ipv4ToInt(ip: string): number {