mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-09-02 21:58:06 +00:00
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:
@@ -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,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;
|
||||
|
||||
@@ -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 };
|
||||
|
||||
Reference in New Issue
Block a user