mirror of
https://github.com/Luchexx/wacrm.git
synced 2026-11-02 17:29:45 +00:00
`ensureImageHeaderHandle` fetched the caller-supplied `header_media_url` server-side with no validation, so a template submit — an action any authenticated member can perform — could reach loopback, RFC1918 and cloud-metadata addresses, with the response status and content-type echoed back through the thrown error. Wire in the project's existing `isDeliverableUrl` guard, which the two other outbound-fetch call sites (webhook delivery, automation `send_webhook`) already use, and reuse their `redirect: 'manual'` + timeout so a public URL can't 3xx-bounce to an internal one. The rejection reuses the unreachable-host message verbatim so the failure doesn't distinguish "blocked" from "down". Addresses GHSA-6fr5-pxw7-r3xw. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
145 lines
5.3 KiB
TypeScript
145 lines
5.3 KiB
TypeScript
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
|
|
// Stub the Meta resumable upload so the helper is tested in isolation.
|
|
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> = {}): TemplatePayload {
|
|
return {
|
|
name: 't',
|
|
category: 'Utility',
|
|
language: 'en_US',
|
|
body_text: 'hi',
|
|
header_type: 'image',
|
|
header_media_url: 'https://x.test/img.jpg',
|
|
...over,
|
|
};
|
|
}
|
|
|
|
function imgResponse(type = 'image/jpeg', size = 1024, ok = true, status = 200): Response {
|
|
return {
|
|
ok,
|
|
status,
|
|
headers: { get: (h: string) => (h.toLowerCase() === 'content-type' ? type : null) },
|
|
arrayBuffer: async () => new ArrayBuffer(size),
|
|
} as unknown as Response;
|
|
}
|
|
|
|
describe('ensureImageHeaderHandle', () => {
|
|
beforeEach(() => {
|
|
vi.mocked(uploadResumableMedia).mockClear();
|
|
vi.mocked(isDeliverableUrl).mockClear();
|
|
vi.mocked(isDeliverableUrl).mockResolvedValue(true);
|
|
});
|
|
afterEach(() => {
|
|
vi.unstubAllGlobals();
|
|
vi.unstubAllEnvs();
|
|
});
|
|
|
|
it('is a no-op for non-image headers', async () => {
|
|
const p = payload({ header_type: 'text', header_content: 'Hi' });
|
|
await ensureImageHeaderHandle(p, 'tok');
|
|
expect(uploadResumableMedia).not.toHaveBeenCalled();
|
|
expect(p.header_handle).toBeUndefined();
|
|
});
|
|
|
|
it('is a no-op when a handle already exists', async () => {
|
|
const p = payload({ header_handle: 'existing' });
|
|
await ensureImageHeaderHandle(p, 'tok');
|
|
expect(uploadResumableMedia).not.toHaveBeenCalled();
|
|
expect(p.header_handle).toBe('existing');
|
|
});
|
|
|
|
it('throws an actionable error when META_APP_ID is unset', async () => {
|
|
const p = payload();
|
|
await expect(ensureImageHeaderHandle(p, 'tok')).rejects.toThrow(/META_APP_ID/);
|
|
});
|
|
|
|
it('derives + sets header_handle from a valid image URL', async () => {
|
|
vi.stubEnv('META_APP_ID', 'app-1');
|
|
vi.stubGlobal('fetch', vi.fn(async () => imgResponse('image/jpeg', 2048)));
|
|
const p = payload();
|
|
await ensureImageHeaderHandle(p, 'tok');
|
|
expect(uploadResumableMedia).toHaveBeenCalledOnce();
|
|
expect(p.header_handle).toBe('HANDLE123');
|
|
});
|
|
|
|
it('rejects a non-image content type', async () => {
|
|
vi.stubEnv('META_APP_ID', 'app-1');
|
|
vi.stubGlobal('fetch', vi.fn(async () => imgResponse('text/html')));
|
|
await expect(ensureImageHeaderHandle(payload(), 'tok')).rejects.toThrow(/JPEG or PNG/);
|
|
});
|
|
|
|
it('rejects an image over 5 MB', async () => {
|
|
vi.stubEnv('META_APP_ID', 'app-1');
|
|
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' });
|
|
});
|
|
});
|