110 lines
4.1 KiB
TypeScript
110 lines
4.1 KiB
TypeScript
/**
|
|
* SSRF protection for the notification subsystem.
|
|
*
|
|
* Every notification channel dispatches through notificationFetch, which
|
|
* validates the target URL before making any request. Notification endpoints
|
|
* use a deliberately permissive policy (isSafeNotificationUrl): loopback and
|
|
* cloud-metadata hosts are blocked, but ordinary LAN ranges are allowed so a
|
|
* self-hosted receiver on the local network still works.
|
|
*/
|
|
|
|
import { describe, test, expect } from 'bun:test';
|
|
import { isSafeNotificationUrl } from '../src/lib/server/url-safety';
|
|
import { validateRepositoryForSave } from '../src/lib/server/backups/helpers';
|
|
import { notificationFetch } from '../src/lib/server/notifications/shared';
|
|
|
|
describe('isSafeNotificationUrl — notification endpoint guard', () => {
|
|
test('blocks loopback and cloud-metadata hosts', () => {
|
|
const blocked = [
|
|
'http://169.254.169.254/', // cloud metadata
|
|
'http://localhost/hook',
|
|
'http://127.0.0.1/hook',
|
|
'http://[::1]/hook',
|
|
'http://0.0.0.0/hook',
|
|
];
|
|
for (const url of blocked) {
|
|
expect(isSafeNotificationUrl(url).ok).toBe(false);
|
|
}
|
|
});
|
|
|
|
test('allows ordinary LAN hosts (self-hosted receivers)', () => {
|
|
const allowed = [
|
|
'http://10.0.0.5/hook',
|
|
'http://192.168.1.10/hook',
|
|
'http://172.16.0.1/hook',
|
|
];
|
|
for (const url of allowed) {
|
|
expect(isSafeNotificationUrl(url).ok).toBe(true);
|
|
}
|
|
});
|
|
|
|
test('rejects non-http(s) schemes', () => {
|
|
expect(isSafeNotificationUrl('file:///etc/passwd').ok).toBe(false);
|
|
expect(isSafeNotificationUrl('gopher://example.com/x').ok).toBe(false);
|
|
});
|
|
|
|
test('accepts a normal public https URL', () => {
|
|
expect(isSafeNotificationUrl('https://hooks.example.com/services/abc').ok).toBe(true);
|
|
});
|
|
|
|
// A public DNS name starting "fc"/"fd" must not be mistaken for an fc00::/fd00::
|
|
// IPv6 unique-local literal. (ULA itself is a LAN range, so the permissive
|
|
// notification policy allows it - the strict-policy block is asserted below.)
|
|
test('public hosts starting fc/fd are allowed (not treated as IPv6 ULA)', () => {
|
|
for (const url of ['https://fcm.googleapis.com/x', 'https://fc2.com/x', 'https://fdroid.link/x']) {
|
|
expect(isSafeNotificationUrl(url).ok).toBe(true);
|
|
}
|
|
});
|
|
});
|
|
|
|
import { ipCategory } from '../src/lib/server/url-safety';
|
|
|
|
describe('ipCategory — fc/fd anchoring', () => {
|
|
test('real IPv6 unique-local / link-local literals are private', () => {
|
|
for (const h of ['fc00::1', 'fd12:3456::1', 'fe80::1']) {
|
|
expect(ipCategory(h)).toBe('private');
|
|
}
|
|
});
|
|
|
|
test('public DNS names starting fc/fd are not IP literals', () => {
|
|
for (const h of ['fcm.googleapis.com', 'fc2.com', 'fdroid.link', 'fd7host.example.com']) {
|
|
expect(ipCategory(h)).toBe(null);
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('validateRepositoryForSave — backup destination SSRF guard', () => {
|
|
test('blocks loopback and cloud-metadata repo hosts', () => {
|
|
expect(validateRepositoryForSave('rest:http://169.254.169.254/repo')).not.toBeNull();
|
|
expect(validateRepositoryForSave('rest:http://127.0.0.1/repo')).not.toBeNull();
|
|
});
|
|
|
|
test('allows a self-hosted repo on a LAN host (MinIO/REST on the local network)', () => {
|
|
expect(validateRepositoryForSave('rest:http://192.168.1.106/repo')).toBeNull();
|
|
expect(validateRepositoryForSave('rest:http://10.0.0.5:8000/')).toBeNull();
|
|
});
|
|
|
|
test('allows a normal public repo and a local absolute path', () => {
|
|
expect(validateRepositoryForSave('rest:https://backups.example.com/repo')).toBeNull();
|
|
expect(validateRepositoryForSave('/srv/backups')).toBeNull();
|
|
});
|
|
|
|
test('rejects an unknown scheme', () => {
|
|
expect(validateRepositoryForSave('gopher://example.com/x')).not.toBeNull();
|
|
});
|
|
});
|
|
|
|
describe('notificationFetch — blocks before making a request', () => {
|
|
test('throws on a cloud-metadata URL (no network access)', async () => {
|
|
await expect(
|
|
notificationFetch('http://169.254.169.254/latest/meta-data/')
|
|
).rejects.toThrow(/Notification endpoint rejected/);
|
|
});
|
|
|
|
test('throws on a loopback URL object (no network access)', async () => {
|
|
await expect(
|
|
notificationFetch(new URL('http://127.0.0.1/hook'))
|
|
).rejects.toThrow(/Notification endpoint rejected/);
|
|
});
|
|
});
|