From 3c59a7ae01f7ea7094178cf961c3f4a668672f08 Mon Sep 17 00:00:00 2001 From: Hazelnoot Date: Tue, 8 Jul 2025 11:27:31 -0400 Subject: [PATCH] allow HTTP connections to private IPs --- .../backend/src/core/HttpRequestService.ts | 17 +++++++++--- packages/backend/src/core/UtilityService.ts | 8 +++--- .../test/unit/core/HttpRequestService.ts | 26 +++++++++++++++++-- 3 files changed, 42 insertions(+), 9 deletions(-) diff --git a/packages/backend/src/core/HttpRequestService.ts b/packages/backend/src/core/HttpRequestService.ts index 046b0dc244..bbc127fa86 100644 --- a/packages/backend/src/core/HttpRequestService.ts +++ b/packages/backend/src/core/HttpRequestService.ts @@ -20,7 +20,6 @@ import type { IObject, IObjectWithId } from '@/core/activitypub/type.js'; import { UtilityService } from '@/core/UtilityService.js'; import { ApUtilityService } from '@/core/activitypub/ApUtilityService.js'; import type { Response } from 'node-fetch'; -import type { URL } from 'node:url'; import type { Socket } from 'node:net'; export type HttpRequestSendOptions = { @@ -28,6 +27,15 @@ export type HttpRequestSendOptions = { validators?: ((res: Response) => void)[]; }; +export function isPrivateUrl(url: URL): boolean { + if (!ipaddr.isValid(url.hostname)) { + return false; + } + + const ip = ipaddr.parse(url.hostname); + return ip.range() !== 'unicast'; +} + export function isPrivateIp(allowedPrivateNetworks: PrivateNetwork[] | undefined, ip: string, port?: number): boolean { const parsedIp = ipaddr.parse(ip); @@ -303,6 +311,7 @@ export class HttpRequestService { timeout?: number, size?: number, isLocalAddressAllowed?: boolean, + allowHttp?: boolean, } = {}, extra: HttpRequestSendOptions = { throwErrorWhenResponseNotOk: true, @@ -311,7 +320,9 @@ export class HttpRequestService { ): Promise { const timeout = args.timeout ?? 5000; - this.utilityService.assertUrl(url); + const parsedUrl = new URL(url); + const allowHttp = args.allowHttp || isPrivateUrl(parsedUrl); + this.utilityService.assertUrl(parsedUrl, allowHttp); const controller = new AbortController(); setTimeout(() => { @@ -320,7 +331,7 @@ export class HttpRequestService { const isLocalAddressAllowed = args.isLocalAddressAllowed ?? false; - const res = await fetch(url, { + const res = await fetch(parsedUrl, { method: args.method ?? 'GET', headers: { 'User-Agent': this.config.userAgent, diff --git a/packages/backend/src/core/UtilityService.ts b/packages/backend/src/core/UtilityService.ts index 27d3cb7776..a90774cf59 100644 --- a/packages/backend/src/core/UtilityService.ts +++ b/packages/backend/src/core/UtilityService.ts @@ -226,7 +226,7 @@ export class UtilityService { * @throws {IdentifiableError} If URL contains credentials */ @bindThis - public assertUrl(url: string | URL): URL | never { + public assertUrl(url: string | URL, allowHttp?: boolean): URL | never { // If string, parse and validate if (typeof(url) === 'string') { try { @@ -237,7 +237,7 @@ export class UtilityService { } // Must be HTTPS - if (!this.checkHttps(url)) { + if (!this.checkHttps(url, allowHttp)) { throw new IdentifiableError('0bedd29b-e3bf-4604-af51-d3352e2518af', `invalid url ${url}: unsupported protocol ${url.protocol}`); } @@ -255,12 +255,12 @@ export class UtilityService { * Based on check-https.ts. */ @bindThis - public checkHttps(url: string | URL): boolean { + public checkHttps(url: string | URL, allowHttp = false): boolean { const isNonProd = this.envService.env.NODE_ENV !== 'production'; try { const proto = new URL(url).protocol; - return proto === 'https:' || (proto === 'http:' && isNonProd); + return proto === 'https:' || (proto === 'http:' && (isNonProd || allowHttp)); } catch { // Invalid URLs don't "count" as HTTPS return false; diff --git a/packages/backend/test/unit/core/HttpRequestService.ts b/packages/backend/test/unit/core/HttpRequestService.ts index a2f4604e7b..5aeeb1e26f 100644 --- a/packages/backend/test/unit/core/HttpRequestService.ts +++ b/packages/backend/test/unit/core/HttpRequestService.ts @@ -3,11 +3,11 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import { jest } from '@jest/globals'; +import { describe, 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 { HttpRequestService, isPrivateIp, isPrivateUrl, validateSocketConnect } from '@/core/HttpRequestService.js'; import { parsePrivateNetworks } from '@/config.js'; describe(HttpRequestService, () => { @@ -53,6 +53,28 @@ describe(HttpRequestService, () => { }); }); + describe('isPrivateUrl', () => { + it('should return false when URL is not an IP', () => { + const result = isPrivateUrl(new URL('https://example.com')); + expect(result).toBe(false); + }); + + it('should return false when IP is public', () => { + const result = isPrivateUrl(new URL('https://23.192.228.80')); + expect(result).toBe(false); + }); + + it('should return true when IP is private', () => { + const result = isPrivateUrl(new URL('https://127.0.0.1')); + expect(result).toBe(true); + }); + + it('should return true when IP is private with port and path', () => { + const result = isPrivateUrl(new URL('https://127.0.0.1:443/some/path')); + expect(result).toBe(true); + }); + }); + describe('validateSocketConnect', () => { let fakeSocket: Socket; let fakeSocketMutable: {