mirror of
https://github.com/Luchexx/wacrm.git
synced 2026-11-02 17:29:45 +00:00
Merge pull request #440 from ArnasDon/fix/ghsa-6fr5-template-header-ssrf
fix(whatsapp): guard the template image-header fetch against SSRF
This commit is contained in:
commit
840089f8cc
@ -5,8 +5,15 @@ vi.mock('./meta-api', () => ({
|
|||||||
uploadResumableMedia: vi.fn(async () => ({ handle: 'HANDLE123' })),
|
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 { ensureImageHeaderHandle } from './template-header-handle';
|
||||||
import { uploadResumableMedia } from './meta-api';
|
import { uploadResumableMedia } from './meta-api';
|
||||||
|
import { isDeliverableUrl } from '@/lib/webhooks/ssrf';
|
||||||
import type { TemplatePayload } from './template-validators';
|
import type { TemplatePayload } from './template-validators';
|
||||||
|
|
||||||
function payload(over: Partial<TemplatePayload> = {}): TemplatePayload {
|
function payload(over: Partial<TemplatePayload> = {}): TemplatePayload {
|
||||||
@ -33,6 +40,8 @@ function imgResponse(type = 'image/jpeg', size = 1024, ok = true, status = 200):
|
|||||||
describe('ensureImageHeaderHandle', () => {
|
describe('ensureImageHeaderHandle', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.mocked(uploadResumableMedia).mockClear();
|
vi.mocked(uploadResumableMedia).mockClear();
|
||||||
|
vi.mocked(isDeliverableUrl).mockClear();
|
||||||
|
vi.mocked(isDeliverableUrl).mockResolvedValue(true);
|
||||||
});
|
});
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
vi.unstubAllGlobals();
|
vi.unstubAllGlobals();
|
||||||
@ -78,4 +87,58 @@ describe('ensureImageHeaderHandle', () => {
|
|||||||
vi.stubGlobal('fetch', vi.fn(async () => imgResponse('image/png', 6 * 1024 * 1024)));
|
vi.stubGlobal('fetch', vi.fn(async () => imgResponse('image/png', 6 * 1024 * 1024)));
|
||||||
await expect(ensureImageHeaderHandle(payload(), 'tok')).rejects.toThrow(/5 MB/);
|
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' });
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
import { uploadResumableMedia } from '@/lib/whatsapp/meta-api'
|
import { uploadResumableMedia } from '@/lib/whatsapp/meta-api'
|
||||||
import type { TemplatePayload } from '@/lib/whatsapp/template-validators'
|
import type { TemplatePayload } from '@/lib/whatsapp/template-validators'
|
||||||
|
import { isDeliverableUrl } from '@/lib/webhooks/ssrf'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Meta requires an `example.header_handle` (from the Resumable Upload
|
* 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
|
// Fetch the sample image bytes (works for our uploaded chat-media URL
|
||||||
// and for a manually-pasted public link).
|
// and for a manually-pasted public link).
|
||||||
let res: Response
|
let res: Response
|
||||||
try {
|
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 {
|
} catch {
|
||||||
throw new Error('Could not fetch the header image URL. Make sure it is publicly reachable.')
|
throw new Error('Could not fetch the header image URL. Make sure it is publicly reachable.')
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user