Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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
212 changes: 82 additions & 130 deletions apps/dashboard-api/src/controllers/project.controller.js
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down Expand Up @@ -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<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.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;
Expand All @@ -723,25 +606,94 @@ module.exports.updateExternalConfig = async (req, res) => {
updateData["resources.db.isExternal"] = true;

console.log("Verifying connection to:", projectId);

let dashboardIp = null;
let publicApiIp = null;
let tempConn = null;

try {
const tempConn = mongoose.createConnection(dbUri, {
serverSelectionTimeoutMS: 5000,
});
await tempConn.asPromise();
await tempConn.close();
await Promise.race([
new Promise((_, reject) => setTimeout(() => reject(new AppError(408, "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 (!process.env.PUBLIC_API_URL || !process.env.INTERNAL_SECRET) {
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`,
{ dbUri },
{ headers: { 'x-internal-secret': process.env.INTERNAL_SECRET }, timeout: 8000 }
);
})()
Comment thread
yash-pouranik marked this conversation as resolved.
]);
} catch (connErr) {
console.error("Verification Connection Failed:", connErr.message);
console.error("Verification Connection Failed");
let errorMsg = "Could not connect to the provided MongoDB URI.";

if (
connErr.message.includes("Server selection timed out") ||
connErr.message.includes("Could not connect")

try {
dashboardIp = await getPublicIp();
} catch (ipErr) {
console.error("Failed to fetch Dashboard API IP:", ipErr.message);
}

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;
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 && (
connErr.message.includes("Server selection timed out") ||
connErr.message.includes("Could not connect") ||
connErr.message.includes("Overall verification timeout exceeded")
)
) {
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.`;
} 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." });
}

// 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 && 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;
} catch (e) {
console.error("Failed to fetch public API IP for error message", e.message);
}
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

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();
}
}
}

Expand Down
6 changes: 6 additions & 0 deletions apps/public-api/src/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,12 @@ 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', limiter, logger, internalRoute);

app.get('/api/server-ip', async (req, res) => {
const ip = await getPublicIp();
res.json({ ip });
Expand Down
76 changes: 76 additions & 0 deletions apps/public-api/src/routes/internal.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
const express = require('express');
const mongoose = require('mongoose');
const crypto = require('crypto');
const { getPublicIp, isSafeUri } = 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, data: {}, message: "Server misconfiguration" });
}

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(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" });
}

next();
};

router.post('/test-db', internalAuth, async (req, res) => {
const { dbUri } = req.body;
if (!dbUri) {
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.",
});
}

Comment thread
yash-pouranik marked this conversation as resolved.
console.log("[Internal] Verifying connection for external DB...");
let tempConn = null;
try {
tempConn = mongoose.createConnection(dbUri, {
serverSelectionTimeoutMS: 5000,
});
await tempConn.asPromise();
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.";
let serverIp = null;

if (
connErr.message.includes("Server selection timed out") ||
connErr.message.includes("Could not connect")
) {
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, data: { serverIp }, message: errorMsg });
} finally {
if (tempConn) {
await tempConn.close();
}
}
});

module.exports = router;
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,25 @@ 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 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`, { signal: controller.signal })
.then(r => {
if (!r.ok) throw new Error("Public API unresponsive");
return r.json();
})
.finally(() => clearTimeout(timeoutId))
]);

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(', '));
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
catch (e) { console.error("Failed to fetch server IP", e); }
};
Expand Down
3 changes: 2 additions & 1 deletion packages/common/src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -107,7 +107,7 @@ const {
clearCompiledModel,
createUniqueIndexes,
} = require("./utils/injectModel");
const { getPublicIp } = require("./utils/network");
const { getPublicIp, isSafeUri } = require("./utils/network");
const {
isProjectStorageExternal,
isProjectDbExternal,
Expand Down Expand Up @@ -198,6 +198,7 @@ module.exports = {
clearCompiledModel,
createUniqueIndexes,
getPublicIp,
isSafeUri,
isProjectStorageExternal,
isProjectDbExternal,
getBucket,
Expand Down
Loading
Loading