diff --git a/src/lib/whatsapp/template-header-handle.test.ts b/src/lib/whatsapp/template-header-handle.test.ts index f73af6d..4787291 100644 --- a/src/lib/whatsapp/template-header-handle.test.ts +++ b/src/lib/whatsapp/template-header-handle.test.ts @@ -5,8 +5,15 @@ vi.mock('./meta-api', () => ({ uploadResumableMedia: vi.fn(async () => ({ handle: 'HANDLE123' })), })); +// The SSRF guard does a real DNS lookup, so stub it — the fixtures below use +// a `.test` hostname that would never resolve. Each test sets the verdict. +vi.mock('@/lib/webhooks/ssrf', () => ({ + isDeliverableUrl: vi.fn(async () => true), +})); + import { ensureImageHeaderHandle } from './template-header-handle'; import { uploadResumableMedia } from './meta-api'; +import { isDeliverableUrl } from '@/lib/webhooks/ssrf'; import type { TemplatePayload } from './template-validators'; function payload(over: Partial = {}): TemplatePayload { @@ -33,6 +40,8 @@ function imgResponse(type = 'image/jpeg', size = 1024, ok = true, status = 200): describe('ensureImageHeaderHandle', () => { beforeEach(() => { vi.mocked(uploadResumableMedia).mockClear(); + vi.mocked(isDeliverableUrl).mockClear(); + vi.mocked(isDeliverableUrl).mockResolvedValue(true); }); afterEach(() => { vi.unstubAllGlobals(); @@ -78,4 +87,58 @@ describe('ensureImageHeaderHandle', () => { vi.stubGlobal('fetch', vi.fn(async () => imgResponse('image/png', 6 * 1024 * 1024))); await expect(ensureImageHeaderHandle(payload(), 'tok')).rejects.toThrow(/5 MB/); }); + + // Regression: `header_media_url` is caller-supplied and any authenticated + // member can submit a template, so a non-public destination has to be + // refused *before* the server issues the request — otherwise the status + // and content-type carried back in the thrown error are an SSRF oracle for + // loopback, RFC1918 and cloud-metadata addresses. + it('refuses a non-public header URL without fetching it', async () => { + vi.stubEnv('META_APP_ID', 'app-1'); + vi.mocked(isDeliverableUrl).mockResolvedValue(false); + const fetchSpy = vi.fn(async () => imgResponse('application/json')); + vi.stubGlobal('fetch', fetchSpy); + + const p = payload({ header_media_url: 'http://169.254.169.254/latest/meta-data/' }); + await expect(ensureImageHeaderHandle(p, 'tok')).rejects.toThrow(/publicly reachable/); + + expect(isDeliverableUrl).toHaveBeenCalledWith('http://169.254.169.254/latest/meta-data/'); + expect(fetchSpy).not.toHaveBeenCalled(); + expect(uploadResumableMedia).not.toHaveBeenCalled(); + expect(p.header_handle).toBeUndefined(); + }); + + it('reports a blocked URL exactly like an unreachable one', async () => { + vi.stubEnv('META_APP_ID', 'app-1'); + + vi.mocked(isDeliverableUrl).mockResolvedValue(false); + vi.stubGlobal('fetch', vi.fn(async () => imgResponse())); + const blocked = await ensureImageHeaderHandle(payload(), 'tok').catch( + (e: Error) => e.message, + ); + + vi.mocked(isDeliverableUrl).mockResolvedValue(true); + vi.stubGlobal( + 'fetch', + vi.fn(async () => { + throw new Error('ECONNREFUSED'); + }), + ); + const unreachable = await ensureImageHeaderHandle(payload(), 'tok').catch( + (e: Error) => e.message, + ); + + expect(blocked).toBe(unreachable); + }); + + it('does not follow redirects, so a public URL cannot bounce to an internal one', async () => { + vi.stubEnv('META_APP_ID', 'app-1'); + const fetchSpy = vi.fn(async () => imgResponse('image/jpeg', 1024)); + vi.stubGlobal('fetch', fetchSpy); + + await ensureImageHeaderHandle(payload(), 'tok'); + + const init = (fetchSpy.mock.calls[0] as unknown[])[1] as RequestInit; + expect(init).toMatchObject({ redirect: 'manual' }); + }); }); diff --git a/src/lib/whatsapp/template-header-handle.ts b/src/lib/whatsapp/template-header-handle.ts index eb05183..19722ad 100644 --- a/src/lib/whatsapp/template-header-handle.ts +++ b/src/lib/whatsapp/template-header-handle.ts @@ -1,5 +1,6 @@ import { uploadResumableMedia } from '@/lib/whatsapp/meta-api' import type { TemplatePayload } from '@/lib/whatsapp/template-validators' +import { isDeliverableUrl } from '@/lib/webhooks/ssrf' /** * Meta requires an `example.header_handle` (from the Resumable Upload @@ -33,11 +34,27 @@ export async function ensureImageHeaderHandle( ) } + // SSRF guard: `header_media_url` is caller-supplied (any authenticated + // member can submit a template) and the fetch below happens server-side, + // so refuse any destination that resolves to a private / loopback / + // link-local / reserved address. Same guard, same message as the two + // other outbound-fetch call sites (see lib/webhooks/ssrf.ts) — matching + // the unreachable-host message keeps the failure from being an oracle. + if (!(await isDeliverableUrl(payload.header_media_url))) { + throw new Error('Could not fetch the header image URL. Make sure it is publicly reachable.') + } + // Fetch the sample image bytes (works for our uploaded chat-media URL // and for a manually-pasted public link). let res: Response try { - res = await fetch(payload.header_media_url) + res = await fetch(payload.header_media_url, { + // Do NOT follow redirects — a public URL could 3xx-bounce to an + // internal address, defeating the guard above. Bound the request so + // a hung host can't tie up the template-submit handler. + redirect: 'manual', + signal: AbortSignal.timeout(10_000), + }) } catch { throw new Error('Could not fetch the header image URL. Make sure it is publicly reachable.') }