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
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')
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { PKPass } from 'passkit-generator'
import fetch from 'isomorphic-fetch'
import logger from '../../../logger'
import path from 'node:path'
import { assertSafeCallbackUrl } from '../../../utils/safeCallbackUrl'
import { getCertificates } from './getCertificates'

// Resolve the absolute path to the pass model template directory
Expand All @@ -25,7 +26,10 @@ const contentTypeToExtensionMap: { [key: string]: string } = {

// utility to retrieve an image via HTTP and return it as a Buffer
async function fetchImageAsBuffer(imageUrl: string) {
const response = await fetch(imageUrl)
// Lock managers can set metadata.image; pass generation fetches it server-side
// on an unauthenticated GET. Fail closed on private/link-local targets.
const safeUrl = await assertSafeCallbackUrl(imageUrl)
const response = await fetch(safeUrl, { redirect: 'error' } as RequestInit)
if (!response.ok) {
throw new Error(`Failed to fetch image: ${response.statusText}`)
}
Expand Down
97 changes: 97 additions & 0 deletions locksmith/src/utils/safeCallbackUrl.ts
Original file line number Diff line number Diff line change
@@ -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).
*/

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()
}