988a3cca9a
Multi-user authorization hardening from a full security evaluation (EVALUATION.md):
- P0: /api/settings/{export,import}-db are now admin-only. Previously any signed-in user could download the whole instance DB (all bcrypt hashes + plaintext AI keys) or replace it wholesale. Per-user CSV export/import stays open.
- AI custom-URL providers (Ollama, OpenAI-compatible) are now admin-only, and every server fetch to a user-supplied URL passes through assertSafeProviderUrl (blocks link-local/cloud-metadata; private LAN allowed by design). Fixed-URL cloud providers stay per-user. Removed the dead legacy /api/ai/config route.
- Dev: fixed broken quick-start (added npm run create-admin; rewrote README; dropped dead CLAUDE_API_KEY) and the export-db 0-byte path resolution (resolveDatabasePath now matches Prisma).
ExVer bumped to 1.1.0:8 (no schema/data migration). Tests 197 pass, build green, tsc clean.
68 lines
2.8 KiB
TypeScript
68 lines
2.8 KiB
TypeScript
import { describe, it, expect } from 'vitest';
|
|
import { assertSafeProviderUrl, isBlockedAddress } from '@/lib/ai/safeUrl';
|
|
|
|
// SSRF guard for user-supplied provider base URLs (EVALUATION.md P1).
|
|
// Self-hosted intent: private-LAN + loopback are ALLOWED (Ollama, gateways,
|
|
// dev); only link-local/cloud-metadata + unspecified + non-http(s) are blocked.
|
|
|
|
describe('isBlockedAddress', () => {
|
|
it('blocks link-local / cloud-metadata and unspecified', () => {
|
|
expect(isBlockedAddress('169.254.169.254')).toBe(true); // cloud metadata
|
|
expect(isBlockedAddress('169.254.0.1')).toBe(true);
|
|
expect(isBlockedAddress('0.0.0.0')).toBe(true);
|
|
expect(isBlockedAddress('::')).toBe(true);
|
|
expect(isBlockedAddress('fe80::1')).toBe(true); // IPv6 link-local
|
|
expect(isBlockedAddress('::ffff:169.254.169.254')).toBe(true); // mapped
|
|
});
|
|
|
|
it('allows private LAN, loopback, and public (the legitimate targets)', () => {
|
|
expect(isBlockedAddress('192.168.1.10')).toBe(false);
|
|
expect(isBlockedAddress('10.0.0.5')).toBe(false);
|
|
expect(isBlockedAddress('172.16.0.9')).toBe(false);
|
|
expect(isBlockedAddress('127.0.0.1')).toBe(false);
|
|
expect(isBlockedAddress('1.1.1.1')).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('assertSafeProviderUrl', () => {
|
|
it('rejects non-http(s) schemes', async () => {
|
|
await expect(assertSafeProviderUrl('ftp://example.com')).rejects.toThrow();
|
|
await expect(assertSafeProviderUrl('file:///etc/passwd')).rejects.toThrow();
|
|
});
|
|
|
|
it('rejects an unparseable URL', async () => {
|
|
await expect(assertSafeProviderUrl('not a url')).rejects.toThrow();
|
|
});
|
|
|
|
it('rejects cloud-metadata / link-local IP literals', async () => {
|
|
await expect(
|
|
assertSafeProviderUrl('http://169.254.169.254/latest/meta-data/'),
|
|
).rejects.toThrow();
|
|
await expect(assertSafeProviderUrl('http://0.0.0.0:11434')).rejects.toThrow();
|
|
await expect(assertSafeProviderUrl('http://[fe80::1]:11434')).rejects.toThrow();
|
|
});
|
|
|
|
it('allows private-LAN and loopback Ollama targets', async () => {
|
|
await expect(
|
|
assertSafeProviderUrl('http://192.168.1.50:11434/api/chat'),
|
|
).resolves.toBeUndefined();
|
|
await expect(
|
|
assertSafeProviderUrl('http://127.0.0.1:11434/api/tags'),
|
|
).resolves.toBeUndefined();
|
|
await expect(
|
|
assertSafeProviderUrl('http://10.0.0.2:8000/v1/chat/completions'),
|
|
).resolves.toBeUndefined();
|
|
});
|
|
|
|
// IPv6 literals carry brackets in URL.hostname; the guard must strip BOTH
|
|
// and classify by the real address (catches a missing-/g bracket-strip bug).
|
|
it('handles bracketed IPv6 literals correctly', async () => {
|
|
await expect(
|
|
assertSafeProviderUrl('http://[::1]:11434/api/tags'),
|
|
).resolves.toBeUndefined(); // loopback allowed
|
|
await expect(
|
|
assertSafeProviderUrl('http://[fe80::1]:11434'),
|
|
).rejects.toThrow(/link-local/); // blocked by isBlockedAddress, not a DNS miss
|
|
});
|
|
});
|