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
55 changes: 55 additions & 0 deletions locksmith/__tests__/utils/image.test.ts
Original file line number Diff line number Diff line change
@@ -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: () => '<svg xmlns="http://www.w3.org/2000/svg"></svg>',
},
}))

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()
})
})
34 changes: 34 additions & 0 deletions locksmith/__tests__/utils/safeCallbackUrl.test.ts
Original file line number Diff line number Diff line change
@@ -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')
})
})
15 changes: 12 additions & 3 deletions locksmith/src/controllers/lockController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()))
Expand Down Expand Up @@ -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
}
}
}

Expand Down
9 changes: 7 additions & 2 deletions locksmith/src/utils/image.ts
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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)
Expand Down
98 changes: 98 additions & 0 deletions locksmith/src/utils/safeCallbackUrl.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
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,
* Apple Wallet pass thumbnail fetches (lock metadata.image), OG/certification
* imageURLToDataURI fetches, and unauthenticated /image tokenURI 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<string> {
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()
}