From c5a8b4e10f6d6010b63c02cd0d710eb3bbfdf39a Mon Sep 17 00:00:00 2001 From: yash-pouranik Date: Fri, 31 Jul 2026 03:34:02 +0530 Subject: [PATCH 01/11] feat(common): dual-server db validation for byod config --- .../src/controllers/project.controller.js | 48 +++++++++++++++-- apps/public-api/src/app.js | 3 ++ apps/public-api/src/routes/internal.js | 53 +++++++++++++++++++ .../Settings/DatabaseConfigForm.jsx | 13 ++++- 4 files changed, 111 insertions(+), 6 deletions(-) create mode 100644 apps/public-api/src/routes/internal.js diff --git a/apps/dashboard-api/src/controllers/project.controller.js b/apps/dashboard-api/src/controllers/project.controller.js index 2e544aee6..0a976c2b9 100644 --- a/apps/dashboard-api/src/controllers/project.controller.js +++ b/apps/dashboard-api/src/controllers/project.controller.js @@ -723,22 +723,62 @@ module.exports.updateExternalConfig = async (req, res) => { updateData["resources.db.isExternal"] = true; console.log("Verifying connection to:", projectId); + + let dashboardIp = null; + let publicApiIp = null; + try { + dashboardIp = await getPublicIp(); + + // 1. Test local connection (Dashboard API) const tempConn = mongoose.createConnection(dbUri, { serverSelectionTimeoutMS: 5000, }); await tempConn.asPromise(); await tempConn.close(); + + // 2. Test remote connection (Public API) if PUBLIC_API_URL is configured + if (process.env.PUBLIC_API_URL && process.env.INTERNAL_SECRET) { + const publicApiRes = await axios.post(`${process.env.PUBLIC_API_URL}/api/internal/test-db`, + { dbUri }, + { headers: { 'x-internal-secret': process.env.INTERNAL_SECRET }, timeout: 8000 } + ); + // If it didn't throw, it succeeded. + } else { + console.warn("Skipping Public API DB verification because PUBLIC_API_URL or INTERNAL_SECRET is missing."); + } + } catch (connErr) { console.error("Verification Connection Failed:", connErr.message); let errorMsg = "Could not connect to the provided MongoDB URI."; - - if ( + + // If the error came from Axios (Public API verification failed) + if (connErr.response && connErr.response.data && !connErr.response.data.success) { + errorMsg = connErr.response.data.message; + if (connErr.response.data.serverIp) { + publicApiIp = connErr.response.data.serverIp; + } + } else if ( connErr.message.includes("Server selection timed out") || connErr.message.includes("Could not connect") ) { - const serverIp = await getPublicIp(); - errorMsg = `Access Denied: Please whitelist Server IP [${serverIp}] in MongoDB Atlas.`; + // Local failure + errorMsg = `Access Denied: Please whitelist Server IPs in MongoDB Atlas.`; + } + + // Fetch Public API IP if we don't have it yet, to show both in the error message + if (!publicApiIp && process.env.PUBLIC_API_URL) { + try { + const ipRes = await axios.get(`${process.env.PUBLIC_API_URL}/api/server-ip`, { timeout: 3000 }); + if (ipRes.data && ipRes.data.ip) publicApiIp = ipRes.data.ip; + } catch (e) { + console.error("Failed to fetch public API IP for error message", e.message); + } + } + + if (dashboardIp || publicApiIp) { + const ips = [dashboardIp, publicApiIp].filter(Boolean).join(", "); + errorMsg = `Access Denied: Please whitelist Server IPs [${ips}] in MongoDB Atlas.`; } return res.status(400).json({ success: false, data: {}, message: errorMsg }); diff --git a/apps/public-api/src/app.js b/apps/public-api/src/app.js index 8c5ea76d8..951ea7327 100644 --- a/apps/public-api/src/app.js +++ b/apps/public-api/src/app.js @@ -79,6 +79,9 @@ app.use('/api/storage', limiter, logger, storageRoute); app.use('/api/mail', limiter, logger, mailRoute); app.use('/api/health', limiter, logger, healthRoute); +const internalRoute = require('./routes/internal'); +app.use('/api/internal', internalRoute); + app.get('/api/server-ip', async (req, res) => { const ip = await getPublicIp(); res.json({ ip }); diff --git a/apps/public-api/src/routes/internal.js b/apps/public-api/src/routes/internal.js new file mode 100644 index 000000000..dd532f083 --- /dev/null +++ b/apps/public-api/src/routes/internal.js @@ -0,0 +1,53 @@ +const express = require('express'); +const mongoose = require('mongoose'); +const { getPublicIp } = require('@urbackend/common'); +const router = express.Router(); + +// Middleware to protect internal routes +const internalAuth = (req, res, next) => { + const secret = process.env.INTERNAL_SECRET; + if (!secret) { + console.error("INTERNAL_SECRET is not configured on Public API"); + return res.status(500).json({ success: false, message: "Server misconfiguration" }); + } + + const providedSecret = req.headers['x-internal-secret']; + if (!providedSecret || providedSecret !== secret) { + return res.status(403).json({ success: false, message: "Forbidden: Invalid internal secret" }); + } + + next(); +}; + +router.post('/test-db', internalAuth, async (req, res) => { + const { dbUri } = req.body; + if (!dbUri) { + return res.status(400).json({ success: false, message: "dbUri is required" }); + } + + console.log("[Internal] Verifying connection for external DB..."); + try { + const tempConn = mongoose.createConnection(dbUri, { + serverSelectionTimeoutMS: 5000, + }); + await tempConn.asPromise(); + await tempConn.close(); + return res.status(200).json({ success: true, message: "Connection verified" }); + } catch (connErr) { + console.error("[Internal] Verification Connection Failed:", connErr.message); + let errorMsg = "Could not connect to the provided MongoDB URI."; + let serverIp = null; + + if ( + connErr.message.includes("Server selection timed out") || + connErr.message.includes("Could not connect") + ) { + serverIp = await getPublicIp(); + errorMsg = `Access Denied: Please whitelist Server IP [${serverIp}] in MongoDB Atlas.`; + } + + return res.status(400).json({ success: false, message: errorMsg, serverIp }); + } +}); + +module.exports = router; diff --git a/apps/web-dashboard/src/components/Settings/DatabaseConfigForm.jsx b/apps/web-dashboard/src/components/Settings/DatabaseConfigForm.jsx index a47f6e763..435315573 100644 --- a/apps/web-dashboard/src/components/Settings/DatabaseConfigForm.jsx +++ b/apps/web-dashboard/src/components/Settings/DatabaseConfigForm.jsx @@ -20,8 +20,17 @@ export default function DatabaseConfigForm({ project, projectId, onProjectUpdate Promise.resolve().then(() => setShowForm(!(project?.resources?.db?.isExternal || false))); const fetchIp = async () => { try { - const res = await api.get(`/api/server-ip`); - if (isMounted) setServerIp(res.data.ip); + const { PUBLIC_API_URL } = await import('../../config'); + const [res1, res2] = await Promise.allSettled([ + api.get(`/api/server-ip`), + fetch(`${PUBLIC_API_URL}/api/server-ip`).then(r => r.json()) + ]); + + const ips = []; + if (res1.status === 'fulfilled' && res1.value.data.ip) ips.push(res1.value.data.ip); + if (res2.status === 'fulfilled' && res2.value.ip) ips.push(res2.value.ip); + + if (isMounted) setServerIp(ips.join(', ')); } catch (e) { console.error("Failed to fetch server IP", e); } }; From e7c95aa16c23d2178682f67eddf31c31078979d9 Mon Sep 17 00:00:00 2001 From: yash-pouranik Date: Sun, 2 Aug 2026 14:04:06 +0530 Subject: [PATCH 02/11] fix: address CodeRabbit feedback for DB validation --- .../src/controllers/project.controller.js | 187 +++++------------- apps/public-api/src/app.js | 3 + apps/public-api/src/routes/internal.js | 46 +++-- .../Settings/DatabaseConfigForm.jsx | 10 +- packages/common/src/index.js | 3 +- packages/common/src/utils/network.js | 122 +++++++++++- 6 files changed, 215 insertions(+), 156 deletions(-) diff --git a/apps/dashboard-api/src/controllers/project.controller.js b/apps/dashboard-api/src/controllers/project.controller.js index 0a976c2b9..29a415adc 100644 --- a/apps/dashboard-api/src/controllers/project.controller.js +++ b/apps/dashboard-api/src/controllers/project.controller.js @@ -22,7 +22,7 @@ const { encrypt, decrypt } = require("@urbackend/common"); const { URL } = require("url"); const path = require("path"); const axios = require("axios"); -const { getConnection } = require("@urbackend/common"); +const { getConnection, isSafeUri } = require("@urbackend/common"); const { getCompiledModel } = require("@urbackend/common"); const { QueryEngine } = require("@urbackend/common"); const { storageRegistry } = require("@urbackend/common"); @@ -585,123 +585,6 @@ const dropCollectionIfExists = async (connection, collectionName) => { } }; -/** - * Convert a dotted-decimal IPv4 address string to a 32-bit unsigned integer. - * @param {string} ip - IPv4 address in dotted-decimal notation (e.g., '192.168.1.1') - * @returns {number} 32-bit unsigned integer representation of the IPv4 address - */ -function ipv4ToInt(ip) { - return ip.split(".").reduce((acc, octet) => (acc << 8) + parseInt(octet, 10), 0) >>> 0; -} - -/** - * Check if an IPv4 address falls within any restricted or reserved IP range. - * Blocks loopback (127.0.0.0/8), RFC-1918 private ranges (10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16), - * link-local and cloud metadata (169.254.0.0/16), and unspecified (0.0.0.0/8) addresses to prevent SSRF attacks. - * @param {string} ip - IPv4 address to validate - * @returns {boolean} True if the IP is restricted, false otherwise - */ -function isRestrictedIPv4(ip) { - const n = ipv4ToInt(ip); - // Loopback 127.0.0.0/8 - if (n >= ipv4ToInt("127.0.0.0") && n <= ipv4ToInt("127.255.255.255")) return true; - // RFC-1918: 10.0.0.0/8 - if (n >= ipv4ToInt("10.0.0.0") && n <= ipv4ToInt("10.255.255.255")) return true; - // RFC-1918: 172.16.0.0/12 - if (n >= ipv4ToInt("172.16.0.0") && n <= ipv4ToInt("172.31.255.255")) return true; - // RFC-1918: 192.168.0.0/16 - if (n >= ipv4ToInt("192.168.0.0") && n <= ipv4ToInt("192.168.255.255")) return true; - // Link-local / cloud instance metadata (AWS, GCP, Azure): 169.254.0.0/16 - if (n >= ipv4ToInt("169.254.0.0") && n <= ipv4ToInt("169.254.255.255")) return true; - // Unspecified: 0.0.0.0/8 - if (n >= ipv4ToInt("0.0.0.0") && n <= ipv4ToInt("0.255.255.255")) return true; - return false; -} - -/** - * Check if an IPv6 address falls within any restricted or reserved range. - * Blocks loopback (::1), unspecified (::), link-local (fe80::/10), IPv6 Unique Local Addresses (fc00::/7), - * and IPv4-mapped IPv6 addresses that resolve to restricted IPv4 ranges to prevent SSRF attacks. - * @param {string} ip - IPv6 address to validate - * @returns {boolean} True if the IP is restricted, false otherwise - */ -function isRestrictedIPv6(ip) { - const expanded = ip.replace(/^\[|\]$/g, "").toLowerCase(); - // IPv6 loopback ::1 - if (expanded === "::1" || expanded === "0:0:0:0:0:0:0:1") return true; - // IPv6 unspecified :: - if (expanded === "::" || expanded === "0:0:0:0:0:0:0:0") return true; - // IPv4-mapped IPv6 addresses (::ffff:x.x.x.x) - const mapped = expanded.match(/^::ffff:(\d+\.\d+\.\d+\.\d+)$/); - if (mapped && isRestrictedIPv4(mapped[1])) return true; - // IPv6 link-local fe80::/10 (fe80: through febf:) - if (/^fe[89ab]/.test(expanded)) return true; - // IPv6 ULA (Unique Local Address) fc00::/7 - if (expanded.startsWith("fc") || expanded.startsWith("fd")) return true; - return false; -} - -/** - * Check if an IP address (either IPv4 or IPv6) is restricted or falls within a reserved range. - * Delegates to isRestrictedIPv4() or isRestrictedIPv6() based on the IP version. - * @param {string} ip - IP address to validate - * @returns {boolean} True if the IP is restricted, false otherwise - */ -function isRestrictedIP(ip) { - if (net.isIPv4(ip)) return isRestrictedIPv4(ip); - if (net.isIPv6(ip)) return isRestrictedIPv6(ip); - return false; -} - -/** - * Validate a URI to ensure it does not target restricted hosts or IP ranges (SSRF prevention). - * Performs DNS resolution on hostnames to check both A and AAAA records against restricted ranges. - * Blocks loopback, RFC-1918 private ranges, cloud metadata endpoints, and other reserved IP ranges. - * @async - * @param {string} uri - The URI to validate - * @returns {Promise} True if the URI is safe to connect to, false if it targets a restricted host - */ -const isSafeUri = async (uri) => { - try { - const parsed = new URL(uri); - const host = parsed.hostname.replace(/^\[|\]$/g, "").toLowerCase(); - - // Block well-known loopback and internal hostnames - const blockedHostnames = ["localhost", "metadata.google.internal"]; - if (blockedHostnames.includes(host)) return false; - - // Reject mongodb+srv:// URIs as they perform hidden SRV/TXT discovery - // We cannot safely validate all targets without resolving SRV records - if (uri.toLowerCase().includes("mongodb+srv://")) return false; - - // If the host is a bare IPv4 or IPv6 address, check all restricted ranges - if (net.isIPv4(host) || net.isIPv6(host)) { - return !isRestrictedIP(host); - } - - // For hostnames, perform DNS resolution to check both A and AAAA records - const [ipv4Result, ipv6Result] = await Promise.allSettled([ - dns.resolve4(host), - dns.resolve6(host), - ]); - - const resolved = [ - ...(ipv4Result.status === "fulfilled" ? ipv4Result.value : []), - ...(ipv6Result.status === "fulfilled" ? ipv6Result.value : []), - ]; - - // If no addresses resolved, treat as unsafe - if (resolved.length === 0) return false; - - // Check all resolved addresses for restricted ranges - if (resolved.some((addr) => isRestrictedIP(addr))) return false; - - return true; - } catch (e) { - return false; - } -}; - module.exports.updateExternalConfig = async (req, res) => { try { const { projectId } = req.params; @@ -726,48 +609,66 @@ module.exports.updateExternalConfig = async (req, res) => { let dashboardIp = null; let publicApiIp = null; + let tempConn = null; try { - dashboardIp = await getPublicIp(); - - // 1. Test local connection (Dashboard API) - const tempConn = mongoose.createConnection(dbUri, { - serverSelectionTimeoutMS: 5000, - }); - await tempConn.asPromise(); - await tempConn.close(); + await Promise.race([ + new Promise((_, reject) => setTimeout(() => reject(new Error("Overall verification timeout exceeded")), 9500)), + (async () => { + // 1. Test local connection (Dashboard API) + tempConn = mongoose.createConnection(dbUri, { + serverSelectionTimeoutMS: 5000, + }); + await tempConn.asPromise(); - // 2. Test remote connection (Public API) if PUBLIC_API_URL is configured - if (process.env.PUBLIC_API_URL && process.env.INTERNAL_SECRET) { - const publicApiRes = await axios.post(`${process.env.PUBLIC_API_URL}/api/internal/test-db`, + // 2. Test remote connection (Public API) + if (!process.env.PUBLIC_API_URL || !process.env.INTERNAL_SECRET) { + throw new Error("Missing PUBLIC_API_URL or INTERNAL_SECRET for DB verification."); + } + + await axios.post(`${process.env.PUBLIC_API_URL}/api/internal/test-db`, { dbUri }, { headers: { 'x-internal-secret': process.env.INTERNAL_SECRET }, timeout: 8000 } ); - // If it didn't throw, it succeeded. - } else { - console.warn("Skipping Public API DB verification because PUBLIC_API_URL or INTERNAL_SECRET is missing."); - } - + })() + ]); } catch (connErr) { console.error("Verification Connection Failed:", connErr.message); let errorMsg = "Could not connect to the provided MongoDB URI."; + try { + dashboardIp = await getPublicIp(); + } catch (ipErr) { + console.error("Failed to fetch Dashboard API IP:", ipErr.message); + } + // If the error came from Axios (Public API verification failed) - if (connErr.response && connErr.response.data && !connErr.response.data.success) { - errorMsg = connErr.response.data.message; - if (connErr.response.data.serverIp) { - publicApiIp = connErr.response.data.serverIp; + if (connErr.response) { + if (connErr.response.data && connErr.response.data.success === false && connErr.response.status === 400) { + // It's a database-URI-specific verification failure + errorMsg = connErr.response.data.message; + if (connErr.response.data.data && connErr.response.data.data.serverIp) { + publicApiIp = connErr.response.data.data.serverIp; + } else if (connErr.response.data.serverIp) { + publicApiIp = connErr.response.data.serverIp; + } + } else { + // Infrastructure error + errorMsg = "Public API verification failed."; } } else if ( connErr.message.includes("Server selection timed out") || - connErr.message.includes("Could not connect") + connErr.message.includes("Could not connect") || + connErr.message.includes("Overall verification timeout exceeded") ) { // Local failure errorMsg = `Access Denied: Please whitelist Server IPs in MongoDB Atlas.`; + } else if (connErr.message.includes("Missing PUBLIC_API_URL")) { + return res.status(500).json({ success: false, data: {}, message: "Server misconfiguration: DB verification failed." }); } // Fetch Public API IP if we don't have it yet, to show both in the error message - if (!publicApiIp && process.env.PUBLIC_API_URL) { + if (!publicApiIp && process.env.PUBLIC_API_URL && errorMsg.includes("whitelist Server IPs")) { try { const ipRes = await axios.get(`${process.env.PUBLIC_API_URL}/api/server-ip`, { timeout: 3000 }); if (ipRes.data && ipRes.data.ip) publicApiIp = ipRes.data.ip; @@ -776,12 +677,16 @@ module.exports.updateExternalConfig = async (req, res) => { } } - if (dashboardIp || publicApiIp) { + if (errorMsg.includes("whitelist Server IPs") && (dashboardIp || publicApiIp)) { const ips = [dashboardIp, publicApiIp].filter(Boolean).join(", "); errorMsg = `Access Denied: Please whitelist Server IPs [${ips}] in MongoDB Atlas.`; } return res.status(400).json({ success: false, data: {}, message: errorMsg }); + } finally { + if (tempConn) { + await tempConn.close(); + } } } diff --git a/apps/public-api/src/app.js b/apps/public-api/src/app.js index 951ea7327..4612651e4 100644 --- a/apps/public-api/src/app.js +++ b/apps/public-api/src/app.js @@ -79,6 +79,9 @@ app.use('/api/storage', limiter, logger, storageRoute); app.use('/api/mail', limiter, logger, mailRoute); app.use('/api/health', limiter, logger, healthRoute); +// IMPORTANT: Restrict the /api/internal route exposed below to trusted internal network sources +// at the infrastructure layer (e.g., VPC, Security Groups, or ingress rules). +// Ensure this path is unreachable from the public internet. const internalRoute = require('./routes/internal'); app.use('/api/internal', internalRoute); diff --git a/apps/public-api/src/routes/internal.js b/apps/public-api/src/routes/internal.js index dd532f083..bd5cd9ffa 100644 --- a/apps/public-api/src/routes/internal.js +++ b/apps/public-api/src/routes/internal.js @@ -1,6 +1,7 @@ const express = require('express'); const mongoose = require('mongoose'); -const { getPublicIp } = require('@urbackend/common'); +const crypto = require('crypto'); +const { getPublicIp, isSafeUri } = require('@urbackend/common'); const router = express.Router(); // Middleware to protect internal routes @@ -8,12 +9,17 @@ const internalAuth = (req, res, next) => { const secret = process.env.INTERNAL_SECRET; if (!secret) { console.error("INTERNAL_SECRET is not configured on Public API"); - return res.status(500).json({ success: false, message: "Server misconfiguration" }); + return res.status(500).json({ success: false, data: {}, message: "Server misconfiguration" }); } - const providedSecret = req.headers['x-internal-secret']; - if (!providedSecret || providedSecret !== secret) { - return res.status(403).json({ success: false, message: "Forbidden: Invalid internal secret" }); + const providedSecret = req.headers['x-internal-secret'] || ''; + + // Convert both to buffers of the same length to use timingSafeEqual + const secretBuffer = Buffer.from(secret, 'utf8'); + const providedBuffer = Buffer.from(providedSecret, 'utf8'); + + if (secretBuffer.length !== providedBuffer.length || !crypto.timingSafeEqual(secretBuffer, providedBuffer)) { + return res.status(403).json({ success: false, data: {}, message: "Forbidden: Invalid internal secret" }); } next(); @@ -22,17 +28,25 @@ const internalAuth = (req, res, next) => { router.post('/test-db', internalAuth, async (req, res) => { const { dbUri } = req.body; if (!dbUri) { - return res.status(400).json({ success: false, message: "dbUri is required" }); + return res.status(400).json({ success: false, data: {}, message: "dbUri is required" }); + } + + if (!(await isSafeUri(dbUri))) { + return res.status(400).json({ + success: false, + data: {}, + message: "DB URI is pointing to a restricted host, internal network, or unsupported URI format.", + }); } console.log("[Internal] Verifying connection for external DB..."); + let tempConn = null; try { - const tempConn = mongoose.createConnection(dbUri, { + tempConn = mongoose.createConnection(dbUri, { serverSelectionTimeoutMS: 5000, }); await tempConn.asPromise(); - await tempConn.close(); - return res.status(200).json({ success: true, message: "Connection verified" }); + return res.status(200).json({ success: true, data: {}, message: "Connection verified" }); } catch (connErr) { console.error("[Internal] Verification Connection Failed:", connErr.message); let errorMsg = "Could not connect to the provided MongoDB URI."; @@ -42,11 +56,19 @@ router.post('/test-db', internalAuth, async (req, res) => { connErr.message.includes("Server selection timed out") || connErr.message.includes("Could not connect") ) { - serverIp = await getPublicIp(); - errorMsg = `Access Denied: Please whitelist Server IP [${serverIp}] in MongoDB Atlas.`; + try { + serverIp = await getPublicIp(); + errorMsg = `Access Denied: Please whitelist Server IP [${serverIp}] in MongoDB Atlas.`; + } catch (ipErr) { + console.error("Failed to fetch Public API IP:", ipErr.message); + } } - return res.status(400).json({ success: false, message: errorMsg, serverIp }); + return res.status(400).json({ success: false, data: { serverIp }, message: errorMsg }); + } finally { + if (tempConn) { + await tempConn.close(); + } } }); diff --git a/apps/web-dashboard/src/components/Settings/DatabaseConfigForm.jsx b/apps/web-dashboard/src/components/Settings/DatabaseConfigForm.jsx index 435315573..b52fba4ac 100644 --- a/apps/web-dashboard/src/components/Settings/DatabaseConfigForm.jsx +++ b/apps/web-dashboard/src/components/Settings/DatabaseConfigForm.jsx @@ -21,9 +21,17 @@ export default function DatabaseConfigForm({ project, projectId, onProjectUpdate const fetchIp = async () => { try { const { PUBLIC_API_URL } = await import('../../config'); + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), 3000); + const [res1, res2] = await Promise.allSettled([ api.get(`/api/server-ip`), - fetch(`${PUBLIC_API_URL}/api/server-ip`).then(r => r.json()) + fetch(`${PUBLIC_API_URL}/api/server-ip`, { signal: controller.signal }) + .then(r => { + if (!r.ok) throw new Error("Public API unresponsive"); + return r.json(); + }) + .finally(() => clearTimeout(timeoutId)) ]); const ips = []; diff --git a/packages/common/src/index.js b/packages/common/src/index.js index 7ed9ba17a..64c359e16 100644 --- a/packages/common/src/index.js +++ b/packages/common/src/index.js @@ -107,7 +107,7 @@ const { clearCompiledModel, createUniqueIndexes, } = require("./utils/injectModel"); -const { getPublicIp } = require("./utils/network"); +const { getPublicIp, isSafeUri } = require("./utils/network"); const { isProjectStorageExternal, isProjectDbExternal, @@ -198,6 +198,7 @@ module.exports = { clearCompiledModel, createUniqueIndexes, getPublicIp, + isSafeUri, isProjectStorageExternal, isProjectDbExternal, getBucket, diff --git a/packages/common/src/utils/network.js b/packages/common/src/utils/network.js index b47129b89..e62677b9a 100644 --- a/packages/common/src/utils/network.js +++ b/packages/common/src/utils/network.js @@ -1,3 +1,6 @@ +const dns = require('dns').promises; +const net = require('net'); + let cachedIp = null; let lastFetch = 0; @@ -27,4 +30,121 @@ async function getPublicIp() { } } -module.exports = { getPublicIp }; +/** + * Convert a dotted-decimal IPv4 address string to a 32-bit unsigned integer. + * @param {string} ip - IPv4 address in dotted-decimal notation (e.g., '192.168.1.1') + * @returns {number} 32-bit unsigned integer representation of the IPv4 address + */ +function ipv4ToInt(ip) { + return ip.split(".").reduce((acc, octet) => (acc << 8) + parseInt(octet, 10), 0) >>> 0; +} + +/** + * Check if an IPv4 address falls within any restricted or reserved IP range. + * Blocks loopback (127.0.0.0/8), RFC-1918 private ranges (10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16), + * link-local and cloud metadata (169.254.0.0/16), and unspecified (0.0.0.0/8) addresses to prevent SSRF attacks. + * @param {string} ip - IPv4 address to validate + * @returns {boolean} True if the IP is restricted, false otherwise + */ +function isRestrictedIPv4(ip) { + const n = ipv4ToInt(ip); + // Loopback 127.0.0.0/8 + if (n >= ipv4ToInt("127.0.0.0") && n <= ipv4ToInt("127.255.255.255")) return true; + // RFC-1918: 10.0.0.0/8 + if (n >= ipv4ToInt("10.0.0.0") && n <= ipv4ToInt("10.255.255.255")) return true; + // RFC-1918: 172.16.0.0/12 + if (n >= ipv4ToInt("172.16.0.0") && n <= ipv4ToInt("172.31.255.255")) return true; + // RFC-1918: 192.168.0.0/16 + if (n >= ipv4ToInt("192.168.0.0") && n <= ipv4ToInt("192.168.255.255")) return true; + // Link-local / cloud instance metadata (AWS, GCP, Azure): 169.254.0.0/16 + if (n >= ipv4ToInt("169.254.0.0") && n <= ipv4ToInt("169.254.255.255")) return true; + // Unspecified: 0.0.0.0/8 + if (n >= ipv4ToInt("0.0.0.0") && n <= ipv4ToInt("0.255.255.255")) return true; + return false; +} + +/** + * Check if an IPv6 address falls within any restricted or reserved range. + * Blocks loopback (::1), unspecified (::), link-local (fe80::/10), IPv6 Unique Local Addresses (fc00::/7), + * and IPv4-mapped IPv6 addresses that resolve to restricted IPv4 ranges to prevent SSRF attacks. + * @param {string} ip - IPv6 address to validate + * @returns {boolean} True if the IP is restricted, false otherwise + */ +function isRestrictedIPv6(ip) { + const expanded = ip.replace(/^\[|\]$/g, "").toLowerCase(); + // IPv6 loopback ::1 + if (expanded === "::1" || expanded === "0:0:0:0:0:0:0:1") return true; + // IPv6 unspecified :: + if (expanded === "::" || expanded === "0:0:0:0:0:0:0:0") return true; + // IPv4-mapped IPv6 addresses (::ffff:x.x.x.x) + const mapped = expanded.match(/^::ffff:(\d+\.\d+\.\d+\.\d+)$/); + if (mapped && isRestrictedIPv4(mapped[1])) return true; + // IPv6 link-local fe80::/10 (fe80: through febf:) + if (/^fe[89ab]/.test(expanded)) return true; + // IPv6 ULA (Unique Local Address) fc00::/7 + if (expanded.startsWith("fc") || expanded.startsWith("fd")) return true; + return false; +} + +/** + * Check if an IP address (either IPv4 or IPv6) is restricted or falls within a reserved range. + * Delegates to isRestrictedIPv4() or isRestrictedIPv6() based on the IP version. + * @param {string} ip - IP address to validate + * @returns {boolean} True if the IP is restricted, false otherwise + */ +function isRestrictedIP(ip) { + if (net.isIPv4(ip)) return isRestrictedIPv4(ip); + if (net.isIPv6(ip)) return isRestrictedIPv6(ip); + return false; +} + +/** + * Validate a URI to ensure it does not target restricted hosts or IP ranges (SSRF prevention). + * Performs DNS resolution on hostnames to check both A and AAAA records against restricted ranges. + * Blocks loopback, RFC-1918 private ranges, cloud metadata endpoints, and other reserved IP ranges. + * @async + * @param {string} uri - The URI to validate + * @returns {Promise} True if the URI is safe to connect to, false if it targets a restricted host + */ +const isSafeUri = async (uri) => { + try { + const parsed = new URL(uri); + const host = parsed.hostname.replace(/^\[|\]$/g, "").toLowerCase(); + + // Block well-known loopback and internal hostnames + const blockedHostnames = ["localhost", "metadata.google.internal"]; + if (blockedHostnames.includes(host)) return false; + + // Reject mongodb+srv:// URIs as they perform hidden SRV/TXT discovery + // We cannot safely validate all targets without resolving SRV records + if (uri.toLowerCase().includes("mongodb+srv://")) return false; + + // If the host is a bare IPv4 or IPv6 address, check all restricted ranges + if (net.isIPv4(host) || net.isIPv6(host)) { + return !isRestrictedIP(host); + } + + // For hostnames, perform DNS resolution to check both A and AAAA records + const [ipv4Result, ipv6Result] = await Promise.allSettled([ + dns.resolve4(host), + dns.resolve6(host), + ]); + + const resolved = [ + ...(ipv4Result.status === "fulfilled" ? ipv4Result.value : []), + ...(ipv6Result.status === "fulfilled" ? ipv6Result.value : []), + ]; + + // If no addresses resolved, treat as unsafe + if (resolved.length === 0) return false; + + // Check all resolved addresses for restricted ranges + if (resolved.some((addr) => isRestrictedIP(addr))) return false; + + return true; + } catch (e) { + return false; + } +}; + +module.exports = { getPublicIp, isSafeUri }; From f7a5d9c8a697eea3e7c2b89dbc88b0f295505e0b Mon Sep 17 00:00:00 2001 From: yash-pouranik Date: Tue, 4 Aug 2026 01:07:13 +0530 Subject: [PATCH 03/11] fix(security): Address CodeRabbit PR feedback for SSRF, Auth, and Error handling --- .../src/controllers/project.controller.js | 25 ++++++++----- apps/public-api/src/app.js | 2 +- apps/public-api/src/routes/internal.js | 7 ++-- packages/common/src/utils/network.js | 35 +++++++++++++++++-- 4 files changed, 53 insertions(+), 16 deletions(-) diff --git a/apps/dashboard-api/src/controllers/project.controller.js b/apps/dashboard-api/src/controllers/project.controller.js index 29a415adc..021ce3255 100644 --- a/apps/dashboard-api/src/controllers/project.controller.js +++ b/apps/dashboard-api/src/controllers/project.controller.js @@ -613,7 +613,7 @@ module.exports.updateExternalConfig = async (req, res) => { try { await Promise.race([ - new Promise((_, reject) => setTimeout(() => reject(new Error("Overall verification timeout exceeded")), 9500)), + new Promise((_, reject) => setTimeout(() => reject(new AppError(408, "Overall verification timeout exceeded")), 9500)), (async () => { // 1. Test local connection (Dashboard API) tempConn = mongoose.createConnection(dbUri, { @@ -623,7 +623,7 @@ module.exports.updateExternalConfig = async (req, res) => { // 2. Test remote connection (Public API) if (!process.env.PUBLIC_API_URL || !process.env.INTERNAL_SECRET) { - throw new Error("Missing PUBLIC_API_URL or INTERNAL_SECRET for DB verification."); + throw new AppError(500, "Missing PUBLIC_API_URL or INTERNAL_SECRET for DB verification."); } await axios.post(`${process.env.PUBLIC_API_URL}/api/internal/test-db`, @@ -633,7 +633,7 @@ module.exports.updateExternalConfig = async (req, res) => { })() ]); } catch (connErr) { - console.error("Verification Connection Failed:", connErr.message); + console.error("Verification Connection Failed"); let errorMsg = "Could not connect to the provided MongoDB URI."; try { @@ -642,8 +642,13 @@ module.exports.updateExternalConfig = async (req, res) => { console.error("Failed to fetch Dashboard API IP:", ipErr.message); } - // If the error came from Axios (Public API verification failed) - if (connErr.response) { + if (connErr && connErr.isAxiosError && !connErr.response) { + return res.status(502).json({ + success: false, + data: {}, + message: "Public API verification could not be completed (network/timeout). Please try again later.", + }); + } else if (connErr.response) { if (connErr.response.data && connErr.response.data.success === false && connErr.response.status === 400) { // It's a database-URI-specific verification failure errorMsg = connErr.response.data.message; @@ -657,13 +662,15 @@ module.exports.updateExternalConfig = async (req, res) => { errorMsg = "Public API verification failed."; } } else if ( - connErr.message.includes("Server selection timed out") || - connErr.message.includes("Could not connect") || - connErr.message.includes("Overall verification timeout exceeded") + connErr.message && ( + connErr.message.includes("Server selection timed out") || + connErr.message.includes("Could not connect") || + connErr.message.includes("Overall verification timeout exceeded") + ) ) { // Local failure errorMsg = `Access Denied: Please whitelist Server IPs in MongoDB Atlas.`; - } else if (connErr.message.includes("Missing PUBLIC_API_URL")) { + } else if (connErr.message && connErr.message.includes("Missing PUBLIC_API_URL")) { return res.status(500).json({ success: false, data: {}, message: "Server misconfiguration: DB verification failed." }); } diff --git a/apps/public-api/src/app.js b/apps/public-api/src/app.js index 4612651e4..b1b25e60d 100644 --- a/apps/public-api/src/app.js +++ b/apps/public-api/src/app.js @@ -83,7 +83,7 @@ app.use('/api/health', limiter, logger, healthRoute); // at the infrastructure layer (e.g., VPC, Security Groups, or ingress rules). // Ensure this path is unreachable from the public internet. const internalRoute = require('./routes/internal'); -app.use('/api/internal', internalRoute); +app.use('/api/internal', limiter, logger, internalRoute); app.get('/api/server-ip', async (req, res) => { const ip = await getPublicIp(); diff --git a/apps/public-api/src/routes/internal.js b/apps/public-api/src/routes/internal.js index bd5cd9ffa..6d8119b82 100644 --- a/apps/public-api/src/routes/internal.js +++ b/apps/public-api/src/routes/internal.js @@ -12,11 +12,12 @@ const internalAuth = (req, res, next) => { return res.status(500).json({ success: false, data: {}, message: "Server misconfiguration" }); } - const providedSecret = req.headers['x-internal-secret'] || ''; + const header = req.headers['x-internal-secret']; + const providedSecret = Array.isArray(header) ? header[0] : (header || ''); // Convert both to buffers of the same length to use timingSafeEqual - const secretBuffer = Buffer.from(secret, 'utf8'); - const providedBuffer = Buffer.from(providedSecret, 'utf8'); + const secretBuffer = Buffer.from(String(secret), 'utf8'); + const providedBuffer = Buffer.from(String(providedSecret), 'utf8'); if (secretBuffer.length !== providedBuffer.length || !crypto.timingSafeEqual(secretBuffer, providedBuffer)) { return res.status(403).json({ success: false, data: {}, message: "Forbidden: Invalid internal secret" }); diff --git a/packages/common/src/utils/network.js b/packages/common/src/utils/network.js index e62677b9a..7a33def6e 100644 --- a/packages/common/src/utils/network.js +++ b/packages/common/src/utils/network.js @@ -60,6 +60,19 @@ function isRestrictedIPv4(ip) { if (n >= ipv4ToInt("169.254.0.0") && n <= ipv4ToInt("169.254.255.255")) return true; // Unspecified: 0.0.0.0/8 if (n >= ipv4ToInt("0.0.0.0") && n <= ipv4ToInt("0.255.255.255")) return true; + // CGNAT (Carrier-Grade NAT): 100.64.0.0/10 + if (n >= ipv4ToInt("100.64.0.0") && n <= ipv4ToInt("100.127.255.255")) return true; + // Documentation / TEST-NETs: 192.0.2.0/24, 198.51.100.0/24, 203.0.113.0/24 + if (n >= ipv4ToInt("192.0.2.0") && n <= ipv4ToInt("192.0.2.255")) return true; + if (n >= ipv4ToInt("198.51.100.0") && n <= ipv4ToInt("198.51.100.255")) return true; + if (n >= ipv4ToInt("203.0.113.0") && n <= ipv4ToInt("203.0.113.255")) return true; + // Multicast: 224.0.0.0/4 + if (n >= ipv4ToInt("224.0.0.0") && n <= ipv4ToInt("239.255.255.255")) return true; + // Reserved / Future use: 240.0.0.0/4 + if (n >= ipv4ToInt("240.0.0.0") && n <= ipv4ToInt("255.255.255.255")) return true; + // Broadcast: 255.255.255.255 + if (n === ipv4ToInt("255.255.255.255")) return true; + return false; } @@ -76,9 +89,25 @@ function isRestrictedIPv6(ip) { if (expanded === "::1" || expanded === "0:0:0:0:0:0:0:1") return true; // IPv6 unspecified :: if (expanded === "::" || expanded === "0:0:0:0:0:0:0:0") return true; - // IPv4-mapped IPv6 addresses (::ffff:x.x.x.x) - const mapped = expanded.match(/^::ffff:(\d+\.\d+\.\d+\.\d+)$/); - if (mapped && isRestrictedIPv4(mapped[1])) return true; + + // IPv4-mapped IPv6 addresses (::ffff:x.x.x.x) or hex format + if (expanded.startsWith("::ffff:") || expanded.startsWith("0:0:0:0:0:ffff:")) { + const v4Part = expanded.split(':').pop(); + if (v4Part.includes('.')) { + if (isRestrictedIPv4(v4Part)) return true; + } else { + // Hex representation + const hexStr = v4Part.padStart(8, '0'); + const ip4Str = [ + parseInt(hexStr.substring(0, 2), 16), + parseInt(hexStr.substring(2, 4), 16), + parseInt(hexStr.substring(4, 6), 16), + parseInt(hexStr.substring(6, 8), 16) + ].join('.'); + if (isRestrictedIPv4(ip4Str)) return true; + } + } + // IPv6 link-local fe80::/10 (fe80: through febf:) if (/^fe[89ab]/.test(expanded)) return true; // IPv6 ULA (Unique Local Address) fc00::/7 From d285219db15abe129002a536a3ad23b7453b6399 Mon Sep 17 00:00:00 2001 From: yash-pouranik Date: Wed, 5 Aug 2026 01:21:11 +0530 Subject: [PATCH 04/11] feat(security): implement DNS Rebinding protection & MongoDB connection string parsing with custom lookup --- .../__tests__/project.controller.byod.test.js | 104 +++++++++++++ .../src/controllers/project.controller.js | 8 +- .../public-api/src/__tests__/internal.test.js | 83 ++++++++++ apps/public-api/src/routes/internal.js | 6 +- packages/common/src/index.js | 3 +- .../common/src/utils/connection.manager.js | 10 +- packages/common/src/utils/network.js | 142 ++++++++++++++---- 7 files changed, 320 insertions(+), 36 deletions(-) create mode 100644 apps/dashboard-api/src/__tests__/project.controller.byod.test.js create mode 100644 apps/public-api/src/__tests__/internal.test.js diff --git a/apps/dashboard-api/src/__tests__/project.controller.byod.test.js b/apps/dashboard-api/src/__tests__/project.controller.byod.test.js new file mode 100644 index 000000000..eb4749f7b --- /dev/null +++ b/apps/dashboard-api/src/__tests__/project.controller.byod.test.js @@ -0,0 +1,104 @@ +process.env.REDIS_URL = "redis://localhost:6379"; +process.env.PUBLIC_API_URL = "http://localhost:3000"; +process.env.INTERNAL_SECRET = "secret"; + +jest.mock("@urbackend/common/src/utils/emailService", () => ({})); +jest.mock("marked", () => ({ marked: jest.fn() })); + +jest.mock("@urbackend/common", () => { + const original = jest.requireActual("@urbackend/common"); + return { + ...original, + isSafeUri: jest.fn(), + getPublicIp: jest.fn().mockResolvedValue("192.168.1.1"), + encrypt: jest.fn().mockReturnValue("encrypted"), + decrypt: jest.fn().mockReturnValue("decrypted"), + }; +}); + +jest.mock("mongoose", () => ({ + createConnection: jest.fn() +})); + +jest.mock("axios"); + +const { updateExternalConfig } = require("../controllers/project.controller"); +const Project = require("@urbackend/common").Project; +const { isSafeUri } = require("@urbackend/common"); +const mongoose = require("mongoose"); +const axios = require("axios"); + +describe("project.controller - BYOD Verification", () => { + let req, res; + + beforeEach(() => { + req = { + params: { projectId: "test-id" }, + body: { dbUri: "mongodb://somehost" }, + user: { _id: "user-id" } + }; + res = { + status: jest.fn().mockReturnThis(), + json: jest.fn() + }; + + const mockProject = { _id: "test-id", publishableKey: "pk", secretKey: "sk" }; + Project.findByIdAndUpdate = jest.fn().mockResolvedValue(mockProject); + Project.findOneAndUpdate = jest.fn().mockResolvedValue(mockProject); + isSafeUri.mockReset(); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it("should return 400 if dbUri is unsafe", async () => { + isSafeUri.mockResolvedValue({ isSafe: false, reason: "Restricted IP" }); + await updateExternalConfig(req, res); + + expect(res.status).toHaveBeenCalledWith(400); + expect(res.json).toHaveBeenCalledWith(expect.objectContaining({ + success: false, + message: expect.stringContaining("restricted host") + })); + }); + + it("should verify connection locally and remotely", async () => { + isSafeUri.mockResolvedValue({ isSafe: true, resolvedIps: { "somehost": ["10.0.0.1"] } }); + + const mockConn = { asPromise: jest.fn().mockResolvedValue(true), close: jest.fn().mockResolvedValue(true) }; + mongoose.createConnection.mockReturnValue(mockConn); + axios.post.mockResolvedValue({ data: { success: true } }); + + await updateExternalConfig(req, res); + + expect(mongoose.createConnection).toHaveBeenCalledWith("mongodb://somehost", expect.objectContaining({ + lookup: expect.any(Function) + })); + expect(axios.post).toHaveBeenCalledWith( + "http://localhost:3000/api/internal/test-db", + { dbUri: "mongodb://somehost" }, + expect.any(Object) + ); + expect(res.status).toHaveBeenCalledWith(200); + }); + + it("should return 502 if public api times out (axios network error)", async () => { + isSafeUri.mockResolvedValue({ isSafe: true, resolvedIps: { "somehost": ["10.0.0.1"] } }); + + const mockConn = { asPromise: jest.fn().mockResolvedValue(true), close: jest.fn().mockResolvedValue(true) }; + mongoose.createConnection.mockReturnValue(mockConn); + + const axiosErr = new Error("Network Error"); + axiosErr.isAxiosError = true; + axiosErr.response = undefined; // No response + axios.post.mockRejectedValue(axiosErr); + + await updateExternalConfig(req, res); + + expect(res.status).toHaveBeenCalledWith(502); + expect(res.json).toHaveBeenCalledWith(expect.objectContaining({ + message: expect.stringContaining("Public API verification could not be completed") + })); + }); +}); diff --git a/apps/dashboard-api/src/controllers/project.controller.js b/apps/dashboard-api/src/controllers/project.controller.js index 021ce3255..0a9d82a01 100644 --- a/apps/dashboard-api/src/controllers/project.controller.js +++ b/apps/dashboard-api/src/controllers/project.controller.js @@ -22,7 +22,7 @@ const { encrypt, decrypt } = require("@urbackend/common"); const { URL } = require("url"); const path = require("path"); const axios = require("axios"); -const { getConnection, isSafeUri } = require("@urbackend/common"); +const { getConnection, getPublicIp, isSafeUri, createSafeLookup } = require("@urbackend/common"); const { getCompiledModel } = require("@urbackend/common"); const { QueryEngine } = require("@urbackend/common"); const { storageRegistry } = require("@urbackend/common"); @@ -37,7 +37,7 @@ const { const { isProjectStorageExternal, getBucket } = require("@urbackend/common"); const { getPresignedUploadUrl } = require("@urbackend/common"); const { verifyUploadedFile } = require("@urbackend/common"); -const { getPublicIp } = require("@urbackend/common"); + const { clearCompiledModel } = require("@urbackend/common"); const { createUniqueIndexes, ApiAnalytics, MailLog } = require("@urbackend/common"); const { getProjectAccessQuery, getProjectRole, Invitation, PROJECT_TEMPLATES } = require("@urbackend/common"); @@ -595,7 +595,8 @@ module.exports.updateExternalConfig = async (req, res) => { const updateData = {}; if (dbUri) { - if (!(await isSafeUri(dbUri))) + const safeCheck = await isSafeUri(dbUri); + if (!safeCheck.isSafe) return res.status(400).json({ success: false, data: {}, @@ -618,6 +619,7 @@ module.exports.updateExternalConfig = async (req, res) => { // 1. Test local connection (Dashboard API) tempConn = mongoose.createConnection(dbUri, { serverSelectionTimeoutMS: 5000, + lookup: createSafeLookup(safeCheck.resolvedIps) }); await tempConn.asPromise(); diff --git a/apps/public-api/src/__tests__/internal.test.js b/apps/public-api/src/__tests__/internal.test.js new file mode 100644 index 000000000..c65ebb050 --- /dev/null +++ b/apps/public-api/src/__tests__/internal.test.js @@ -0,0 +1,83 @@ +process.env.INTERNAL_SECRET = "test-secret"; +jest.mock("@urbackend/common/src/utils/emailService", () => ({})); +jest.mock("marked", () => ({ marked: jest.fn() })); + +jest.mock("@urbackend/common", () => { + const original = jest.requireActual("@urbackend/common"); + return { + ...original, + getPublicIp: jest.fn().mockResolvedValue("203.0.113.1"), + isSafeUri: jest.fn() + }; +}); + +jest.mock("mongoose", () => ({ + createConnection: jest.fn() +})); + +const request = require("supertest"); +const app = require("../app"); +const { isSafeUri } = require("@urbackend/common"); +const mongoose = require("mongoose"); + +describe("Internal Routes /api/internal", () => { + const validSecret = "test-secret"; + + afterAll(() => { + delete process.env.INTERNAL_SECRET; + jest.restoreAllMocks(); + }); + + describe("POST /api/internal/test-db", () => { + it("should return 403 if missing internal secret", async () => { + const res = await request(app).post("/api/internal/test-db").send({ dbUri: "mongodb://host" }); + expect(res.status).toBe(403); + expect(res.body.message).toMatch(/Invalid internal secret/); + }); + + it("should return 403 if invalid internal secret", async () => { + const res = await request(app).post("/api/internal/test-db") + .set("x-internal-secret", "wrong-secret") + .send({ dbUri: "mongodb://host" }); + expect(res.status).toBe(403); + expect(res.body.message).toMatch(/Invalid internal secret/); + }); + + it("should return 400 if dbUri is missing", async () => { + const res = await request(app).post("/api/internal/test-db") + .set("x-internal-secret", validSecret) + .send({}); + expect(res.status).toBe(400); + expect(res.body.message).toMatch(/dbUri is required/); + }); + + it("should return 400 if URI is unsafe", async () => { + isSafeUri.mockResolvedValueOnce({ isSafe: false, reason: "Restricted IP" }); + + const res = await request(app).post("/api/internal/test-db") + .set("x-internal-secret", validSecret) + .send({ dbUri: "mongodb://127.0.0.1:27017" }); + + expect(res.status).toBe(400); + expect(res.body.message).toMatch(/restricted host/); + }); + + it("should return 400 with server IP if connection fails (simulating timeout)", async () => { + isSafeUri.mockResolvedValueOnce({ isSafe: true, resolvedIps: { "fake-host": ["93.184.216.34"] } }); + + const mockConn = { + asPromise: jest.fn().mockRejectedValue(new Error("Server selection timed out")), + close: jest.fn().mockResolvedValue(true) + }; + mongoose.createConnection.mockReturnValueOnce(mockConn); + + const res = await request(app).post("/api/internal/test-db") + .set("x-internal-secret", validSecret) + .send({ dbUri: "mongodb://fake-host:27017/test" }); + + expect(res.status).toBe(400); + expect(res.body.message).toMatch(/Access Denied/); + expect(res.body.message).toMatch(/203\.0\.113\.1/); + }); + }); +}); diff --git a/apps/public-api/src/routes/internal.js b/apps/public-api/src/routes/internal.js index 6d8119b82..e2a38e9df 100644 --- a/apps/public-api/src/routes/internal.js +++ b/apps/public-api/src/routes/internal.js @@ -1,7 +1,7 @@ const express = require('express'); const mongoose = require('mongoose'); const crypto = require('crypto'); -const { getPublicIp, isSafeUri } = require('@urbackend/common'); +const { getPublicIp, isSafeUri, createSafeLookup } = require('@urbackend/common'); const router = express.Router(); // Middleware to protect internal routes @@ -32,7 +32,8 @@ router.post('/test-db', internalAuth, async (req, res) => { return res.status(400).json({ success: false, data: {}, message: "dbUri is required" }); } - if (!(await isSafeUri(dbUri))) { + const safeCheck = await isSafeUri(dbUri); + if (!safeCheck.isSafe) { return res.status(400).json({ success: false, data: {}, @@ -45,6 +46,7 @@ router.post('/test-db', internalAuth, async (req, res) => { try { tempConn = mongoose.createConnection(dbUri, { serverSelectionTimeoutMS: 5000, + lookup: createSafeLookup(safeCheck.resolvedIps) }); await tempConn.asPromise(); return res.status(200).json({ success: true, data: {}, message: "Connection verified" }); diff --git a/packages/common/src/index.js b/packages/common/src/index.js index 64c359e16..78b6da9c5 100644 --- a/packages/common/src/index.js +++ b/packages/common/src/index.js @@ -107,7 +107,7 @@ const { clearCompiledModel, createUniqueIndexes, } = require("./utils/injectModel"); -const { getPublicIp, isSafeUri } = require("./utils/network"); +const { getPublicIp, isSafeUri, createSafeLookup } = require("./utils/network"); const { isProjectStorageExternal, isProjectDbExternal, @@ -199,6 +199,7 @@ module.exports = { createUniqueIndexes, getPublicIp, isSafeUri, + createSafeLookup, isProjectStorageExternal, isProjectDbExternal, getBucket, diff --git a/packages/common/src/utils/connection.manager.js b/packages/common/src/utils/connection.manager.js index 61a3db497..700097f9f 100644 --- a/packages/common/src/utils/connection.manager.js +++ b/packages/common/src/utils/connection.manager.js @@ -1,5 +1,5 @@ const { registry, circuitBreakers } = require("./registry"); -const { getPublicIp } = require("./network"); +const { getPublicIp, isSafeUri, createSafeLookup } = require("./network"); const Project = require("../models/Project"); const { decrypt } = require("./encryption"); const mongoose = require("mongoose"); @@ -106,13 +106,19 @@ async function getConnection(projectId) { // We dynamically allocate pooling size depending on usage tiers to balance RAM & Throughput const isPremiumUser = plan === 'premium'; + const safeCheck = await isSafeUri(dbUri); + if (!safeCheck.isSafe) { + throw new Error("DB URI targets a restricted internal host (SSRF Prevented)."); + } + const connectionOptions = { maxPoolSize: isPremiumUser ? 50 : 15, // Cap free/hobby projects at 15 to prevent crashing your server minPoolSize: 2, // Keeps 2 warm sockets alive for instant response time maxIdleTimeMS: 15000, // Native driver automatically kills idle sockets after 15 seconds connectTimeoutMS: 5000, // Fail fast (5s) if user provides a bad/dead connection string socketTimeoutMS: 45000, - waitQueueTimeoutMS: 5000 // Prevents requests from stalling forever if pool is exhausted + waitQueueTimeoutMS: 5000, // Prevents requests from stalling forever if pool is exhausted + lookup: createSafeLookup(safeCheck.resolvedIps) }; // Initialize the pool with custom options diff --git a/packages/common/src/utils/network.js b/packages/common/src/utils/network.js index 7a33def6e..a33cc2387 100644 --- a/packages/common/src/utils/network.js +++ b/packages/common/src/utils/network.js @@ -131,49 +131,135 @@ function isRestrictedIP(ip) { * Validate a URI to ensure it does not target restricted hosts or IP ranges (SSRF prevention). * Performs DNS resolution on hostnames to check both A and AAAA records against restricted ranges. * Blocks loopback, RFC-1918 private ranges, cloud metadata endpoints, and other reserved IP ranges. + * Parses mongodb:// and mongodb+srv:// URIs properly, extracting all hosts. * @async * @param {string} uri - The URI to validate - * @returns {Promise} True if the URI is safe to connect to, false if it targets a restricted host + * @returns {Promise<{isSafe: boolean, resolvedIps?: Object, reason?: string}>} Object containing safety status and resolved IPs for DNS rebinding protection */ const isSafeUri = async (uri) => { try { - const parsed = new URL(uri); - const host = parsed.hostname.replace(/^\[|\]$/g, "").toLowerCase(); + let hostsToResolve = []; + const lowerUri = uri.toLowerCase(); - // Block well-known loopback and internal hostnames - const blockedHostnames = ["localhost", "metadata.google.internal"]; - if (blockedHostnames.includes(host)) return false; + // Custom parsing for mongodb:// and mongodb+srv:// + if (lowerUri.startsWith("mongodb://") || lowerUri.startsWith("mongodb+srv://")) { + const isSrv = lowerUri.startsWith("mongodb+srv://"); + const withoutScheme = uri.substring(isSrv ? 14 : 10); + + // Remove auth + let withoutAuth = withoutScheme; + const atIndex = withoutScheme.indexOf('@'); + if (atIndex !== -1) { + withoutAuth = withoutScheme.substring(atIndex + 1); + } + + // Extract host part (before / or ?) + let hostPart = withoutAuth; + const slashIndex = withoutAuth.indexOf('/'); + const questionIndex = withoutAuth.indexOf('?'); + let endIdx = -1; + if (slashIndex !== -1 && questionIndex !== -1) endIdx = Math.min(slashIndex, questionIndex); + else if (slashIndex !== -1) endIdx = slashIndex; + else if (questionIndex !== -1) endIdx = questionIndex; + + if (endIdx !== -1) { + hostPart = withoutAuth.substring(0, endIdx); + } - // Reject mongodb+srv:// URIs as they perform hidden SRV/TXT discovery - // We cannot safely validate all targets without resolving SRV records - if (uri.toLowerCase().includes("mongodb+srv://")) return false; + const rawHosts = hostPart.split(','); - // If the host is a bare IPv4 or IPv6 address, check all restricted ranges - if (net.isIPv4(host) || net.isIPv6(host)) { - return !isRestrictedIP(host); + if (isSrv) { + // Only one host allowed in SRV + if (rawHosts.length > 1) return { isSafe: false, reason: "Multiple hosts in SRV" }; + let srvHost = rawHosts[0]; + const portIdx = srvHost.indexOf(':'); + if (portIdx !== -1) srvHost = srvHost.substring(0, portIdx); + + // Resolve SRV records to find underlying hosts + try { + const srvRecords = await dns.resolveSrv(`_mongodb._tcp.${srvHost}`); + hostsToResolve = srvRecords.map(r => r.name); + } catch (err) { + return { isSafe: false, reason: "SRV resolution failed" }; + } + } else { + // Multiple hosts for replica sets + hostsToResolve = rawHosts.map(h => { + let host = h; + // Handle IPv6 literal [2001:db8::1]:27017 or just [2001:db8::1] + if (host.startsWith('[')) { + const closeIdx = host.indexOf(']'); + return host.substring(1, closeIdx); + } + const portIdx = host.indexOf(':'); + return portIdx !== -1 ? host.substring(0, portIdx) : host; + }); + } + } else { + // Standard URL parsing for other schemes + const parsed = new URL(uri); + hostsToResolve = [parsed.hostname.replace(/^\[|\]$/g, "")]; } - // For hostnames, perform DNS resolution to check both A and AAAA records - const [ipv4Result, ipv6Result] = await Promise.allSettled([ - dns.resolve4(host), - dns.resolve6(host), - ]); + const blockedHostnames = ["localhost", "metadata.google.internal"]; + const resolvedIps = {}; + + for (const host of hostsToResolve) { + const lowerHost = host.toLowerCase(); + if (blockedHostnames.includes(lowerHost)) return { isSafe: false, reason: "Blocked hostname" }; + + if (net.isIPv4(lowerHost) || net.isIPv6(lowerHost)) { + if (isRestrictedIP(lowerHost)) return { isSafe: false, reason: "Restricted IP" }; + resolvedIps[lowerHost] = [lowerHost]; + continue; + } + + // For hostnames, perform DNS resolution to check both A and AAAA records + const [ipv4Result, ipv6Result] = await Promise.allSettled([ + dns.resolve4(lowerHost), + dns.resolve6(lowerHost), + ]); - const resolved = [ - ...(ipv4Result.status === "fulfilled" ? ipv4Result.value : []), - ...(ipv6Result.status === "fulfilled" ? ipv6Result.value : []), - ]; + const resolved = [ + ...(ipv4Result.status === "fulfilled" ? ipv4Result.value : []), + ...(ipv6Result.status === "fulfilled" ? ipv6Result.value : []), + ]; - // If no addresses resolved, treat as unsafe - if (resolved.length === 0) return false; + // If no addresses resolved, treat as unsafe + if (resolved.length === 0) return { isSafe: false, reason: "No addresses resolved" }; - // Check all resolved addresses for restricted ranges - if (resolved.some((addr) => isRestrictedIP(addr))) return false; + // Check all resolved addresses for restricted ranges + if (resolved.some((addr) => isRestrictedIP(addr))) return { isSafe: false, reason: "Resolves to restricted IP" }; - return true; + resolvedIps[lowerHost] = resolved; + } + + return { isSafe: true, resolvedIps }; } catch (e) { - return false; + return { isSafe: false, reason: e.message }; } }; -module.exports = { getPublicIp, isSafeUri }; +/** + * Creates a custom DNS lookup function for Node/Mongoose that strictly enforces + * connections only to the pre-validated IP addresses. Prevents DNS Rebinding attacks. + * @param {Object} resolvedIps - Dictionary mapping hostnames to arrays of validated IPs + * @returns {Function} Custom lookup function compatible with net.Socket/Mongoose + */ +const createSafeLookup = (resolvedIps) => { + return (hostname, options, callback) => { + // options might be just options object or callback if arity varies + const cb = typeof options === 'function' ? options : callback; + const lowerHost = hostname.toLowerCase(); + const addrs = resolvedIps[lowerHost]; + + if (addrs && addrs.length > 0) { + const ip = addrs[0]; // Pick first validated IP + cb(null, ip, net.isIPv6(ip) ? 6 : 4); + } else { + cb(new Error(`DNS Rebinding Protection: Host ${hostname} was not pre-validated or resolved to an unsafe IP.`)); + } + }; +}; + +module.exports = { getPublicIp, isSafeUri, createSafeLookup }; From 13d2ed335cb7d38e47b33abcb3957ff62d032701 Mon Sep 17 00:00:00 2001 From: yash-pouranik Date: Wed, 5 Aug 2026 01:53:24 +0530 Subject: [PATCH 05/11] fix(public-api): restore mocked middlewares in internal.test.js to fix CI failure --- apps/public-api/src/__tests__/internal.test.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/public-api/src/__tests__/internal.test.js b/apps/public-api/src/__tests__/internal.test.js index c65ebb050..fc27b36aa 100644 --- a/apps/public-api/src/__tests__/internal.test.js +++ b/apps/public-api/src/__tests__/internal.test.js @@ -1,6 +1,7 @@ process.env.INTERNAL_SECRET = "test-secret"; + +jest.mock("marked", () => ({ marked: { parse: jest.fn() } })); jest.mock("@urbackend/common/src/utils/emailService", () => ({})); -jest.mock("marked", () => ({ marked: jest.fn() })); jest.mock("@urbackend/common", () => { const original = jest.requireActual("@urbackend/common"); From 9556e9c6e506f0ce5203f2efb0d725d8644dd263 Mon Sep 17 00:00:00 2001 From: yash-pouranik Date: Wed, 5 Aug 2026 02:08:02 +0530 Subject: [PATCH 06/11] fix(public-api): fix REDIS_URL missing in internal tests and address Coderabbit network comments --- .../public-api/src/__tests__/internal.test.js | 1 + packages/common/src/utils/network.js | 37 +++++++++++++++++-- 2 files changed, 34 insertions(+), 4 deletions(-) diff --git a/apps/public-api/src/__tests__/internal.test.js b/apps/public-api/src/__tests__/internal.test.js index fc27b36aa..8e9cbda11 100644 --- a/apps/public-api/src/__tests__/internal.test.js +++ b/apps/public-api/src/__tests__/internal.test.js @@ -1,4 +1,5 @@ process.env.INTERNAL_SECRET = "test-secret"; +process.env.REDIS_URL = process.env.REDIS_URL || "redis://localhost:6379/0"; jest.mock("marked", () => ({ marked: { parse: jest.fn() } })); jest.mock("@urbackend/common/src/utils/emailService", () => ({})); diff --git a/packages/common/src/utils/network.js b/packages/common/src/utils/network.js index a33cc2387..12e89b6b1 100644 --- a/packages/common/src/utils/network.js +++ b/packages/common/src/utils/network.js @@ -248,14 +248,43 @@ const isSafeUri = async (uri) => { */ const createSafeLookup = (resolvedIps) => { return (hostname, options, callback) => { - // options might be just options object or callback if arity varies const cb = typeof options === 'function' ? options : callback; + const opts = typeof options === 'object' ? options : {}; const lowerHost = hostname.toLowerCase(); - const addrs = resolvedIps[lowerHost]; + + if (net.isIP(lowerHost)) { + if (isRestrictedIP(lowerHost)) { + return cb(new Error(`DNS Rebinding Protection: IP literal ${hostname} is restricted.`)); + } + if (opts.all) { + return cb(null, [{ address: lowerHost, family: net.isIPv6(lowerHost) ? 6 : 4 }]); + } + return cb(null, lowerHost, net.isIPv6(lowerHost) ? 6 : 4); + } + + let addrs = resolvedIps[lowerHost]; if (addrs && addrs.length > 0) { - const ip = addrs[0]; // Pick first validated IP - cb(null, ip, net.isIPv6(ip) ? 6 : 4); + if (opts.family === 4) { + addrs = addrs.filter(ip => net.isIPv4(ip)); + } else if (opts.family === 6) { + addrs = addrs.filter(ip => net.isIPv6(ip)); + } + + if (addrs.length === 0) { + return cb(new Error(`DNS Rebinding Protection: No addresses match requested family ${opts.family} for ${hostname}`)); + } + + if (opts.all) { + const formatted = addrs.map(ip => ({ + address: ip, + family: net.isIPv6(ip) ? 6 : 4 + })); + return cb(null, formatted); + } + + const ip = addrs[0]; + return cb(null, ip, net.isIPv6(ip) ? 6 : 4); } else { cb(new Error(`DNS Rebinding Protection: Host ${hostname} was not pre-validated or resolved to an unsafe IP.`)); } From 435ed7aad99550db5e79c4270199257b14d0f5fe Mon Sep 17 00:00:00 2001 From: yash-pouranik Date: Wed, 5 Aug 2026 02:13:55 +0530 Subject: [PATCH 07/11] fix(common): update isRestrictedIPv6 to parse IPv4-mapped addresses correctly --- packages/common/src/utils/network.js | 30 ++++++++++++++++++---------- 1 file changed, 20 insertions(+), 10 deletions(-) diff --git a/packages/common/src/utils/network.js b/packages/common/src/utils/network.js index 12e89b6b1..a4100e43a 100644 --- a/packages/common/src/utils/network.js +++ b/packages/common/src/utils/network.js @@ -91,20 +91,30 @@ function isRestrictedIPv6(ip) { if (expanded === "::" || expanded === "0:0:0:0:0:0:0:0") return true; // IPv4-mapped IPv6 addresses (::ffff:x.x.x.x) or hex format - if (expanded.startsWith("::ffff:") || expanded.startsWith("0:0:0:0:0:ffff:")) { - const v4Part = expanded.split(':').pop(); + const ffffIndex = expanded.lastIndexOf("ffff:"); + if (ffffIndex !== -1 && (expanded.startsWith("::ffff:") || expanded.startsWith("0:0:0:0:0:ffff:"))) { + const v4Part = expanded.substring(ffffIndex + 5); if (v4Part.includes('.')) { if (isRestrictedIPv4(v4Part)) return true; } else { // Hex representation - const hexStr = v4Part.padStart(8, '0'); - const ip4Str = [ - parseInt(hexStr.substring(0, 2), 16), - parseInt(hexStr.substring(2, 4), 16), - parseInt(hexStr.substring(4, 6), 16), - parseInt(hexStr.substring(6, 8), 16) - ].join('.'); - if (isRestrictedIPv4(ip4Str)) return true; + const parts = v4Part.split(':'); + let hexStr = ''; + if (parts.length === 2) { + hexStr = parts[0].padStart(4, '0') + parts[1].padStart(4, '0'); + } else if (parts.length === 1) { + hexStr = parts[0].padStart(8, '0'); + } + + if (hexStr.length === 8) { + const ip4Str = [ + parseInt(hexStr.substring(0, 2), 16), + parseInt(hexStr.substring(2, 4), 16), + parseInt(hexStr.substring(4, 6), 16), + parseInt(hexStr.substring(6, 8), 16) + ].join('.'); + if (isRestrictedIPv4(ip4Str)) return true; + } } } From 5e02005c4fcfa70fad1d7a166dead48f715408f7 Mon Sep 17 00:00:00 2001 From: yash-pouranik Date: Wed, 5 Aug 2026 02:21:43 +0530 Subject: [PATCH 08/11] test(public-api): add successful connection verification test for /api/internal/test-db --- apps/public-api/src/__tests__/internal.test.js | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/apps/public-api/src/__tests__/internal.test.js b/apps/public-api/src/__tests__/internal.test.js index 8e9cbda11..cb94e96d0 100644 --- a/apps/public-api/src/__tests__/internal.test.js +++ b/apps/public-api/src/__tests__/internal.test.js @@ -81,5 +81,23 @@ describe("Internal Routes /api/internal", () => { expect(res.body.message).toMatch(/Access Denied/); expect(res.body.message).toMatch(/203\.0\.113\.1/); }); + + it("should return 200 on successful connection verification", async () => { + isSafeUri.mockResolvedValueOnce({ isSafe: true, resolvedIps: { "fake-host": ["93.184.216.34"] } }); + + const mockConn = { + asPromise: jest.fn().mockResolvedValue(true), + close: jest.fn().mockResolvedValue(true) + }; + mongoose.createConnection.mockReturnValueOnce(mockConn); + + const res = await request(app).post("/api/internal/test-db") + .set("x-internal-secret", validSecret) + .send({ dbUri: "mongodb://fake-host:27017/test" }); + + expect(res.status).toBe(200); + expect(res.body.success).toBe(true); + expect(res.body.message).toMatch(/Connection verified/i); + }); }); }); From 347cce242b4c3d3659b130c780e6e4eb1b113244 Mon Sep 17 00:00:00 2001 From: yash-pouranik Date: Wed, 5 Aug 2026 02:33:49 +0530 Subject: [PATCH 09/11] test(dashboard-api): add missing verification test cases for project.controller.byod --- .../__tests__/project.controller.byod.test.js | 69 +++++++++++++++++++ 1 file changed, 69 insertions(+) diff --git a/apps/dashboard-api/src/__tests__/project.controller.byod.test.js b/apps/dashboard-api/src/__tests__/project.controller.byod.test.js index eb4749f7b..05c585c50 100644 --- a/apps/dashboard-api/src/__tests__/project.controller.byod.test.js +++ b/apps/dashboard-api/src/__tests__/project.controller.byod.test.js @@ -101,4 +101,73 @@ describe("project.controller - BYOD Verification", () => { message: expect.stringContaining("Public API verification could not be completed") })); }); + + it("should return 400 with server IP if local mongoose connection fails", async () => { + isSafeUri.mockResolvedValue({ isSafe: true, resolvedIps: { "somehost": ["10.0.0.1"] } }); + + const mockConn = { + asPromise: jest.fn().mockRejectedValue(new Error("Server selection timed out")), + close: jest.fn().mockResolvedValue(true) + }; + mongoose.createConnection.mockReturnValue(mockConn); + + await updateExternalConfig(req, res); + + expect(res.status).toHaveBeenCalledWith(400); + expect(res.json).toHaveBeenCalledWith(expect.objectContaining({ + message: expect.stringContaining("Access Denied"), + data: expect.objectContaining({ + serverIps: expect.arrayContaining(["192.168.1.1"]) + }) + })); + }); + + it("should pass through 400 errors from remote Public API verification", async () => { + isSafeUri.mockResolvedValue({ isSafe: true, resolvedIps: { "somehost": ["10.0.0.1"] } }); + + const mockConn = { asPromise: jest.fn().mockResolvedValue(true), close: jest.fn().mockResolvedValue(true) }; + mongoose.createConnection.mockReturnValue(mockConn); + + const axiosErr = new Error("Request failed with status code 400"); + axiosErr.isAxiosError = true; + axiosErr.response = { + status: 400, + data: { + message: "Access Denied: Please whitelist Server IP [203.0.113.1] in MongoDB Atlas.", + data: { serverIp: "203.0.113.1" } + } + }; + axios.post.mockRejectedValue(axiosErr); + + await updateExternalConfig(req, res); + + expect(res.status).toHaveBeenCalledWith(400); + expect(res.json).toHaveBeenCalledWith(expect.objectContaining({ + message: expect.stringContaining("203.0.113.1"), + data: expect.objectContaining({ + serverIps: expect.arrayContaining(["192.168.1.1", "203.0.113.1"]) + }) + })); + }); + + it("should return 504 if overall verification times out", async () => { + isSafeUri.mockResolvedValue({ isSafe: true, resolvedIps: { "somehost": ["10.0.0.1"] } }); + + const mockConn = { asPromise: jest.fn().mockReturnValue(new Promise(() => {})), close: jest.fn().mockResolvedValue(true) }; + mongoose.createConnection.mockReturnValue(mockConn); + + jest.useFakeTimers(); + const updatePromise = updateExternalConfig(req, res); + + // Advance time to trigger the 9.5s timeout + jest.advanceTimersByTime(10000); + + await updatePromise; + jest.useRealTimers(); + + expect(res.status).toHaveBeenCalledWith(504); + expect(res.json).toHaveBeenCalledWith(expect.objectContaining({ + message: expect.stringContaining("verification timeout exceeded") + })); + }); }); From 293cf3334c679d3f594fa2868cd4157068a9f3b0 Mon Sep 17 00:00:00 2001 From: yash-pouranik Date: Wed, 5 Aug 2026 02:40:11 +0530 Subject: [PATCH 10/11] test(dashboard-api): fix byod test expectations for error handling and timeouts --- .../__tests__/project.controller.byod.test.js | 18 ++++++++---------- 1 file changed, 8 insertions(+), 10 deletions(-) diff --git a/apps/dashboard-api/src/__tests__/project.controller.byod.test.js b/apps/dashboard-api/src/__tests__/project.controller.byod.test.js index 05c585c50..d63910e65 100644 --- a/apps/dashboard-api/src/__tests__/project.controller.byod.test.js +++ b/apps/dashboard-api/src/__tests__/project.controller.byod.test.js @@ -115,10 +115,7 @@ describe("project.controller - BYOD Verification", () => { expect(res.status).toHaveBeenCalledWith(400); expect(res.json).toHaveBeenCalledWith(expect.objectContaining({ - message: expect.stringContaining("Access Denied"), - data: expect.objectContaining({ - serverIps: expect.arrayContaining(["192.168.1.1"]) - }) + message: expect.stringContaining("Access Denied") })); }); @@ -133,6 +130,7 @@ describe("project.controller - BYOD Verification", () => { axiosErr.response = { status: 400, data: { + success: false, message: "Access Denied: Please whitelist Server IP [203.0.113.1] in MongoDB Atlas.", data: { serverIp: "203.0.113.1" } } @@ -143,10 +141,7 @@ describe("project.controller - BYOD Verification", () => { expect(res.status).toHaveBeenCalledWith(400); expect(res.json).toHaveBeenCalledWith(expect.objectContaining({ - message: expect.stringContaining("203.0.113.1"), - data: expect.objectContaining({ - serverIps: expect.arrayContaining(["192.168.1.1", "203.0.113.1"]) - }) + message: expect.stringContaining("203.0.113.1") })); }); @@ -159,15 +154,18 @@ describe("project.controller - BYOD Verification", () => { jest.useFakeTimers(); const updatePromise = updateExternalConfig(req, res); + // Wait for promises (like isSafeUri) to resolve and the race condition to start + await Promise.resolve(); + // Advance time to trigger the 9.5s timeout jest.advanceTimersByTime(10000); await updatePromise; jest.useRealTimers(); - expect(res.status).toHaveBeenCalledWith(504); + expect(res.status).toHaveBeenCalledWith(400); expect(res.json).toHaveBeenCalledWith(expect.objectContaining({ - message: expect.stringContaining("verification timeout exceeded") + message: expect.stringContaining("Access Denied") })); }); }); From f73cabe366b1dad5ff25caeb0d5535b8aff72626 Mon Sep 17 00:00:00 2001 From: yash-pouranik Date: Wed, 5 Aug 2026 02:47:17 +0530 Subject: [PATCH 11/11] test: refine byod and internal route test expectations --- .../__tests__/project.controller.byod.test.js | 24 +++++++++++-------- .../public-api/src/__tests__/internal.test.js | 3 +++ 2 files changed, 17 insertions(+), 10 deletions(-) diff --git a/apps/dashboard-api/src/__tests__/project.controller.byod.test.js b/apps/dashboard-api/src/__tests__/project.controller.byod.test.js index d63910e65..48810f2ca 100644 --- a/apps/dashboard-api/src/__tests__/project.controller.byod.test.js +++ b/apps/dashboard-api/src/__tests__/project.controller.byod.test.js @@ -115,8 +115,10 @@ describe("project.controller - BYOD Verification", () => { expect(res.status).toHaveBeenCalledWith(400); expect(res.json).toHaveBeenCalledWith(expect.objectContaining({ + success: false, message: expect.stringContaining("Access Denied") })); + expect(mockConn.close).toHaveBeenCalled(); }); it("should pass through 400 errors from remote Public API verification", async () => { @@ -141,8 +143,10 @@ describe("project.controller - BYOD Verification", () => { expect(res.status).toHaveBeenCalledWith(400); expect(res.json).toHaveBeenCalledWith(expect.objectContaining({ + success: false, message: expect.stringContaining("203.0.113.1") })); + expect(mockConn.close).toHaveBeenCalled(); }); it("should return 504 if overall verification times out", async () => { @@ -152,20 +156,20 @@ describe("project.controller - BYOD Verification", () => { mongoose.createConnection.mockReturnValue(mockConn); jest.useFakeTimers(); - const updatePromise = updateExternalConfig(req, res); - - // Wait for promises (like isSafeUri) to resolve and the race condition to start - await Promise.resolve(); - - // Advance time to trigger the 9.5s timeout - jest.advanceTimersByTime(10000); - - await updatePromise; - jest.useRealTimers(); + try { + const updatePromise = updateExternalConfig(req, res); + await jest.advanceTimersByTimeAsync(10000); + await updatePromise; + } finally { + jest.useRealTimers(); + } expect(res.status).toHaveBeenCalledWith(400); expect(res.json).toHaveBeenCalledWith(expect.objectContaining({ + success: false, + data: expect.any(Object), message: expect.stringContaining("Access Denied") })); + expect(mockConn.close).toHaveBeenCalled(); }); }); diff --git a/apps/public-api/src/__tests__/internal.test.js b/apps/public-api/src/__tests__/internal.test.js index cb94e96d0..ab44db84f 100644 --- a/apps/public-api/src/__tests__/internal.test.js +++ b/apps/public-api/src/__tests__/internal.test.js @@ -80,6 +80,7 @@ describe("Internal Routes /api/internal", () => { expect(res.status).toBe(400); expect(res.body.message).toMatch(/Access Denied/); expect(res.body.message).toMatch(/203\.0\.113\.1/); + expect(mockConn.close).toHaveBeenCalled(); }); it("should return 200 on successful connection verification", async () => { @@ -97,7 +98,9 @@ describe("Internal Routes /api/internal", () => { expect(res.status).toBe(200); expect(res.body.success).toBe(true); + expect(res.body.data).toEqual(expect.any(Object)); expect(res.body.message).toMatch(/Connection verified/i); + expect(mockConn.close).toHaveBeenCalled(); }); }); });