From 72078d3bc1df564792d31b5cc00b8c195597c0e7 Mon Sep 17 00:00:00 2001 From: KernelDeimos <7225168+KernelDeimos@users.noreply.github.com> Date: Fri, 23 Jan 2026 07:29:19 -0500 Subject: [PATCH] wip: try to resolve DNS with cloudflare DNS --- .../src/services/drivers/CoercionService.js | 37 +-- .../src/services/drivers/FileFacade.js | 19 +- src/backend/src/util/securehttp.js | 277 ++++++++++++++++++ 3 files changed, 294 insertions(+), 39 deletions(-) create mode 100644 src/backend/src/util/securehttp.js diff --git a/src/backend/src/services/drivers/CoercionService.js b/src/backend/src/services/drivers/CoercionService.js index dfb1715d8..ceadd4923 100644 --- a/src/backend/src/services/drivers/CoercionService.js +++ b/src/backend/src/services/drivers/CoercionService.js @@ -20,6 +20,7 @@ const APIError = require('../../api/APIError'); const BaseService = require('../BaseService'); const { TypeSpec } = require('./meta/Construct'); const { TypedValue } = require('./meta/Runtime'); +const { secureAxiosRequest } = require('../../util/securehttp'); /** * CoercionService class is responsible for handling coercion operations @@ -65,19 +66,11 @@ class CoercionService extends BaseService { coerce: async typed_value => { console.debug('coercion is running!'); - const response = await (async () => { - try { - return await CoercionService.MODULES.axios.get(typed_value.value, { - responseType: 'stream', - }); - } catch (e) { - APIError.create('field_invalid', null, { - key: 'url', - expected: 'web URL', - got: `error during request: ${ e.message}`, - }); - } - })(); + const response = await secureAxiosRequest(CoercionService.MODULES.axios, + typed_value.value, + { + responseType: 'stream', + }); return new TypedValue({ $: 'stream', @@ -96,19 +89,11 @@ class CoercionService extends BaseService { content_type: 'video', }, coerce: async typed_value => { - const response = await (async () => { - try { - return await CoercionService.MODULES.axios.get(typed_value.value, { - responseType: 'stream', - }); - } catch (e) { - APIError.create('field_invalid', null, { - key: 'url', - expected: 'web URL', - got: `error during request: ${ e.message}`, - }); - } - })(); + const response = await secureAxiosRequest(CoercionService.MODULES.axios, + typed_value.value, + { + responseType: 'stream', + }); return new TypedValue({ $: 'stream', diff --git a/src/backend/src/services/drivers/FileFacade.js b/src/backend/src/services/drivers/FileFacade.js index afa28dfcc..67adb4f30 100644 --- a/src/backend/src/services/drivers/FileFacade.js +++ b/src/backend/src/services/drivers/FileFacade.js @@ -23,6 +23,7 @@ const { stream_to_buffer } = require('../../util/streamutil'); const { PassThrough } = require('stream'); const { LLRead } = require('../../filesystem/ll_operations/ll_read'); const APIError = require('../../api/APIError'); +const { secureAxiosRequest } = require('../../util/securehttp'); /** * @class FileFacade @@ -89,19 +90,11 @@ class FileFacade extends AdvancedBase { }); this.values.add_factory('stream', 'web_url', async web_url => { - const response = await (async () => { - try { - return await FileFacade.MODULES.axios.get(web_url, { - responseType: 'stream', - }); - } catch (e) { - throw APIError.create('field_invalid', null, { - key: 'url', - expected: 'web URL', - got: `error during request: ${ e.message}`, - }); - } - })(); + const response = await secureAxiosRequest(FileFacade.MODULES.axios, + web_url, + { + responseType: 'stream', + }); return response.data; }); diff --git a/src/backend/src/util/securehttp.js b/src/backend/src/util/securehttp.js new file mode 100644 index 000000000..8ae2f4cec --- /dev/null +++ b/src/backend/src/util/securehttp.js @@ -0,0 +1,277 @@ +/* + * Copyright (C) 2024-present Puter Technologies Inc. + * + * This file is part of Puter. + * + * Puter is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +const http = require('http'); +const https = require('https'); +const dns = require('dns'); +const { Resolver } = require('dns/promises'); +const net = require('net'); +const { URL } = require('url'); +const APIError = require('../api/APIError'); + +// Cloudflare's malware-blocking DNS server +const SECURE_DNS_SERVER = '1.1.1.3'; + +// Create a DNS resolver using 1.1.1.3 +let secureResolver = null; +function getSecureResolver () { + if ( ! secureResolver ) { + secureResolver = new Resolver(); + secureResolver.setServers([SECURE_DNS_SERVER]); + } + return secureResolver; +} + +/** + * Validates that a URL does not contain an IP address (IPv4 or IPv6). + * Only domain names are allowed to prevent SSRF attacks. + * @param {string} url - The URL to validate + * @throws {APIError} If the URL contains an IP address + */ +function validateUrlNoIP (url) { + let parsedUrl; + try { + parsedUrl = new URL(url); + } catch (e) { + throw APIError.create('field_invalid', null, { + key: 'url', + expected: 'valid URL', + got: `invalid URL format: ${e.message}`, + }); + } + + const hostname = parsedUrl.hostname; + + // Remove brackets from IPv6 addresses for validation + const hostnameForValidation = hostname.startsWith('[') && hostname.endsWith(']') + ? hostname.slice(1, -1) + : hostname; + + // Use Node.js's built-in IP validation (more reliable than regex) + const ipVersion = net.isIP(hostnameForValidation); + if ( ipVersion === 4 || ipVersion === 6 ) { + throw APIError.create('field_invalid', null, { + key: 'url', + expected: 'domain name (IP addresses not allowed)', + got: `IPv${ipVersion} address`, + }); + } + + // Additional check: reject localhost + if ( hostnameForValidation === 'localhost' ) { + throw APIError.create('field_invalid', null, { + key: 'url', + expected: 'domain name (localhost not allowed)', + got: 'localhost', + }); + } +} + +/** + * Creates a custom DNS lookup function that uses 1.1.1.3 for DNS resolution. + * This function resolves hostnames using Node.js's built-in Resolver with the secure DNS server. + * Falls back to system DNS if the secure resolver fails. + * @param {string} hostname - The hostname to resolve + * @param {Object} options - Lookup options + * @param {Function} callback - Callback function (err, address, family) + */ +function secureDNSLookup (hostname, options, callback) { + // First validate it's not an IP address (should have been validated already, but double-check) + const hostnameForValidation = hostname.startsWith('[') && hostname.endsWith(']') + ? hostname.slice(1, -1) + : hostname; + + // Use Node.js's built-in IP validation + const ipVersion = net.isIP(hostnameForValidation); + if ( ipVersion === 4 || ipVersion === 6 ) { + return callback(new Error('IP addresses not allowed')); + } + + // Use Resolver with 1.1.1.3 to resolve the hostname + const resolver = getSecureResolver(); + + // Try IPv4 first, then IPv6 + (async () => { + let resolverError = null; + try { + // Try IPv4 first + const records = await resolver.resolve4(hostname); + if ( records && records.length > 0 ) { + const ip = records[0]; + // Validate it's actually an IP address + if ( ip && typeof ip === 'string' && net.isIP(ip) === 4 ) { + console.log(`[securehttp] Resolved ${hostname} to ${ip} via 1.1.1.3 (IPv4)`); + return callback(null, ip, 4); + } + } + } catch ( err ) { + resolverError = err; + // If IPv4 fails, try IPv6 + try { + const records6 = await resolver.resolve6(hostname); + if ( records6 && records6.length > 0 ) { + const ip6 = records6[0]; + // Validate it's actually an IPv6 address + if ( ip6 && typeof ip6 === 'string' && net.isIP(ip6) === 6 ) { + console.log(`[securehttp] Resolved ${hostname} to ${ip6} via 1.1.1.3 (IPv6)`); + return callback(null, ip6, 6); + } + } + } catch ( err6 ) { + // Both IPv4 and IPv6 failed with secure resolver + console.warn(`[securehttp] Secure resolver (1.1.1.3) failed for ${hostname}: IPv4 error: ${resolverError.message}, IPv6 error: ${err6.message}, falling back to system DNS`); + } + } + + // Fallback to system DNS if secure resolver fails or returns no results + dns.lookup(hostname, options, (lookupErr, address, family) => { + if ( lookupErr ) { + // If both failed, return a comprehensive error + const errorMsg = resolverError + ? `DNS resolution failed: secure resolver (1.1.1.3) error: ${resolverError.message}, system DNS error: ${lookupErr.message}` + : `DNS resolution failed: ${lookupErr.message}`; + console.error(`[securehttp] Failed to resolve ${hostname}: ${errorMsg}`); + return callback(new Error(errorMsg)); + } + + // Validate the address before using it + if ( !address || typeof address !== 'string' ) { + const errorMsg = `System DNS returned invalid address for ${hostname}: ${address}`; + console.error(`[securehttp] ${errorMsg}`); + return callback(new Error(errorMsg)); + } + + // Validate it's actually an IP address + const ipVersion = net.isIP(address); + if ( ! ipVersion ) { + const errorMsg = `System DNS returned non-IP address for ${hostname}: ${address}`; + console.error(`[securehttp] ${errorMsg}`); + return callback(new Error(errorMsg)); + } + + console.log(`[securehttp] Resolved ${hostname} to ${address} via system DNS (IPv${ipVersion})`); + callback(null, address, family || ipVersion); + }); + })(); +} + +/** + * Creates secure HTTP and HTTPS agents with custom DNS lookup and no redirects. + * @returns {Object} Object containing httpAgent and httpsAgent + */ +function createSecureAgents () { + const httpAgent = new http.Agent({ + lookup: secureDNSLookup, + keepAlive: false, + }); + + const httpsAgent = new https.Agent({ + lookup: secureDNSLookup, + keepAlive: false, + }); + + return { httpAgent, httpsAgent }; +} + +/** + * Makes a secure HTTP request using axios with SSRF protections: + * - Validates URL does not contain IP addresses + * - Disables redirects + * - Uses secure DNS resolution (1.1.1.3) + * @param {Object} axios - The axios instance + * @param {string} url - The URL to request + * @param {Object} options - Additional axios options + * @returns {Promise} Axios response + */ +async function secureAxiosRequest (axios, url, options = {}) { + // Validate URL doesn't contain IP addresses + validateUrlNoIP(url); + + console.log(`[securehttp] Making secure request to ${url}`); + + // Create secure agents + const { httpAgent, httpsAgent } = createSecureAgents(); + + // Merge options with security settings + const secureOptions = { + ...options, + maxRedirects: 0, // Disable redirects - axios will return 3xx responses without following + httpAgent, + httpsAgent, + validateStatus: (status) => { + // Accept all status codes so we can check for redirects + return true; + }, + }; + + try { + const response = await axios.get(url, secureOptions); + + // Check if the response is a redirect (maxRedirects: 0 means axios returns but doesn't follow) + if ( response.status >= 300 && response.status < 400 ) { + throw APIError.create('field_invalid', null, { + key: 'url', + expected: 'web URL (redirects not allowed)', + got: `redirect to ${response.headers.location || 'unknown'}`, + }); + } + + console.log(`[securehttp] Successfully fetched ${url} (status: ${response.status})`); + return response; + } catch (e) { + // Re-throw APIError if it's already one (e.g., from validateUrlNoIP or redirect check) + if ( e instanceof APIError || (e.constructor && e.constructor.name === 'APIError') ) { + throw e; + } + + // Log the full error for debugging + console.error(`[securehttp] Request failed for ${url}:`, e); + + // Handle redirect errors in catch block (in case axios throws for redirects) + if ( e.response && (e.response.status === 301 || e.response.status === 302 || + e.response.status === 303 || e.response.status === 307 || e.response.status === 308) ) { + throw APIError.create('field_invalid', null, { + key: 'url', + expected: 'web URL (redirects not allowed)', + got: `redirect to ${e.response.headers.location || 'unknown'}`, + }); + } + + // Provide more detailed error messages + let errorMessage = e.message; + if ( e.code === 'ENOTFOUND' || e.code === 'EAI_AGAIN' ) { + errorMessage = `DNS resolution failed: ${e.message}`; + } else if ( e.code === 'ECONNREFUSED' ) { + errorMessage = `Connection refused: ${e.message}`; + } else if ( e.code === 'ETIMEDOUT' ) { + errorMessage = `Connection timeout: ${e.message}`; + } + + throw APIError.create('field_invalid', null, { + key: 'url', + expected: 'web URL', + got: errorMessage, + }); + } +} + +module.exports = { + validateUrlNoIP, + createSecureAgents, + secureAxiosRequest, +};