Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
139 changes: 121 additions & 18 deletions apps/dashboard-api/src/controllers/project.controller.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
const mongoose = require("mongoose");
const net = require("net");
const dns = require("dns").promises;
const { Project } = require("@urbackend/common");
const { Developer } = require("@urbackend/common");
const { Log } = require("@urbackend/common");
Expand Down Expand Up @@ -483,12 +485,118 @@ const dropCollectionIfExists = async (connection, collectionName) => {
}
};

const isSafeUri = (uri) => {
/**
* 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;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

/**
* 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<boolean>} 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.toLowerCase();
const badHosts = ["localhost", "127.0.0.1", "0.0.0.0", "::1"];
return !badHosts.includes(host);
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;
Comment thread
coderabbitai[bot] marked this conversation as resolved.

return true;
} catch (e) {
return false;
}
Expand All @@ -504,10 +612,11 @@ module.exports.updateExternalConfig = async (req, res) => {
const updateData = {};

if (dbUri) {
if (!isSafeUri(dbUri))
if (!(await isSafeUri(dbUri)))
return res.status(400).json({
error:
"DB URI is pointing to a restricted host (localhost/internal).",
success: false,
data: {},
message: "DB URI is pointing to a restricted host, internal network, or unsupported URI format.",
});

updateData["resources.db.config"] = encrypt(JSON.stringify({ dbUri }));
Expand All @@ -530,11 +639,9 @@ module.exports.updateExternalConfig = async (req, res) => {
) {
const serverIp = await getPublicIp();
errorMsg = `Access Denied: Please whitelist Server IP [${serverIp}] in MongoDB Atlas.`;
} else {
errorMsg += " " + connErr.message;
}

return res.status(400).json({ error: errorMsg });
return res.status(400).json({ success: false, data: {}, message: errorMsg });
}
}

Expand All @@ -557,20 +664,16 @@ module.exports.updateExternalConfig = async (req, res) => {
);

if (!project)
return res
.status(404)
.json({ error: "Project not found or access denied." });
return res.status(404).json({ success: false, data: {}, message: "Project not found or access denied." });

res
.status(200)
.json({ message: "External configuration updated successfully." });
res.status(200).json({ success: true, data: {}, message: "External configuration updated successfully." });
} catch (err) {
if (err instanceof z.ZodError) {
return res.status(400).json({ error: err.issues });
return res.status(400).json({ success: false, data: {}, message: "Invalid request data." });
}

console.error("External Config Error:", err);
res.status(500).json({ error: err.message });
res.status(500).json({ success: false, data: {}, message: "Failed to update external configuration." });
}
};

Expand Down
Loading