allow private IP ranges to specify allowed ports

This commit is contained in:
Hazelnoot 2025-05-11 23:22:41 -04:00
parent 00cfeca3d7
commit fb63167d85
3 changed files with 161 additions and 43 deletions

View file

@ -8,9 +8,11 @@ import { fileURLToPath } from 'node:url';
import { dirname, resolve } from 'node:path'; import { dirname, resolve } from 'node:path';
import * as yaml from 'js-yaml'; import * as yaml from 'js-yaml';
import { globSync } from 'glob'; import { globSync } from 'glob';
import ipaddr from 'ipaddr.js';
import type * as Sentry from '@sentry/node'; import type * as Sentry from '@sentry/node';
import type * as SentryVue from '@sentry/vue'; import type * as SentryVue from '@sentry/vue';
import type { RedisOptions } from 'ioredis'; import type { RedisOptions } from 'ioredis';
import type { IPv4, IPv6 } from 'ipaddr.js';
type RedisOptionsSource = Partial<RedisOptions> & { type RedisOptionsSource = Partial<RedisOptions> & {
host?: string; host?: string;
@ -152,6 +154,33 @@ type Source = {
} }
}; };
export type PrivateNetwork = {
/**
* CIDR IP/netmask definition of the IP range to match.
*/
cidr: [ip: IPv4 | IPv6, mask: number];
/**
* List of ports to match.
* If undefined, then all ports match.
* If empty, then NO ports match.
*/
ports?: number[];
};
export function parsePrivateNetworks(patterns: string[]): PrivateNetwork[];
export function parsePrivateNetworks(patterns: undefined): undefined;
export function parsePrivateNetworks(patterns: string[] | undefined): PrivateNetwork[] | undefined;
export function parsePrivateNetworks(patterns: string[] | undefined): PrivateNetwork[] | undefined {
return patterns?.map(e => {
const [ip, ports] = e.split('#') as [string, ...(string | undefined)[]];
return {
cidr: ipaddr.parseCIDR(ip),
ports: ports?.split(',').map(p => parseInt(p)),
};
});
}
export type Config = { export type Config = {
url: string; url: string;
port: number; port: number;
@ -190,7 +219,7 @@ export type Config = {
proxy: string | undefined; proxy: string | undefined;
proxySmtp: string | undefined; proxySmtp: string | undefined;
proxyBypassHosts: string[] | undefined; proxyBypassHosts: string[] | undefined;
allowedPrivateNetworks: string[] | undefined; allowedPrivateNetworks: PrivateNetwork[] | undefined;
disallowExternalApRedirect: boolean; disallowExternalApRedirect: boolean;
maxFileSize: number; maxFileSize: number;
maxNoteLength: number; maxNoteLength: number;
@ -382,7 +411,7 @@ export function loadConfig(): Config {
proxy: config.proxy, proxy: config.proxy,
proxySmtp: config.proxySmtp, proxySmtp: config.proxySmtp,
proxyBypassHosts: config.proxyBypassHosts, proxyBypassHosts: config.proxyBypassHosts,
allowedPrivateNetworks: config.allowedPrivateNetworks, allowedPrivateNetworks: parsePrivateNetworks(config.allowedPrivateNetworks),
disallowExternalApRedirect: config.disallowExternalApRedirect ?? false, disallowExternalApRedirect: config.disallowExternalApRedirect ?? false,
maxFileSize: config.maxFileSize ?? 262144000, maxFileSize: config.maxFileSize ?? 262144000,
maxNoteLength: config.maxNoteLength ?? 3000, maxNoteLength: config.maxNoteLength ?? 3000,

View file

@ -12,7 +12,7 @@ import fetch from 'node-fetch';
import { HttpProxyAgent, HttpsProxyAgent } from 'hpagent'; import { HttpProxyAgent, HttpsProxyAgent } from 'hpagent';
import { Inject, Injectable } from '@nestjs/common'; import { Inject, Injectable } from '@nestjs/common';
import { DI } from '@/di-symbols.js'; import { DI } from '@/di-symbols.js';
import type { Config } from '@/config.js'; import type { Config, PrivateNetwork } from '@/config.js';
import { StatusError } from '@/misc/status-error.js'; import { StatusError } from '@/misc/status-error.js';
import { bindThis } from '@/decorators.js'; import { bindThis } from '@/decorators.js';
import { validateContentTypeSetAsActivityPub } from '@/core/activitypub/misc/validator.js'; import { validateContentTypeSetAsActivityPub } from '@/core/activitypub/misc/validator.js';
@ -20,12 +20,36 @@ import type { IObject, IObjectWithId } from '@/core/activitypub/type.js';
import { ApUtilityService } from './activitypub/ApUtilityService.js'; import { ApUtilityService } from './activitypub/ApUtilityService.js';
import type { Response } from 'node-fetch'; import type { Response } from 'node-fetch';
import type { URL } from 'node:url'; import type { URL } from 'node:url';
import type { Socket } from 'node:net';
export type HttpRequestSendOptions = { export type HttpRequestSendOptions = {
throwErrorWhenResponseNotOk: boolean; throwErrorWhenResponseNotOk: boolean;
validators?: ((res: Response) => void)[]; validators?: ((res: Response) => void)[];
}; };
export function isPrivateIp(allowedPrivateNetworks: PrivateNetwork[] | undefined, ip: string, port?: number): boolean {
const parsedIp = ipaddr.parse(ip);
for (const { cidr, ports } of allowedPrivateNetworks ?? []) {
if (cidr[0].kind() === parsedIp.kind() && parsedIp.match(cidr)) {
if (port == null || ports == null || ports.includes(port)) {
return false;
}
}
}
return parsedIp.range() !== 'unicast';
}
export function validateSocketConnect(allowedPrivateNetworks: PrivateNetwork[] | undefined, socket: Socket): void {
const address = socket.remoteAddress;
if (address && ipaddr.isValid(address)) {
if (isPrivateIp(allowedPrivateNetworks, address, socket.remotePort)) {
socket.destroy(new Error(`Blocked address: ${address}`));
}
}
}
declare module 'node:http' { declare module 'node:http' {
interface Agent { interface Agent {
createConnection(options: net.NetConnectOpts, callback?: (err: unknown, stream: net.Socket) => void): net.Socket; createConnection(options: net.NetConnectOpts, callback?: (err: unknown, stream: net.Socket) => void): net.Socket;
@ -44,31 +68,12 @@ class HttpRequestServiceAgent extends http.Agent {
public createConnection(options: net.NetConnectOpts, callback?: (err: unknown, stream: net.Socket) => void): net.Socket { public createConnection(options: net.NetConnectOpts, callback?: (err: unknown, stream: net.Socket) => void): net.Socket {
const socket = super.createConnection(options, callback) const socket = super.createConnection(options, callback)
.on('connect', () => { .on('connect', () => {
const address = socket.remoteAddress;
if (process.env.NODE_ENV === 'production') { if (process.env.NODE_ENV === 'production') {
if (address && ipaddr.isValid(address)) { validateSocketConnect(this.config.allowedPrivateNetworks, socket);
if (this.isPrivateIp(address)) {
socket.destroy(new Error(`Blocked address: ${address}`));
}
}
} }
}); });
return socket; return socket;
} }
@bindThis
private isPrivateIp(ip: string): boolean {
const parsedIp = ipaddr.parse(ip);
for (const net of this.config.allowedPrivateNetworks ?? []) {
const cidr = ipaddr.parseCIDR(net);
if (cidr[0].kind() === parsedIp.kind() && parsedIp.match(ipaddr.parseCIDR(net))) {
return false;
}
}
return parsedIp.range() !== 'unicast';
}
} }
class HttpsRequestServiceAgent extends https.Agent { class HttpsRequestServiceAgent extends https.Agent {
@ -83,31 +88,12 @@ class HttpsRequestServiceAgent extends https.Agent {
public createConnection(options: net.NetConnectOpts, callback?: (err: unknown, stream: net.Socket) => void): net.Socket { public createConnection(options: net.NetConnectOpts, callback?: (err: unknown, stream: net.Socket) => void): net.Socket {
const socket = super.createConnection(options, callback) const socket = super.createConnection(options, callback)
.on('connect', () => { .on('connect', () => {
const address = socket.remoteAddress;
if (process.env.NODE_ENV === 'production') { if (process.env.NODE_ENV === 'production') {
if (address && ipaddr.isValid(address)) { validateSocketConnect(this.config.allowedPrivateNetworks, socket);
if (this.isPrivateIp(address)) {
socket.destroy(new Error(`Blocked address: ${address}`));
}
}
} }
}); });
return socket; return socket;
} }
@bindThis
private isPrivateIp(ip: string): boolean {
const parsedIp = ipaddr.parse(ip);
for (const net of this.config.allowedPrivateNetworks ?? []) {
const cidr = ipaddr.parseCIDR(net);
if (cidr[0].kind() === parsedIp.kind() && parsedIp.match(ipaddr.parseCIDR(net))) {
return false;
}
}
return parsedIp.range() !== 'unicast';
}
} }
@Injectable() @Injectable()

View file

@ -0,0 +1,103 @@
/*
* SPDX-FileCopyrightText: hazelnoot and other Sharkey contributors
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { jest } from '@jest/globals';
import type { Mock } from 'jest-mock';
import type { PrivateNetwork } from '@/config.js';
import type { Socket } from 'net';
import { HttpRequestService, isPrivateIp, validateSocketConnect } from '@/core/HttpRequestService.js';
import { parsePrivateNetworks } from '@/config.js';
describe(HttpRequestService, () => {
let allowedPrivateNetworks: PrivateNetwork[] | undefined;
beforeEach(() => {
allowedPrivateNetworks = parsePrivateNetworks([
'10.0.0.1/32',
'127.0.0.1/32#1',
'127.0.0.1/32#3,4,5',
]);
});
describe('isPrivateIp', () => {
it('should return false when ip public', () => {
const result = isPrivateIp(allowedPrivateNetworks, '74.125.127.100', 80);
expect(result).toBeFalsy();
});
it('should return false when ip private and port matches', () => {
const result = isPrivateIp(allowedPrivateNetworks, '127.0.0.1', 1);
expect(result).toBeFalsy();
});
it('should return true when ip private and no ports specified', () => {
const result = isPrivateIp(allowedPrivateNetworks, '10.0.0.2', 80);
expect(result).toBeTruthy();
});
it('should return true when ip private and port does not match', () => {
const result = isPrivateIp(allowedPrivateNetworks, '127.0.0.1', 80);
expect(result).toBeTruthy();
});
});
describe('validateSocketConnect', () => {
let fakeSocket: Socket;
let fakeSocketMutable: {
remoteAddress: string | undefined;
remotePort: number | undefined;
destroy: Mock<(error?: Error) => void>;
};
beforeEach(() => {
fakeSocketMutable = {
remoteAddress: '74.125.127.100',
remotePort: 80,
destroy: jest.fn<(error?: Error) => void>(),
};
fakeSocket = fakeSocketMutable as unknown as Socket;
});
it('should accept when IP is empty', () => {
fakeSocketMutable.remoteAddress = undefined;
validateSocketConnect(allowedPrivateNetworks, fakeSocket);
expect(fakeSocket.destroy).not.toHaveBeenCalled();
});
it('should accept when IP is invalid', () => {
fakeSocketMutable.remoteAddress = 'AB939ajd9jdajsdja8jj';
validateSocketConnect(allowedPrivateNetworks, fakeSocket);
expect(fakeSocket.destroy).not.toHaveBeenCalled();
});
it('should accept when IP is valid', () => {
validateSocketConnect(allowedPrivateNetworks, fakeSocket);
expect(fakeSocket.destroy).not.toHaveBeenCalled();
});
it('should accept when IP is private and port match', () => {
fakeSocketMutable.remoteAddress = '127.0.0.1';
fakeSocketMutable.remotePort = 1;
validateSocketConnect(allowedPrivateNetworks, fakeSocket);
expect(fakeSocket.destroy).not.toHaveBeenCalled();
});
it('should reject when IP is private and port no match', () => {
fakeSocketMutable.remoteAddress = '127.0.0.1';
fakeSocketMutable.remotePort = 2;
validateSocketConnect(allowedPrivateNetworks, fakeSocket);
expect(fakeSocket.destroy).toHaveBeenCalled();
});
});
});