mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-01 16:12:02 +00:00
fix: reach a company calendar on a private address only when its host names the intranet
This commit is contained in:
@@ -4,7 +4,7 @@ import { getStalwartCredentials, type StalwartCredentials } from '@/lib/stalwart
|
||||
import { DisallowedUrlError, fetchPublicUrl, type PublicFetchResponse } from '@/lib/security/url-guard';
|
||||
import { readBodyCapped } from '@/lib/security/capped-body';
|
||||
import { configManager } from '@/lib/admin/config-manager';
|
||||
import { feedForSubject, feedHostname } from '@/lib/admin/company-feeds';
|
||||
import { feedForSubject, intranetFeedHost } from '@/lib/admin/company-feeds';
|
||||
import { companyFeedCache } from '@/lib/admin/company-feed-cache';
|
||||
import { serverPolicySubject } from '@/lib/admin/server-policy-subject';
|
||||
|
||||
@@ -66,7 +66,7 @@ type FetchOutcome =
|
||||
*/
|
||||
async function fetchIcal(
|
||||
url: string,
|
||||
options: { allowPrivateHost?: string; etag?: string | null; lastModified?: string | null } = {},
|
||||
options: { allowPrivateHost?: string; etag?: string | null; lastModified?: string | null; requireCalendar?: boolean } = {},
|
||||
): Promise<FetchOutcome> {
|
||||
const fail = (error: string, status: number): FetchOutcome => ({ kind: 'error', response: NextResponse.json({ error }, { status }) });
|
||||
const extracted = extractBasicAuth(url);
|
||||
@@ -97,7 +97,9 @@ async function fetchIcal(
|
||||
|
||||
try {
|
||||
const init = { signal: controller.signal, headers };
|
||||
response = options.allowPrivateHost
|
||||
// The intranet exception holds for the admin's own address only, not
|
||||
// for a redirect to another port or host.
|
||||
response = options.allowPrivateHost && new URL(currentUrl).origin === originalOrigin
|
||||
? await fetchPublicUrl(currentUrl, init, { allowPrivateHost: options.allowPrivateHost })
|
||||
: await fetchPublicUrl(currentUrl, init);
|
||||
} catch (error) {
|
||||
@@ -138,6 +140,9 @@ async function fetchIcal(
|
||||
if (!buffer) {
|
||||
return { kind: 'error', response: tooLarge(maxResponseSize) };
|
||||
}
|
||||
if (options.requireCalendar && !looksLikeCalendar(buffer)) {
|
||||
return fail('The address did not answer with a calendar', 502);
|
||||
}
|
||||
return { kind: 'ok', buffer, etag: response.headers.get('etag'), lastModified: response.headers.get('last-modified') };
|
||||
} catch (error: unknown) {
|
||||
if (error instanceof Error && error.name === 'AbortError') {
|
||||
@@ -149,6 +154,13 @@ async function fetchIcal(
|
||||
}
|
||||
}
|
||||
|
||||
/** Starts with BEGIN:VCALENDAR (after a byte order mark and blank space). */
|
||||
function looksLikeCalendar(buffer: ArrayBuffer): boolean {
|
||||
// TextDecoder drops a byte order mark; trimStart the rest.
|
||||
const head = new TextDecoder().decode(buffer.slice(0, 512)).trimStart();
|
||||
return /^BEGIN:VCALENDAR/i.test(head);
|
||||
}
|
||||
|
||||
function icalResponse(buffer: ArrayBuffer): NextResponse {
|
||||
return new NextResponse(buffer, {
|
||||
status: 200,
|
||||
@@ -180,7 +192,8 @@ async function fetchCompanyFeed(creds: StalwartCredentials, feedId: string): Pro
|
||||
return icalResponse(cached.body);
|
||||
}
|
||||
const outcome = await fetchIcal(feed.url, {
|
||||
allowPrivateHost: feedHostname(feed) ?? undefined,
|
||||
allowPrivateHost: intranetFeedHost(feed) ?? undefined,
|
||||
requireCalendar: true,
|
||||
etag: cached?.etag,
|
||||
lastModified: cached?.lastModified,
|
||||
});
|
||||
|
||||
@@ -162,8 +162,10 @@ export function CompanyCalendarsSection({ draft }: { draft: OrgPolicyDraft }) {
|
||||
<div className="space-y-1 rounded-md border border-border bg-muted/30 px-3 py-2 text-xs text-muted-foreground" data-testid="org-feed-notes">
|
||||
<p>
|
||||
Bulwark's server fetches the feeds, at most once per refresh interval for everyone, and keeps the addresses to
|
||||
itself: users never see them. A feed may be on your intranet (a private address): only the hosts entered here are
|
||||
let through. They stay reachable with "No third-party traffic" on and are listed on Organisation > Security.
|
||||
itself: users never see them. A feed may be on your intranet when its address names it: an intranet IP address
|
||||
(10.x, 172.16-31.x, 192.168.x), a .local or .internal name, or a name without dots. Loopback and link-local
|
||||
addresses are never reached. Feeds stay reachable with "No third-party traffic" on and are listed on
|
||||
Organisation > Security.
|
||||
</p>
|
||||
<p>
|
||||
Each refresh that changed the feed uploads it once to Stalwart for every user who has Calendar open, which counts
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { test, expect, type Browser, type Page } from '@playwright/test';
|
||||
import { createServer, type Server } from 'node:http';
|
||||
import type { AddressInfo } from 'node:net';
|
||||
import { networkInterfaces } from 'node:os';
|
||||
import {
|
||||
ENT, ENT_SERVER, adminContext, getPolicy, jmapAs, putPolicy, rawMessage, requireEntEnv, shot, userPage, withPolicy,
|
||||
} from './helpers/ent';
|
||||
@@ -492,6 +493,8 @@ test('the out-of-office checkbox fits a phone', async ({ browser }) => {
|
||||
});
|
||||
|
||||
// ── 9. Company calendars ─────────────────────────────────────────────
|
||||
// The feed is served from this PC's intranet address, the case of a feed on
|
||||
// the company intranet.
|
||||
|
||||
function icsServer(title: string): Promise<{ server: Server; url: string; hits: () => number }> {
|
||||
let hits = 0;
|
||||
@@ -507,9 +510,14 @@ function icsServer(title: string): Promise<{ server: Server; url: string; hits:
|
||||
res.writeHead(200, { 'content-type': 'text/calendar' });
|
||||
res.end(body);
|
||||
});
|
||||
return new Promise((resolve) => server.listen(0, '127.0.0.1', () => {
|
||||
// An intranet address of this PC: the server reaches admin feeds on the
|
||||
// intranet, never on loopback.
|
||||
const lan = Object.values(networkInterfaces()).flat()
|
||||
.find((i) => i && i.family === 'IPv4' && !i.internal && /^(10.|192.168.|172.(1[6-9]|2d|3[01]).)/.test(i.address))?.address;
|
||||
if (!lan) throw new Error('no intranet IPv4 address on this PC for the feed server');
|
||||
return new Promise((resolve) => server.listen(0, lan, () => {
|
||||
const { port } = server.address() as AddressInfo;
|
||||
resolve({ server, url: `http://127.0.0.1:${port}/team.ics`, hits: () => hits });
|
||||
resolve({ server, url: `http://${lan}:${port}/team.ics`, hits: () => hits });
|
||||
}));
|
||||
}
|
||||
|
||||
|
||||
@@ -54,7 +54,7 @@ const POLICY = {
|
||||
restrictions: {}, features: {}, defaults: {}, themePolicy: {},
|
||||
organisation: { calendars: { feeds: [{ id: 'hol', name: 'Holidays', url: 'https://cal.example.org/h.ics?token=t', color: '#2563eb', refreshMinutes: 60 }] } },
|
||||
scopes: [
|
||||
{ id: 'team', label: 'Team', match: { groups: ['ent-team@example.org'] }, policy: { organisation: { calendars: { feeds: [{ id: 'team', name: 'Team', url: 'http://cal.intranet.example/team.ics', refreshMinutes: 120 }] } } } },
|
||||
{ id: 'team', label: 'Team', match: { groups: ['ent-team@example.org'] }, policy: { organisation: { calendars: { feeds: [{ id: 'team', name: 'Team', url: 'http://calendar.corp.internal/team.ics', refreshMinutes: 120 }] } } } },
|
||||
{ id: 'bob', label: 'Bob', match: { groups: ['ent-bob@example.org'] }, policy: { organisation: { calendars: { feeds: [{ id: 'bobs', name: 'Bob', url: 'https://cal.example.org/b.ics' }] } } } },
|
||||
],
|
||||
};
|
||||
@@ -90,9 +90,11 @@ describe('POST /api/fetch-ical { feedId }', () => {
|
||||
expect((await post({ feedId: 'hol' })).status).toBe(200);
|
||||
expect(guardedFetch.mock.calls[0][0]).toBe('https://cal.example.org/h.ics?token=t');
|
||||
expect((await post({ feedId: 'team' })).status).toBe(200);
|
||||
// The admin's own intranet host may be private, and only it.
|
||||
expect(guardedFetch.mock.calls[1][0]).toBe('http://cal.intranet.example/team.ics');
|
||||
expect(guardedFetch.mock.calls[1][2]).toEqual({ allowPrivateHost: 'cal.intranet.example' });
|
||||
// The admin's own intranet host may be private, and only it; a public
|
||||
// host gets no exception.
|
||||
expect(guardedFetch.mock.calls[0]).toHaveLength(2);
|
||||
expect(guardedFetch.mock.calls[1][0]).toBe('http://calendar.corp.internal/team.ics');
|
||||
expect(guardedFetch.mock.calls[1][2]).toEqual({ allowPrivateHost: 'calendar.corp.internal' });
|
||||
});
|
||||
|
||||
it('refuses a feed of a scope the user is not in, without fetching anything', async () => {
|
||||
@@ -122,6 +124,20 @@ describe('POST /api/fetch-ical { feedId }', () => {
|
||||
expect(guardedFetch.mock.calls[1][1].headers).toMatchObject({ 'If-None-Match': '"v1"', 'If-Modified-Since': 'Mon, 02 Nov 2026 07:00:00 GMT' });
|
||||
});
|
||||
|
||||
it('keeps the intranet exception off a redirect to another port', async () => {
|
||||
guardedFetch
|
||||
.mockImplementationOnce(async () => new Response(null, { status: 302, headers: { location: 'http://calendar.corp.internal:8443/x' } }))
|
||||
.mockImplementationOnce(async () => ics());
|
||||
expect((await post({ feedId: 'team' })).status).toBe(200);
|
||||
expect(guardedFetch.mock.calls[0][2]).toEqual({ allowPrivateHost: 'calendar.corp.internal' });
|
||||
expect(guardedFetch.mock.calls[1]).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('refuses an answer that is not a calendar', async () => {
|
||||
guardedFetch.mockImplementation(async () => new Response('{"secret":1}', { status: 200, headers: { 'content-type': 'application/json' } }));
|
||||
expect((await post({ feedId: 'team' })).status).toBe(502);
|
||||
});
|
||||
|
||||
it('needs a signed-in user', async () => {
|
||||
creds.value = null;
|
||||
expect((await post({ feedId: 'hol' })).status).toBe(401);
|
||||
|
||||
@@ -3,10 +3,12 @@ import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vites
|
||||
import { createServer, type Server } from 'node:http';
|
||||
import type { AddressInfo } from 'node:net';
|
||||
|
||||
// A company calendar may live on the intranet: fetchPublicUrl lets exactly
|
||||
// the host the admin entered resolve to a private address, and nothing else
|
||||
// (a redirect to another private host, another name), with the address still
|
||||
// pinned at connect time.
|
||||
// A company calendar may live on the intranet: fetchPublicUrl lets the one
|
||||
// host the admin entered resolve to an intranet address, and only when that
|
||||
// host itself names the intranet (an intranet address, .local/.internal, a
|
||||
// single-label name). Loopback, link-local and metadata addresses stay
|
||||
// refused for it, a public name gets no exception at all, and the address is
|
||||
// still pinned at connect time.
|
||||
|
||||
const lookup = vi.fn();
|
||||
vi.mock('node:dns/promises', async (importOriginal) => {
|
||||
@@ -18,6 +20,24 @@ vi.mock('node:dns/promises', async (importOriginal) => {
|
||||
};
|
||||
});
|
||||
|
||||
describe('what counts as the intranet', () => {
|
||||
it('takes intranet addresses and names, never loopback, link-local, metadata or public names', async () => {
|
||||
const { isIntranetAddress, isIntranetHostName } = await import('@/lib/security/url-guard');
|
||||
for (const ip of ['10.1.2.3', '172.16.0.9', '192.168.178.20', '100.64.1.1', 'fd12:3456::1', '::ffff:10.0.0.1']) {
|
||||
expect(isIntranetAddress(ip), ip).toBe(true);
|
||||
}
|
||||
for (const ip of ['127.0.0.1', '169.254.169.254', '0.0.0.0', '::1', 'fe80::1', 'fd00:ec2::254', '::ffff:127.0.0.1', '8.8.8.8', '224.0.0.1']) {
|
||||
expect(isIntranetAddress(ip), ip).toBe(false);
|
||||
}
|
||||
for (const host of ['calendar', 'cal.corp.internal', 'nas.local', 'srv.localdomain', '10.0.0.5', '[fd12::1]']) {
|
||||
expect(isIntranetHostName(host), host).toBe(true);
|
||||
}
|
||||
for (const host of ['cal.example.org', 'localhost', 'app.localhost', '127.0.0.1', '169.254.169.254', '']) {
|
||||
expect(isIntranetHostName(host), host).toBe(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('fetchPublicUrl with allowPrivateHost', () => {
|
||||
let server: Server;
|
||||
let port: number;
|
||||
@@ -37,30 +57,42 @@ describe('fetchPublicUrl with allowPrivateHost', () => {
|
||||
});
|
||||
beforeEach(() => {
|
||||
lookup.mockReset();
|
||||
lookup.mockResolvedValue([{ address: '127.0.0.1', family: 4 }]);
|
||||
hits = 0;
|
||||
});
|
||||
|
||||
it('reaches the admin\'s intranet host', async () => {
|
||||
const { fetchPublicUrl } = await import('@/lib/security/url-guard');
|
||||
const res = await fetchPublicUrl(`http://cal.intranet.example:${port}/team.ics`, {}, { allowPrivateHost: 'cal.intranet.example' });
|
||||
expect(res.status).toBe(200);
|
||||
expect(hits).toBe(1);
|
||||
it('lets an intranet name through to an intranet address', async () => {
|
||||
lookup.mockResolvedValue([{ address: '10.255.255.1', family: 4 }]);
|
||||
const { fetchPublicUrl, DisallowedUrlError } = await import('@/lib/security/url-guard');
|
||||
// Nothing answers there: the connection is tried (the guard lets it
|
||||
// through) and runs out of time.
|
||||
const error = await fetchPublicUrl('http://calendar:8080/team.ics', { signal: AbortSignal.timeout(300) }, { allowPrivateHost: 'calendar' })
|
||||
.catch((e: unknown) => e);
|
||||
expect(error).toBeInstanceOf(Error);
|
||||
expect(error).not.toBeInstanceOf(DisallowedUrlError);
|
||||
expect(lookup).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('allows .internal names only for that host', async () => {
|
||||
it('refuses loopback and metadata addresses even for the admin\'s intranet host', async () => {
|
||||
const { fetchPublicUrl, DisallowedUrlError } = await import('@/lib/security/url-guard');
|
||||
const res = await fetchPublicUrl(`http://calendar.corp.internal:${port}/a.ics`, {}, { allowPrivateHost: 'calendar.corp.internal' });
|
||||
expect(res.status).toBe(200);
|
||||
await expect(fetchPublicUrl(`http://other.corp.internal:${port}/a.ics`, {}, { allowPrivateHost: 'calendar.corp.internal' }))
|
||||
lookup.mockResolvedValue([{ address: '127.0.0.1', family: 4 }]);
|
||||
await expect(fetchPublicUrl(`http://calendar:${port}/team.ics`, {}, { allowPrivateHost: 'calendar' }))
|
||||
.rejects.toBeInstanceOf(DisallowedUrlError);
|
||||
lookup.mockResolvedValue([{ address: '169.254.169.254', family: 4 }]);
|
||||
await expect(fetchPublicUrl('http://cal.corp.internal/latest/meta-data/', {}, { allowPrivateHost: 'cal.corp.internal' }))
|
||||
.rejects.toBeInstanceOf(DisallowedUrlError);
|
||||
await expect(fetchPublicUrl(`http://127.0.0.1:${port}/team.ics`, {}, { allowPrivateHost: '127.0.0.1' }))
|
||||
.rejects.toBeInstanceOf(DisallowedUrlError);
|
||||
expect(hits).toBe(0);
|
||||
});
|
||||
|
||||
it('keeps refusing every other host that resolves to a private address', async () => {
|
||||
it('gives a public host no exception, whatever its DNS points at', async () => {
|
||||
const { fetchPublicUrl, DisallowedUrlError } = await import('@/lib/security/url-guard');
|
||||
await expect(fetchPublicUrl(`http://rebind.example:${port}/cal.ics`, {}, { allowPrivateHost: 'cal.intranet.example' }))
|
||||
lookup.mockResolvedValue([{ address: '10.0.0.7', family: 4 }]);
|
||||
await expect(fetchPublicUrl('http://partner.example.org/cal.ics', {}, { allowPrivateHost: 'partner.example.org' }))
|
||||
.rejects.toBeInstanceOf(DisallowedUrlError);
|
||||
// Another intranet name than the admin's stays refused.
|
||||
await expect(fetchPublicUrl('http://other.corp.internal/a.ics', {}, { allowPrivateHost: 'cal.corp.internal' }))
|
||||
.rejects.toBeInstanceOf(DisallowedUrlError);
|
||||
await expect(fetchPublicUrl(`http://cal.intranet.example:${port}/team.ics`)).rejects.toBeInstanceOf(DisallowedUrlError);
|
||||
expect(hits).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { allFeeds, feedForSubject, stripFeedUrls } from '../company-feeds';
|
||||
import { allFeeds, feedForSubject, intranetFeedHost, stripFeedUrls } from '../company-feeds';
|
||||
import { listReachableHosts } from '../reachable-hosts';
|
||||
import { sanitizeOrganisationPolicy } from '../organisation-policy';
|
||||
import type { SettingsPolicy } from '../types';
|
||||
@@ -32,6 +32,16 @@ describe('company feeds on the server', () => {
|
||||
expect(feedForSubject(policy, { email: 'b@two.example', groups: [] }, 'two')?.name).toBe('Two');
|
||||
});
|
||||
|
||||
it('lifts the private-address block only for a host that names the intranet', () => {
|
||||
expect(intranetFeedHost({ url: 'http://calendar.corp.internal/team.ics' })).toBe('calendar.corp.internal');
|
||||
expect(intranetFeedHost({ url: 'http://intranet/team.ics' })).toBe('intranet');
|
||||
expect(intranetFeedHost({ url: 'http://10.1.2.3/team.ics' })).toBe('10.1.2.3');
|
||||
expect(intranetFeedHost({ url: 'https://cal.example.org/h.ics' })).toBeNull();
|
||||
expect(intranetFeedHost({ url: 'http://127.0.0.1:8080/h.ics' })).toBeNull();
|
||||
expect(intranetFeedHost({ url: 'http://169.254.169.254/latest' })).toBeNull();
|
||||
expect(intranetFeedHost({ url: 'http://localhost/h.ics' })).toBeNull();
|
||||
});
|
||||
|
||||
it('lists every scope\'s feed host as reachable', () => {
|
||||
expect(allFeeds(policy).map((f) => f.id)).toEqual(['hol', 'team', 'two']);
|
||||
const hosts = listReachableHosts({ get: () => undefined, policy, env: {} })
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
import type { SettingsPolicy } from './types';
|
||||
import type { CompanyFeed } from './organisation-policy';
|
||||
import { resolvePolicy, type PolicySubject } from './policy-scopes';
|
||||
import { isIntranetHostName } from '@/lib/security/url-guard';
|
||||
|
||||
function withoutUrls(organisation: unknown): unknown {
|
||||
if (!organisation || typeof organisation !== 'object') return organisation;
|
||||
@@ -55,10 +56,16 @@ export function allFeeds(policy: Pick<SettingsPolicy, 'organisation' | 'scopes'>
|
||||
return lists.flatMap((feeds) => (Array.isArray(feeds) ? feeds.filter((f): f is CompanyFeed => !!f && typeof f === 'object' && typeof (f as CompanyFeed).url === 'string') : []));
|
||||
}
|
||||
|
||||
/** The hostnames of the admin's feeds: the only private hosts a feed fetch may reach. */
|
||||
export function feedHostname(feed: Pick<CompanyFeed, 'url'>): string | null {
|
||||
/**
|
||||
* The feed's host when it names the intranet (an intranet address, a
|
||||
* .local/.internal/.localdomain name or a single-label name): the only kind
|
||||
* of host a feed fetch may reach on a private address. Null for public
|
||||
* names, so a public host whose DNS is later pointed inside reaches nothing.
|
||||
*/
|
||||
export function intranetFeedHost(feed: Pick<CompanyFeed, 'url'>): string | null {
|
||||
try {
|
||||
return new URL(feed.url).hostname.toLowerCase();
|
||||
const host = new URL(feed.url).hostname.toLowerCase();
|
||||
return isIntranetHostName(host) ? host : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
+55
-10
@@ -10,7 +10,7 @@ import {
|
||||
type RequestInit as UndiciRequestInit,
|
||||
type Response as UndiciResponse,
|
||||
} from 'undici';
|
||||
import { isPrivateAddress } from './ip-ranges';
|
||||
import { isPrivateAddress, parseV6Groups } from './ip-ranges';
|
||||
|
||||
const BLOCKED_HOSTNAMES = new Set(['localhost']);
|
||||
const BLOCKED_HOSTNAME_SUFFIXES = ['.localhost', '.local', '.internal', '.arpa', '.localdomain'];
|
||||
@@ -29,6 +29,41 @@ function isBlockedHostname(hostname: string): boolean {
|
||||
return BLOCKED_HOSTNAME_SUFFIXES.some((suffix) => hostname.endsWith(suffix));
|
||||
}
|
||||
|
||||
/**
|
||||
* An intranet address: RFC 1918, CGNAT or an IPv6 unique local address,
|
||||
* as such or IPv4-mapped. Never loopback, link-local (cloud metadata lives
|
||||
* there), unspecified, multicast or the AWS metadata ULA.
|
||||
*/
|
||||
export function isIntranetAddress(ip: string): boolean {
|
||||
const host = normalizeHostname(ip);
|
||||
const v4 = (a: string) => /^10\./.test(a) || /^172\.(1[6-9]|2\d|3[0-1])\./.test(a) || /^192\.168\./.test(a)
|
||||
|| /^100\.(6[4-9]|[7-9]\d|1[01]\d|12[0-7])\./.test(a);
|
||||
const family = isIP(host);
|
||||
if (family === 4) return v4(host);
|
||||
if (family !== 6) return false;
|
||||
const g = parseV6Groups(host);
|
||||
if (!g) return false;
|
||||
if (g.slice(0, 5).every((x) => x === 0) && g[5] === 0xffff) {
|
||||
return v4(`${g[6] >> 8}.${g[6] & 0xff}.${g[7] >> 8}.${g[7] & 0xff}`);
|
||||
}
|
||||
const awsMetadata = g[0] === 0xfd00 && g[1] === 0x0ec2 && g.slice(2, 7).every((x) => x === 0) && g[7] === 0x254;
|
||||
return (g[0] & 0xfe00) === 0xfc00 && !awsMetadata;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether an admin-entered host names the intranet: an intranet address
|
||||
* literal, a name under a private suffix (.local, .internal, .localdomain)
|
||||
* or a single-label name. Public names and localhost never are, so a public
|
||||
* host whose DNS later points inside can't be used to reach it.
|
||||
*/
|
||||
export function isIntranetHostName(hostname: string): boolean {
|
||||
const host = normalizeHostname(hostname);
|
||||
if (!host || host === 'localhost' || host.endsWith('.localhost')) return false;
|
||||
if (isIP(host)) return isIntranetAddress(host);
|
||||
if (['.local', '.internal', '.localdomain'].some((suffix) => host.endsWith(suffix))) return true;
|
||||
return !host.includes('.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Synchronous part of the guard: protocol, embedded credentials, blocked
|
||||
* hostnames / suffixes, and literal IP addresses. Returns the parsed URL when
|
||||
@@ -46,7 +81,7 @@ function parseAllowedUrl(urlString: string, privateHost?: string): URL | null {
|
||||
if (url.username || url.password) return null;
|
||||
|
||||
const hostname = normalizeHostname(url.hostname);
|
||||
if (privateHost && hostname === privateHost) return url;
|
||||
if (privateHost && hostname === privateHost && isIntranetHostName(hostname)) return url;
|
||||
if (isBlockedHostname(hostname)) return null;
|
||||
if (isBlockedIpAddress(hostname)) return null;
|
||||
|
||||
@@ -215,10 +250,12 @@ export function envProxyFor(url: URL): URL | null {
|
||||
}
|
||||
|
||||
/**
|
||||
* guardedLookup, except that `privateHost` may resolve to a private address:
|
||||
* an intranet host the admin entered themselves (company calendars). Every
|
||||
* other name, redirect targets included, is still checked, and the address
|
||||
* is still the one the socket connects to.
|
||||
* guardedLookup, except that `privateHost` may resolve to an intranet
|
||||
* address (isIntranetAddress): a host on the intranet the admin entered
|
||||
* themselves (company calendars). Loopback, link-local and metadata
|
||||
* addresses stay refused for it too; every other name, redirect targets
|
||||
* included, is checked as always; and the address is still the one the
|
||||
* socket connects to.
|
||||
*/
|
||||
function lookupAllowing(privateHost: string): typeof guardedLookup {
|
||||
return (hostname, options, callback) => {
|
||||
@@ -236,6 +273,11 @@ function lookupAllowing(privateHost: string): typeof guardedLookup {
|
||||
cb(err);
|
||||
return;
|
||||
}
|
||||
const refused = records.find((record) => isBlockedIpAddress(record.address) && !isIntranetAddress(record.address));
|
||||
if (refused) {
|
||||
cb(new BlockedAddressError(hostname, refused.address));
|
||||
return;
|
||||
}
|
||||
if (opts.all) cb(null, records);
|
||||
else cb(null, records[0].address, records[0].family);
|
||||
})
|
||||
@@ -341,14 +383,17 @@ export async function fetchPublicUrl(
|
||||
init: Omit<UndiciRequestInit, 'dispatcher' | 'redirect'> = {},
|
||||
options: {
|
||||
/**
|
||||
* One hostname that may be a private (intranet) host: only for an
|
||||
* address the admin entered (company calendars). It must equal the
|
||||
* URL's hostname to make any difference.
|
||||
* One hostname on the intranet (isIntranetHostName) that may resolve to
|
||||
* an intranet address: only for an address the admin entered (company
|
||||
* calendars). It must equal the URL's hostname to make any difference;
|
||||
* a public name here changes nothing.
|
||||
*/
|
||||
allowPrivateHost?: string;
|
||||
} = {},
|
||||
): Promise<PublicFetchResponse> {
|
||||
const privateHost = options.allowPrivateHost ? normalizeHostname(options.allowPrivateHost) : undefined;
|
||||
const privateHost = options.allowPrivateHost && isIntranetHostName(options.allowPrivateHost)
|
||||
? normalizeHostname(options.allowPrivateHost)
|
||||
: undefined;
|
||||
const parsed = parseAllowedUrl(url, privateHost);
|
||||
if (!parsed) {
|
||||
throw new DisallowedUrlError(url);
|
||||
|
||||
Reference in New Issue
Block a user