Skip to content
Closed
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
36 changes: 3 additions & 33 deletions supabase/functions/_backend/private/website_preview.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type { Context } from 'hono'
import { createHono, middlewareAuth, parseBody, quickError, useCors } from '../utils/hono.ts'
import { readResponseBytesWithLimit } from '../utils/response.ts'
import { version } from '../utils/version.ts'
import { getWebhookUrlValidationError } from '../utils/webhook.ts'

Expand Down Expand Up @@ -220,40 +221,9 @@ async function getPublicHostnameValidationError(c: Context, urlString: string) {
}

async function readResponseTextWithLimit(response: Response, limit: number) {
const contentLength = Number.parseInt(response.headers.get('content-length') ?? '', 10)
if (Number.isFinite(contentLength) && contentLength > limit)
const bytes = await readResponseBytesWithLimit(response, limit)
if (!bytes)
return null

if (!response.body)
return await response.text()

const reader = response.body.getReader()
const chunks: Uint8Array[] = []
let total = 0

while (true) {
const { done, value } = await reader.read()
if (done)
break

if (!value)
continue

total += value.byteLength
if (total > limit) {
await reader.cancel()
return null
}
chunks.push(value)
}

const bytes = new Uint8Array(total)
let offset = 0
for (const chunk of chunks) {
bytes.set(chunk, offset)
offset += chunk.byteLength
}

return new TextDecoder().decode(bytes)
}

Expand Down
8 changes: 7 additions & 1 deletion supabase/functions/_backend/public/app/store_metadata.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,14 @@
import type { Context } from 'hono'
import type { MiddlewareKeyVariables } from '../../utils/hono.ts'
import { quickError } from '../../utils/hono.ts'
import { readResponseBytesWithLimit } from '../../utils/response.ts'

export interface FetchStoreMetadataBody {
url?: string
}

const MAX_ICON_BYTES = 512 * 1024

interface AppleLookupResult {
trackName?: string
artworkUrl512?: string
Expand Down Expand Up @@ -51,7 +54,10 @@ async function fetchIconDataUrl(iconUrl: string | null) {
return null

const contentType = response.headers.get('content-type')?.split(';')[0]?.trim() || 'image/png'
const bytes = new Uint8Array(await response.arrayBuffer())
const bytes = await readResponseBytesWithLimit(response, MAX_ICON_BYTES)
if (!bytes)
return null

return `data:${contentType};base64,${uint8ArrayToBase64(bytes)}`
}
catch {
Expand Down
39 changes: 39 additions & 0 deletions supabase/functions/_backend/utils/response.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
export async function readResponseBytesWithLimit(response: Response, limit: number) {
const contentLength = Number.parseInt(response.headers.get('content-length') ?? '', 10)
if (Number.isFinite(contentLength) && contentLength > limit)
return null

if (!response.body) {
const buffer = await response.arrayBuffer()
return buffer.byteLength > limit ? null : new Uint8Array(buffer)
}

const reader = response.body.getReader()
const chunks: Uint8Array[] = []
let total = 0

while (true) {
const { done, value } = await reader.read()
if (done)
break

if (!value)
continue

total += value.byteLength
if (total > limit) {
await reader.cancel()
return null
}
chunks.push(value)
}

const bytes = new Uint8Array(total)
let offset = 0
for (const chunk of chunks) {
bytes.set(chunk, offset)
offset += chunk.byteLength
}

return bytes
}
33 changes: 30 additions & 3 deletions supabase/functions/_backend/utils/webhook.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,10 @@ function allowLocalWebhookUrls(c: Context): boolean {
return getEnv(c, 'CAPGO_ALLOW_LOCAL_WEBHOOK_URLS') === 'true'
}

function isLocalWebhookProtocol(protocol: string): boolean {
return protocol === 'http:' || protocol === 'https:'
}

function normalizeHostname(hostname: string): string {
return hostname.replace(/\.$/, '').toLowerCase()
}
Expand All @@ -64,6 +68,26 @@ function isIpLiteral(hostname: string): boolean {
return IPV4_REGEX.test(hostname) || hostname.includes(':')
}

function getWebhookUrlLogInfo(urlString: string) {
try {
const url = new URL(urlString)
return {
protocol: url.protocol,
hasHostname: url.hostname !== '',
hostnameLength: url.hostname.length,
pathSegmentCount: url.pathname.split('/').filter(Boolean).length,
hasQuery: url.search !== '',
hasCredentials: url.username !== '' || url.password !== '',
}
}
catch {
return {
invalid: true,
length: urlString.length,
}
}
}

export function getWebhookUrlValidationError(c: Context, urlString: string): string | null {
let url: URL
try {
Expand All @@ -73,8 +97,11 @@ export function getWebhookUrlValidationError(c: Context, urlString: string): str
return 'Webhook URL is invalid'
}

if (allowLocalWebhookUrls(c))
return null
if (allowLocalWebhookUrls(c)) {
return isLocalWebhookProtocol(url.protocol)
? null
: 'Webhook URL must use HTTP or HTTPS'
}

// We intentionally stop at syntactic/public-host checks: webhook delivery runs
// entirely from serverless infrastructure, so private/internal addresses are not
Expand Down Expand Up @@ -222,7 +249,7 @@ export async function deliverWebhook(
requestId: c.get('requestId'),
message: 'Webhook delivery blocked by URL validation',
deliveryId,
url,
urlInfo: getWebhookUrlLogInfo(url),
error: urlValidationError,
duration,
})
Expand Down
75 changes: 75 additions & 0 deletions tests/store-metadata.unit.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import { afterEach, describe, expect, it, vi } from 'vitest'

import { fetchStoreMetadata } from '../supabase/functions/_backend/public/app/store_metadata.ts'

function createContext() {
return {
json: (body: unknown) => Response.json(body),
} as any
}

function storeHtml(iconUrl: string) {
return `
<html>
<head>
<meta property="og:title" content="Capgo Test App">
<meta property="og:image" content="${iconUrl}">
</head>
<body></body>
</html>
`
}

function mockStoreMetadataFetch(iconResponse: Response) {
const iconUrl = 'https://play-lh.googleusercontent.com/icon.png'
const fetchMock = vi.fn(async (url: string) => {
if (url.startsWith('https://play.google.com/')) {
return new Response(storeHtml(iconUrl), {
headers: { 'content-type': 'text/html' },
})
}

return iconResponse
})
vi.stubGlobal('fetch', fetchMock)
return fetchMock
}

async function fetchPlayStoreMetadata() {
const response = await fetchStoreMetadata(createContext(), {
url: 'https://play.google.com/store/apps/details?id=app.capgo.test',
})
return response.json() as Promise<{ icon_data_url: string | null }>
}

describe('store metadata icon fetching', () => {
afterEach(() => {
vi.unstubAllGlobals()
})

it('does not buffer oversized icon responses', async () => {
const fetchMock = mockStoreMetadataFetch(
new Response('too large', {
headers: {
'content-length': String(512 * 1024 + 1),
'content-type': 'image/png',
},
}),
)
const body = await fetchPlayStoreMetadata()

expect(body.icon_data_url).toBeNull()
expect(fetchMock).toHaveBeenCalledTimes(2)
})

it('keeps small allowlisted icon responses', async () => {
mockStoreMetadataFetch(
new Response(new Uint8Array([1, 2, 3]), {
headers: { 'content-type': 'image/png' },
}),
)
const body = await fetchPlayStoreMetadata()

expect(body.icon_data_url).toBe('data:image/png;base64,AQID')
})
})
85 changes: 85 additions & 0 deletions tests/webhook-delivery-security.unit.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,17 @@ function createContext() {
} as any
}

function enableLocalWebhookUrls(enabled: boolean) {
mockGetEnv.mockImplementation((_c: unknown, key: string) =>
key === 'CAPGO_ALLOW_LOCAL_WEBHOOK_URLS' && enabled ? 'true' : '',
)
}

async function validateWebhookUrl(url: string) {
const { getWebhookUrlValidationError } = await import('../supabase/functions/_backend/utils/webhook.ts')
return getWebhookUrlValidationError(createContext(), url)
}

afterEach(() => {
vi.restoreAllMocks()
vi.unstubAllGlobals()
Expand Down Expand Up @@ -103,4 +114,78 @@ describe('webhook delivery redirect handling', () => {
expect(result.status).toBe(302)
expect(fetchMock).toHaveBeenCalledOnce()
})

it('does not log raw blocked webhook URLs with query secrets', async () => {
mockGetEnv.mockReturnValue('')

const { deliverWebhook } = await import('../supabase/functions/_backend/utils/webhook.ts')
const result = await deliverWebhook(
createContext(),
'delivery-3',
'http://localhost/webhook?token=secret-token',
{
event: 'app_versions.INSERT',
event_id: 'event-3',
timestamp: new Date().toISOString(),
org_id: 'org-1',
data: {
table: 'app_versions',
operation: 'INSERT',
record_id: 'record-3',
old_record: null,
new_record: null,
changed_fields: null,
},
},
'secret',
)

expect(result.success).toBe(false)
expect(mockCloudlogErr).toHaveBeenCalledOnce()
const logged = JSON.stringify(mockCloudlogErr.mock.calls)
expect(logged).not.toContain('secret-token')
expect(logged).not.toContain('http://localhost/webhook')
expect(mockCloudlogErr.mock.calls[0]?.[0]).toMatchObject({
message: 'Webhook delivery blocked by URL validation',
urlInfo: {
protocol: 'http:',
hasHostname: true,
hasQuery: true,
},
})
})
})

describe('webhook URL validation', () => {
it('allows HTTP and HTTPS webhooks when local webhook URLs are enabled', async () => {
enableLocalWebhookUrls(true)

await expect(validateWebhookUrl('http://localhost:3000/webhook')).resolves.toBeNull()
await expect(validateWebhookUrl('https://localhost/webhook')).resolves.toBeNull()
})

it('rejects unsupported URL schemes even when local webhook URLs are enabled', async () => {
enableLocalWebhookUrls(true)

await expect(validateWebhookUrl('ftp://localhost/webhook')).resolves.toBe('Webhook URL must use HTTP or HTTPS')
await expect(validateWebhookUrl('file:///tmp/webhook')).resolves.toBe('Webhook URL must use HTTP or HTTPS')
})

it('continues to reject HTTP URLs when local webhook URLs are disabled', async () => {
enableLocalWebhookUrls(false)

await expect(validateWebhookUrl('http://example.com/webhook')).resolves.toBe('Webhook URL must use HTTPS')
})

it('allows public HTTPS URLs when local webhook URLs are enabled', async () => {
enableLocalWebhookUrls(true)

await expect(validateWebhookUrl('https://example.com/webhook')).resolves.toBeNull()
})

it('rejects localhost URLs when local webhook URLs are disabled', async () => {
enableLocalWebhookUrls(false)

await expect(validateWebhookUrl('https://localhost/webhook')).resolves.toBe('Webhook URL must point to a public host')
})
})