mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-31 00:12:39 +00:00
234 lines
8.6 KiB
TypeScript
234 lines
8.6 KiB
TypeScript
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
|
|
const lookup = vi.fn();
|
|
const guardedFetch = vi.fn();
|
|
|
|
// Untrusted endpoints connect through the rebinding-safe fetch; stub only
|
|
// that export and keep the real isPublicHttpUrl (which uses the mocked DNS).
|
|
vi.mock('@/lib/security/url-guard', async (importOriginal) => {
|
|
const actual = await importOriginal<typeof import('@/lib/security/url-guard')>();
|
|
return {
|
|
...actual,
|
|
fetchPublicUrl: (...args: unknown[]) => guardedFetch(...args),
|
|
};
|
|
});
|
|
|
|
vi.mock('node:dns/promises', async (importOriginal) => {
|
|
const actual = await importOriginal<typeof import('node:dns/promises')>();
|
|
return {
|
|
...actual,
|
|
default: { ...actual, lookup: (...args: unknown[]) => lookup(...args) },
|
|
lookup: (...args: unknown[]) => lookup(...args),
|
|
};
|
|
});
|
|
|
|
describe('verifyJmapAuth SSRF protection', () => {
|
|
let fetchSpy: ReturnType<typeof vi.spyOn>;
|
|
|
|
beforeEach(() => {
|
|
lookup.mockReset();
|
|
guardedFetch.mockReset();
|
|
vi.resetModules();
|
|
fetchSpy = vi.spyOn(globalThis, 'fetch');
|
|
});
|
|
|
|
afterEach(() => {
|
|
fetchSpy.mockRestore();
|
|
});
|
|
|
|
async function load() {
|
|
const mod = await import('@/lib/auth/verify-jmap-auth');
|
|
return mod;
|
|
}
|
|
|
|
it('rejects loopback literal without issuing fetch', async () => {
|
|
const { verifyJmapAuth, JmapAuthVerificationError } = await load();
|
|
await expect(verifyJmapAuth('http://127.0.0.1', 'Bearer x')).rejects.toBeInstanceOf(
|
|
JmapAuthVerificationError,
|
|
);
|
|
expect(fetchSpy).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('rejects AWS IMDS endpoint without issuing fetch', async () => {
|
|
const { verifyJmapAuth } = await load();
|
|
await expect(
|
|
verifyJmapAuth('http://169.254.169.254', 'Bearer x'),
|
|
).rejects.toMatchObject({ status: 400 });
|
|
expect(fetchSpy).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('rejects RFC-1918 literals without issuing fetch', async () => {
|
|
const { verifyJmapAuth } = await load();
|
|
for (const target of ['http://10.0.0.5', 'http://172.16.0.1', 'http://192.168.1.1']) {
|
|
await expect(verifyJmapAuth(target, 'Bearer x')).rejects.toMatchObject({ status: 400 });
|
|
}
|
|
expect(fetchSpy).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('rejects localhost hostname without issuing fetch', async () => {
|
|
const { verifyJmapAuth } = await load();
|
|
await expect(verifyJmapAuth('http://localhost', 'Bearer x')).rejects.toMatchObject({
|
|
status: 400,
|
|
});
|
|
expect(fetchSpy).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('rejects IPv6 loopback literal without issuing fetch', async () => {
|
|
const { verifyJmapAuth } = await load();
|
|
await expect(verifyJmapAuth('http://[::1]', 'Bearer x')).rejects.toMatchObject({
|
|
status: 400,
|
|
});
|
|
expect(fetchSpy).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('rejects hostnames whose DNS resolves to a private IP without issuing fetch', async () => {
|
|
lookup.mockResolvedValue([{ address: '10.0.0.1', family: 4 }]);
|
|
const { verifyJmapAuth } = await load();
|
|
await expect(verifyJmapAuth('https://internal.example.com', 'Bearer x')).rejects.toMatchObject({
|
|
status: 400,
|
|
});
|
|
expect(fetchSpy).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('rejects file:// URLs', async () => {
|
|
const { verifyJmapAuth } = await load();
|
|
await expect(verifyJmapAuth('file:///etc/passwd', 'Bearer x')).rejects.toMatchObject({
|
|
status: 400,
|
|
});
|
|
expect(fetchSpy).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('refuses to follow a redirect to a private address', async () => {
|
|
lookup.mockResolvedValue([{ address: '93.184.216.34', family: 4 }]);
|
|
guardedFetch.mockResolvedValueOnce(
|
|
new Response(null, { status: 302, headers: { location: 'http://127.0.0.1/.well-known/jmap' } }),
|
|
);
|
|
const { verifyJmapAuth } = await load();
|
|
await expect(verifyJmapAuth('https://example.com', 'Bearer x')).rejects.toMatchObject({
|
|
status: 400,
|
|
});
|
|
expect(guardedFetch).toHaveBeenCalledTimes(1);
|
|
expect(fetchSpy).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('refuses to follow a redirect to AWS IMDS', async () => {
|
|
lookup.mockResolvedValue([{ address: '93.184.216.34', family: 4 }]);
|
|
guardedFetch.mockResolvedValueOnce(
|
|
new Response(null, {
|
|
status: 302,
|
|
headers: { location: 'http://169.254.169.254/latest/meta-data/' },
|
|
}),
|
|
);
|
|
const { verifyJmapAuth } = await load();
|
|
await expect(verifyJmapAuth('https://example.com', 'Bearer x')).rejects.toMatchObject({
|
|
status: 400,
|
|
});
|
|
expect(guardedFetch).toHaveBeenCalledTimes(1);
|
|
expect(fetchSpy).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('maps a connect-time rebinding rejection to a 400', async () => {
|
|
// isPublicHttpUrl saw a public address, but by the time the socket
|
|
// resolved the name it pointed somewhere private: fetchPublicUrl throws.
|
|
lookup.mockResolvedValue([{ address: '93.184.216.34', family: 4 }]);
|
|
const { DisallowedUrlError } = await import('@/lib/security/url-guard');
|
|
guardedFetch.mockRejectedValueOnce(new DisallowedUrlError('https://example.com/.well-known/jmap'));
|
|
const { verifyJmapAuth } = await load();
|
|
await expect(verifyJmapAuth('https://example.com', 'Bearer x')).rejects.toMatchObject({
|
|
status: 400,
|
|
message: 'Server URL is not allowed',
|
|
});
|
|
expect(fetchSpy).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('accepts a public host that returns a valid JMAP session', async () => {
|
|
lookup.mockResolvedValue([{ address: '93.184.216.34', family: 4 }]);
|
|
guardedFetch.mockResolvedValueOnce(
|
|
new Response(JSON.stringify({ apiUrl: 'https://example.com/api', accounts: {} }), {
|
|
status: 200,
|
|
headers: { 'content-type': 'application/json' },
|
|
}),
|
|
);
|
|
const { verifyJmapAuth } = await load();
|
|
await expect(verifyJmapAuth('https://example.com', 'Bearer x')).resolves.toBe(
|
|
'https://example.com',
|
|
);
|
|
expect(guardedFetch).toHaveBeenCalledWith(
|
|
'https://example.com/.well-known/jmap',
|
|
expect.objectContaining({ method: 'GET' }),
|
|
);
|
|
expect(fetchSpy).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('rejects an invalid Authorization header before any fetch', async () => {
|
|
const { verifyJmapAuth } = await load();
|
|
await expect(verifyJmapAuth('https://example.com', 'NotAuth')).rejects.toMatchObject({
|
|
status: 400,
|
|
});
|
|
expect(fetchSpy).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('with trusted=true, accepts a hostname resolving to a private IP', async () => {
|
|
lookup.mockResolvedValue([{ address: '10.0.20.5', family: 4 }]);
|
|
fetchSpy.mockResolvedValueOnce(
|
|
new Response(JSON.stringify({ apiUrl: 'https://mail.internal/api', accounts: {} }), {
|
|
status: 200,
|
|
headers: { 'content-type': 'application/json' },
|
|
}),
|
|
);
|
|
const { verifyJmapAuth } = await load();
|
|
await expect(
|
|
verifyJmapAuth('https://mail.internal', 'Bearer x', { trusted: true }),
|
|
).resolves.toBe('https://mail.internal');
|
|
expect(fetchSpy).toHaveBeenCalledWith(
|
|
'https://mail.internal/.well-known/jmap',
|
|
expect.objectContaining({ redirect: 'manual' }),
|
|
);
|
|
expect(guardedFetch).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('with trusted=true, still rejects unsupported protocols', async () => {
|
|
const { verifyJmapAuth } = await load();
|
|
await expect(
|
|
verifyJmapAuth('file:///etc/passwd', 'Bearer x', { trusted: true }),
|
|
).rejects.toMatchObject({ status: 400 });
|
|
expect(fetchSpy).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('with trusted=true, still rejects an invalid Authorization header', async () => {
|
|
const { verifyJmapAuth } = await load();
|
|
await expect(
|
|
verifyJmapAuth('https://mail.internal', 'NotAuth', { trusted: true }),
|
|
).rejects.toMatchObject({ status: 400 });
|
|
expect(fetchSpy).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe('normalizeJmapServerUrl', () => {
|
|
async function load() {
|
|
return import('@/lib/auth/verify-jmap-auth');
|
|
}
|
|
|
|
it('accepts an app-relative URL (dev mock JMAP server)', async () => {
|
|
const { normalizeJmapServerUrl } = await load();
|
|
expect(normalizeJmapServerUrl('/api/dev-jmap')).toBe('/api/dev-jmap');
|
|
});
|
|
|
|
it('strips query, hash, and trailing slashes from an app-relative URL', async () => {
|
|
const { normalizeJmapServerUrl } = await load();
|
|
expect(normalizeJmapServerUrl('/api/dev-jmap/?x=1#frag')).toBe('/api/dev-jmap');
|
|
});
|
|
|
|
it('rejects protocol-relative URLs', async () => {
|
|
const { normalizeJmapServerUrl } = await load();
|
|
expect(() => normalizeJmapServerUrl('//evil.example.com/api')).toThrow('Invalid server URL');
|
|
});
|
|
|
|
it('normalizes absolute URLs unchanged', async () => {
|
|
const { normalizeJmapServerUrl } = await load();
|
|
expect(normalizeJmapServerUrl('https://mail.example.com/jmap/?a=b')).toBe(
|
|
'https://mail.example.com/jmap',
|
|
);
|
|
});
|
|
});
|