fix(security): harden authentication and outbound targets (#1877)

* fix(security): harden auth and outbound targets

* fix(security): prevent login lockout and honor trusted schemes
This commit is contained in:
Anso
2026-09-01 20:52:12 +00:00
committed by GitHub
parent 82dca29314
commit 79b86ddcd4
76 changed files with 1457 additions and 212 deletions
+211
View File
@@ -0,0 +1,211 @@
import dns, { promises as dnsPromises, type LookupAddress, type LookupAllOptions } from 'dns';
import http from 'http';
import https from 'https';
import net, { type LookupFunction } from 'net';
import { Agent as UndiciAgent, fetch as undiciFetch, type RequestInfo, type RequestInit, type Response } from 'undici';
const blockedIpv4 = new net.BlockList();
blockedIpv4.addSubnet('0.0.0.0', 8, 'ipv4');
blockedIpv4.addSubnet('127.0.0.0', 8, 'ipv4');
blockedIpv4.addSubnet('169.254.0.0', 16, 'ipv4');
blockedIpv4.addSubnet('192.0.0.0', 24, 'ipv4');
blockedIpv4.addSubnet('192.0.2.0', 24, 'ipv4');
blockedIpv4.addSubnet('192.88.99.0', 24, 'ipv4');
blockedIpv4.addSubnet('198.18.0.0', 15, 'ipv4');
blockedIpv4.addSubnet('198.51.100.0', 24, 'ipv4');
blockedIpv4.addSubnet('203.0.113.0', 24, 'ipv4');
blockedIpv4.addSubnet('224.0.0.0', 4, 'ipv4');
blockedIpv4.addSubnet('240.0.0.0', 4, 'ipv4');
blockedIpv4.addAddress('100.100.100.200', 'ipv4');
const blockedIpv6 = new net.BlockList();
blockedIpv6.addAddress('::', 'ipv6');
blockedIpv6.addAddress('::1', 'ipv6');
blockedIpv6.addSubnet('100::', 64, 'ipv6');
blockedIpv6.addSubnet('2001:db8::', 32, 'ipv6');
blockedIpv6.addSubnet('fe80::', 10, 'ipv6');
blockedIpv6.addSubnet('ff00::', 8, 'ipv6');
blockedIpv6.addAddress('fd00:ec2::254', 'ipv6');
const loopbackIpv4 = new net.BlockList();
loopbackIpv4.addSubnet('127.0.0.0', 8, 'ipv4');
export class UnsafeOutboundTargetError extends Error {
public readonly reason: 'blocked' | 'unresolved';
public readonly code = 'EACCES';
public constructor(reason: 'blocked' | 'unresolved') {
super(reason === 'blocked'
? 'The target address is not allowed.'
: 'The target host could not be resolved.');
this.name = 'UnsafeOutboundTargetError';
this.reason = reason;
}
}
export function isBlockedOutboundAddress(address: string): boolean {
const family = net.isIP(address);
if (family === 4) return blockedIpv4.check(address, 'ipv4');
if (family === 6) {
const mappedIpv4 = ipv4FromMappedIpv6(address);
return mappedIpv4
? blockedIpv4.check(mappedIpv4, 'ipv4')
: blockedIpv6.check(address, 'ipv6');
}
return true;
}
function isE2eLoopbackAllowed(address: string): boolean {
if (process.env.NODE_ENV !== 'test' || process.env.SENCHO_E2E_ALLOW_LOOPBACK_OUTBOUND !== 'true') {
return false;
}
if (net.isIPv4(address)) return loopbackIpv4.check(address, 'ipv4');
if (!net.isIPv6(address)) return false;
const mappedIpv4 = ipv4FromMappedIpv6(address);
return mappedIpv4
? loopbackIpv4.check(mappedIpv4, 'ipv4')
: address === '::1';
}
function isDisallowedOutboundAddress(address: string): boolean {
return isBlockedOutboundAddress(address) && !isE2eLoopbackAllowed(address);
}
function ipv4FromMappedIpv6(address: string): string | null {
const mapped = address.match(/^(?:::ffff:|0:0:0:0:0:ffff:)(.+)$/i)?.[1];
if (!mapped) return null;
if (net.isIPv4(mapped)) return mapped;
const words = mapped.split(':');
if (words.length !== 2 || words.some((word) => !/^[0-9a-f]{1,4}$/i.test(word))) return null;
const high = Number.parseInt(words[0], 16);
const low = Number.parseInt(words[1], 16);
return `${high >> 8}.${high & 0xff}.${low >> 8}.${low & 0xff}`;
}
function lookupHostname(url: URL): string {
return url.hostname.startsWith('[') && url.hostname.endsWith(']')
? url.hostname.slice(1, -1)
: url.hostname;
}
export async function assertSafeOutboundHostname(hostname: string): Promise<void> {
await resolveSafeOutboundHostname(hostname);
}
type ResolveAllAddresses = (hostname: string) => Promise<LookupAddress[]>;
type ResolvedOutboundAddresses = [LookupAddress, ...LookupAddress[]];
const systemResolveAllAddresses: ResolveAllAddresses = (hostname) =>
dnsPromises.lookup(hostname, { all: true, verbatim: true });
export async function resolveSafeOutboundHostname(
hostname: string,
resolveAllAddresses: ResolveAllAddresses = systemResolveAllAddresses,
): Promise<ResolvedOutboundAddresses> {
const normalizedHostname = hostname.startsWith('[') && hostname.endsWith(']')
? hostname.slice(1, -1)
: hostname;
if (net.isIP(normalizedHostname) !== 0) {
if (isDisallowedOutboundAddress(normalizedHostname)) throw new UnsafeOutboundTargetError('blocked');
return [{ address: normalizedHostname, family: net.isIPv4(normalizedHostname) ? 4 : 6 }];
}
let addresses: LookupAddress[];
try {
addresses = await resolveAllAddresses(normalizedHostname);
} catch {
throw new UnsafeOutboundTargetError('unresolved');
}
const [first, ...rest] = addresses;
if (!first) throw new UnsafeOutboundTargetError('unresolved');
if (addresses.some(({ address }) => isDisallowedOutboundAddress(address))) {
throw new UnsafeOutboundTargetError('blocked');
}
return [first, ...rest];
}
type LookupAllAddresses = (
hostname: string,
options: LookupAllOptions,
callback: (error: NodeJS.ErrnoException | null, addresses: LookupAddress[]) => void,
) => void;
const systemLookupAllAddresses: LookupAllAddresses = (hostname, options, callback) => {
dns.lookup(hostname, options, callback);
};
export function createSafeOutboundLookup(lookupAllAddresses: LookupAllAddresses): LookupFunction {
return (hostname, options, callback): void => lookupAllAddresses(hostname, { ...options, all: true }, (error, addresses) => {
if (error) {
callback(error, '', 0);
return;
}
if (!Array.isArray(addresses) || addresses.length === 0) {
callback(new UnsafeOutboundTargetError('unresolved'), '', 0);
return;
}
if (addresses.some(({ address }) => isDisallowedOutboundAddress(address))) {
callback(new UnsafeOutboundTargetError('blocked'), '', 0);
return;
}
if (options.all) {
callback(null, addresses);
return;
}
callback(null, addresses[0].address, addresses[0].family);
});
}
export const safeOutboundLookup = createSafeOutboundLookup(systemLookupAllAddresses);
export const safeHttpAgent = new http.Agent({ lookup: safeOutboundLookup });
export const safeHttpsAgent = new https.Agent({ lookup: safeOutboundLookup });
const safeFetchDispatcher = new UndiciAgent({ connect: { lookup: safeOutboundLookup } });
export function safeAxiosTransport(trustedLoopback = false): {
maxRedirects: number;
proxy: false;
httpAgent?: http.Agent;
httpsAgent?: https.Agent;
} {
return {
maxRedirects: 0,
proxy: false,
...(trustedLoopback ? {} : { httpAgent: safeHttpAgent, httpsAgent: safeHttpsAgent }),
};
}
export async function safeRemoteFetch(
input: RequestInfo,
init: RequestInit = {},
trustedLoopback = false,
): Promise<Response> {
if (!trustedLoopback) {
const raw = input instanceof URL
? input.toString()
: typeof input === 'string' ? input : input.url;
const host = lookupHostname(new URL(raw));
if (net.isIP(host) !== 0 && isDisallowedOutboundAddress(host)) {
throw new UnsafeOutboundTargetError('blocked');
}
}
try {
return await undiciFetch(input, {
...init,
...(trustedLoopback ? {} : { dispatcher: safeFetchDispatcher }),
redirect: 'error',
});
} catch (error: unknown) {
const cause = error instanceof Error ? error.cause : undefined;
if (cause instanceof UnsafeOutboundTargetError) throw cause;
throw error;
}
}
export async function assertSafeOutboundUrl(
raw: string,
): Promise<URL> {
const url = new URL(raw);
await assertSafeOutboundHostname(lookupHostname(url));
return url;
}
+9 -8
View File
@@ -9,6 +9,7 @@ import { FileSystemService } from '../services/FileSystemService';
import { NodeRegistry } from '../services/NodeRegistry';
import { formatNoTargetError } from './remoteTarget';
import { isDebugEnabled } from './debug';
import { safeRemoteFetch } from './outboundTarget';
// Presence map over every operator-authored dossier field. Typing it as
// Record<keyof StackDossierFields, true> makes the build fail if a field is
@@ -215,10 +216,10 @@ export async function captureRemoteNodeFiles(node: CaptureNode, captureDocs = fa
const headers: Record<string, string> = {};
if (target.apiToken) headers.Authorization = `Bearer ${target.apiToken}`;
const stacksRes = await fetch(`${baseUrl}/api/stacks`, {
const stacksRes = await safeRemoteFetch(`${baseUrl}/api/stacks`, {
headers,
signal: AbortSignal.timeout(15000),
});
}, target.trustedLoopback);
if (!stacksRes.ok) throw new Error('Failed to fetch stacks from remote node');
const stackNames = await stacksRes.json() as string[];
@@ -231,10 +232,10 @@ export async function captureRemoteNodeFiles(node: CaptureNode, captureDocs = fa
let composeContent: string;
try {
const composeRes = await fetch(`${baseUrl}/api/stacks/${encodeURIComponent(stackName)}`, {
const composeRes = await safeRemoteFetch(`${baseUrl}/api/stacks/${encodeURIComponent(stackName)}`, {
headers,
signal: AbortSignal.timeout(15000),
});
}, target.trustedLoopback);
if (!composeRes.ok) {
const reason = `compose.yaml fetch failed (HTTP ${composeRes.status}); stack skipped`;
console.warn(`[Fleet Snapshot] ${reason} ("${stackName}" on "${node.name}")`);
@@ -255,10 +256,10 @@ export async function captureRemoteNodeFiles(node: CaptureNode, captureDocs = fa
files.push({ filename: 'compose.yaml', content: composeContent });
try {
const envRes = await fetch(`${baseUrl}/api/stacks/${encodeURIComponent(stackName)}/env`, {
const envRes = await safeRemoteFetch(`${baseUrl}/api/stacks/${encodeURIComponent(stackName)}/env`, {
headers,
signal: AbortSignal.timeout(15000),
});
}, target.trustedLoopback);
// The remote replies 200 with an empty body and X-Env-Exists: false when a
// stack has no .env. Treat that as absent (matching the local ENOENT path)
// so restore does not write a spurious empty .env. An older remote that
@@ -280,10 +281,10 @@ export async function captureRemoteNodeFiles(node: CaptureNode, captureDocs = fa
let dossier: StackDossierFields | undefined;
if (captureDocs) {
try {
const dossierRes = await fetch(`${baseUrl}/api/stacks/${encodeURIComponent(stackName)}/dossier`, {
const dossierRes = await safeRemoteFetch(`${baseUrl}/api/stacks/${encodeURIComponent(stackName)}/dossier`, {
headers,
signal: AbortSignal.timeout(15000),
});
}, target.trustedLoopback);
if (dossierRes.ok) {
const fields = pickDossierFields(await dossierRes.json() as Record<string, unknown>);
if (dossierHasContent(fields)) dossier = fields;
+7 -4
View File
@@ -1,5 +1,7 @@
import path from 'path';
import net from 'net';
import { sanitizeForLog } from './safeLog';
import { isBlockedOutboundAddress } from './outboundTarget';
/**
* Stack name must only contain URL-safe characters with no path separators.
@@ -33,12 +35,13 @@ export function isValidRemoteUrl(
if (!['http:', 'https:'].includes(url.protocol)) {
return { valid: false, reason: 'API URL must use http:// or https://' };
}
// Node.js URL API preserves brackets for IPv6: new URL('http://[::1]').hostname === '[::1]'
const loopback = /^(localhost|127(\.\d+){3}|\[::1\]|0\.0\.0\.0)$/i;
if (loopback.test(url.hostname)) {
const hostname = url.hostname.startsWith('[') && url.hostname.endsWith(']')
? url.hostname.slice(1, -1)
: url.hostname;
if (hostname.toLowerCase() === 'localhost' || (net.isIP(hostname) !== 0 && isBlockedOutboundAddress(hostname))) {
return {
valid: false,
reason: 'API URL cannot point to localhost or loopback - use the actual host address',
reason: 'API URL target is not allowed',
};
}
return { valid: true, url };