Skip to content
Open
Show file tree
Hide file tree
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
59 changes: 38 additions & 21 deletions server.js
Original file line number Diff line number Diff line change
Expand Up @@ -19,12 +19,13 @@
summarizeSchema,
summarizeCredentialSchema,
sessionsLookupSchema,
knowledgeGapsSchema,

Check warning on line 22 in server.js

View workflow job for this annotation

GitHub Actions / Backend Tests (Node.js)

'knowledgeGapsSchema' is assigned a value but never used
MAX_QUESTION_LENGTH,
} = require("./validators/schemas");
const { clientIpFromRequest } = require("./security/ip");
const { createRedisClient } = require("./security/redis");
const authRoutes = require("./src/routes/authRoutes");
const { validateURLForSSRF, validateRedirectForSSRF, createSSRFSafeAxiosConfig } = require("./src/utils/ssrfValidation");

Check warning on line 28 in server.js

View workflow job for this annotation

GitHub Actions / Backend Tests (Node.js)

'createSSRFSafeAxiosConfig' is assigned a value but never used

const RAG_SERVICE_URL = process.env.RAG_SERVICE_URL || "http://localhost:5000";
const getInternalRagToken = () => (process.env.INTERNAL_RAG_TOKEN || "").trim();
Expand Down Expand Up @@ -1004,7 +1005,7 @@
});
};

const getTrustedSupabaseOrigin = (hostname) => {

Check warning on line 1008 in server.js

View workflow job for this annotation

GitHub Actions / Backend Tests (Node.js)

'getTrustedSupabaseOrigin' is assigned a value but never used
const normalizedHostname = normalizeHostnameForAllowlist(hostname);
if (!normalizedHostname) return null;

Expand Down Expand Up @@ -1295,23 +1296,6 @@
if (!url || typeof url !== "string") {
return res.status(400).json({ error: "Missing or invalid 'url' field." });
}

// SSRF Protection: Validate URL format, protocol, and hostname.
let parsedUrl;
try {
parsedUrl = new URL(url.trim());
} catch (err) {
return res.status(400).json({ error: "Invalid URL format." });
}

if (parsedUrl.protocol !== "https:") {
return res.status(400).json({ error: "Only HTTPS URLs are allowed." });
}

const trustedSupabaseOrigin = getTrustedSupabaseOrigin(parsedUrl.hostname);
if (!trustedSupabaseOrigin) {
return res.status(403).json({ error: "URL host is not allowed." });
}

if (!filename || typeof filename !== "string") {
return res.status(400).json({ error: "Missing or invalid 'filename' field." });
Expand All @@ -1324,19 +1308,52 @@
.slice(0, 200);

try {
// SSRF Protection: Validate URL format, protocol, hostname, and resolved IPs
let validatedURL;
try {
validatedURL = await validateURLForSSRF(url);
} catch (ssrfErr) {
console.error("SSRF validation failed:", ssrfErr.message);
return res.status(403).json({ error: ssrfErr.message });
}

// Download the PDF from the remote URL into a Buffer
let pdfBuffer;
try {
const downloadUrl = new URL(trustedSupabaseOrigin);
downloadUrl.pathname = parsedUrl.pathname;
downloadUrl.search = parsedUrl.search;
const downloadUrl = new URL(validatedURL.url.toString());

const dlResponse = await axios.get(downloadUrl.toString(), {
responseType: "arraybuffer",
timeout: 30000,
maxContentLength: 50 * 1024 * 1024, // 50 MB cap
maxRedirects: 0, // Disable automatic redirects - SSRF protection
});
pdfBuffer = Buffer.from(dlResponse.data);

// Check for redirect status codes
if (dlResponse.status >= 300 && dlResponse.status < 400) {
const redirectUrl = dlResponse.headers.location;
if (redirectUrl) {
// Validate redirect target before following
try {
await validateRedirectForSSRF(redirectUrl);
// Follow the validated redirect
const redirectResponse = await axios.get(redirectUrl, {
responseType: "arraybuffer",
timeout: 30000,
maxContentLength: 50 * 1024 * 1024,
maxRedirects: 0,
});
pdfBuffer = Buffer.from(redirectResponse.data);
} catch (redirectErr) {
console.error("Redirect validation failed:", redirectErr.message);
return res.status(403).json({ error: "Redirect target is not allowed" });
}
} else {
return res.status(502).json({ error: "Server returned redirect without location header" });
}
} else {
pdfBuffer = Buffer.from(dlResponse.data);
}
Comment thread
Namraa310806 marked this conversation as resolved.
} catch (dlErr) {
console.error("Failed to download PDF from URL:", dlErr.message);
return res.status(502).json({ error: "Could not download PDF from the provided URL." });
Expand Down
161 changes: 161 additions & 0 deletions server.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -495,8 +495,15 @@ describe("route error responses", () => {
test("POST /process-from-url keeps protocol-relative paths on the trusted host", async () => {
const originalGet = axios.get;
const originalPost = axios.post;
const dns = require('dns').promises;
const originalResolve4 = dns.resolve4;
const originalResolve6 = dns.resolve6;
let requestedDownloadUrl = null;

// Mock DNS resolution to return a public IP
dns.resolve4 = async () => ['1.2.3.4'];
dns.resolve6 = async () => [];

axios.get = async (url) => {
requestedDownloadUrl = url;
return { data: Buffer.from("%PDF-1.4\n%%EOF") };
Expand Down Expand Up @@ -535,14 +542,23 @@ describe("route error responses", () => {
} finally {
axios.get = originalGet;
axios.post = originalPost;
dns.resolve4 = originalResolve4;
dns.resolve6 = originalResolve6;
}
});

test("POST /process-from-url accepts whitespace-trimmed Supabase URLs", async () => {
const originalGet = axios.get;
const originalPost = axios.post;
const dns = require('dns').promises;
const originalResolve4 = dns.resolve4;
const originalResolve6 = dns.resolve6;
let requestedDownloadUrl = null;

// Mock DNS resolution to return a public IP
dns.resolve4 = async () => ['1.2.3.4'];
dns.resolve6 = async () => [];

axios.get = async (url) => {
requestedDownloadUrl = url;
return { data: Buffer.from("%PDF-1.4\n%%EOF") };
Expand Down Expand Up @@ -579,6 +595,8 @@ describe("route error responses", () => {
} finally {
axios.get = originalGet;
axios.post = originalPost;
dns.resolve4 = originalResolve4;
dns.resolve6 = originalResolve6;
}
});

Expand Down Expand Up @@ -1331,3 +1349,146 @@ describe("requireSupabaseAuth", () => {
assert.notEqual(res.status, 401, "Valid token should not be rejected");
});
});

describe("SSRF Validation", () => {
let validateURLForSSRF, validateRedirectForSSRF, isPrivateIP, SSRFValidationError;

before(() => {
const ssrfModule = require("./src/utils/ssrfValidation");
validateURLForSSRF = ssrfModule.validateURLForSSRF;
validateRedirectForSSRF = ssrfModule.validateRedirectForSSRF;
isPrivateIP = ssrfModule.isPrivateIP;
SSRFValidationError = ssrfModule.SSRFValidationError;
});

test("rejects HTTP protocol (non-HTTPS)", async () => {
await assert.rejects(
validateURLForSSRF("http://example.supabase.co/test.pdf"),
(err) => {
assert(err instanceof SSRFValidationError);
assert.equal(err.message, "Only HTTPS URLs are allowed");
return true;
}
);
});

test("rejects invalid URL format", async () => {
await assert.rejects(
validateURLForSSRF("not-a-valid-url"),
(err) => {
assert(err instanceof SSRFValidationError);
assert.equal(err.message, "Invalid URL format");
return true;
}
);
});

test("rejects disallowed hostname", async () => {
await assert.rejects(
validateURLForSSRF("https://evil.com/test.pdf"),
(err) => {
assert(err instanceof SSRFValidationError);
assert.equal(err.message, "URL host is not allowed");
return true;
}
);
});

test("rejects hostname without subdomain", async () => {
await assert.rejects(
validateURLForSSRF("https://supabase.co/test.pdf"),
(err) => {
assert(err instanceof SSRFValidationError);
assert.equal(err.message, "URL host is not allowed");
return true;
}
);
});

test("rejects hostname with trailing dot bypass attempt", async () => {
await assert.rejects(
validateURLForSSRF("https://evil.com.supabase.co./test.pdf"),
(err) => {
assert(err instanceof SSRFValidationError);
// The hostname normalization removes the trailing dot, so it becomes
// "evil.com.supabase.co" which is not in the allowlist
assert.match(err.message, /not allowed|DNS resolution/);
return true;
}
);
});

test("rejects private IPv4 addresses - loopback", () => {
assert.equal(isPrivateIP("127.0.0.1"), true);
assert.equal(isPrivateIP("127.0.0.2"), true);
assert.equal(isPrivateIP("127.255.255.255"), true);
});

test("rejects private IPv4 addresses - Class A", () => {
assert.equal(isPrivateIP("10.0.0.1"), true);
assert.equal(isPrivateIP("10.255.255.255"), true);
});

test("rejects private IPv4 addresses - Class B", () => {
assert.equal(isPrivateIP("172.16.0.1"), true);
assert.equal(isPrivateIP("172.31.255.255"), true);
assert.equal(isPrivateIP("172.32.0.1"), false); // Outside range
});

test("rejects private IPv4 addresses - Class C", () => {
assert.equal(isPrivateIP("192.168.0.1"), true);
assert.equal(isPrivateIP("192.168.255.255"), true);
});

test("rejects private IPv4 addresses - link-local", () => {
assert.equal(isPrivateIP("169.254.0.1"), true);
assert.equal(isPrivateIP("169.254.255.255"), true);
});

test("accepts public IPv4 addresses", () => {
assert.equal(isPrivateIP("8.8.8.8"), false);
assert.equal(isPrivateIP("1.1.1.1"), false);
assert.equal(isPrivateIP("172.32.0.1"), false);
});

test("rejects private IPv6 addresses - loopback", () => {
assert.equal(isPrivateIP("::1"), true);
});

test("rejects private IPv6 addresses - unique local", () => {
assert.equal(isPrivateIP("fc00::1"), true);
assert.equal(isPrivateIP("fd00::1"), true);
});

test("rejects private IPv6 addresses - link-local", () => {
assert.equal(isPrivateIP("fe80::1"), true);
assert.equal(isPrivateIP("febf::ffff"), true);
});

test("accepts public IPv6 addresses", () => {
assert.equal(isPrivateIP("2001:4860:4860::8888"), false);
assert.equal(isPrivateIP("2606:4700:4700::1111"), false);
});

test("validateRedirectForSSRF uses same validation as validateURLForSSRF", async () => {
await assert.rejects(
validateRedirectForSSRF("https://evil.com/redirect"),
(err) => {
assert(err instanceof SSRFValidationError);
assert.equal(err.message, "URL host is not allowed");
return true;
}
);
});

test("validateRedirectForSSRF rejects HTTP redirects", async () => {
await assert.rejects(
validateRedirectForSSRF("http://example.supabase.co/redirect"),
(err) => {
assert(err instanceof SSRFValidationError);
assert.equal(err.message, "Only HTTPS URLs are allowed");
return true;
}
);
});
});
24 changes: 24 additions & 0 deletions src/data/users.json
Original file line number Diff line number Diff line change
Expand Up @@ -62,5 +62,29 @@
{
"email": "testuser2-1780859135237@example.com",
"password": "$2b$10$URdT8Tm/Xg7Kwi/UnSiinOWzjsqFc8QCUgpY/TeURTj2T3R1wYW8S"
},
{
"email": "testuser-1781540716124@example.com",
"password": "$2b$10$9iNV0yiIpNM.gJT.lsOa5OpuMxL7hKB.84wloniEj2O.bBpZ7MUjW"
},
{
"email": "testuser2-1781540716507@example.com",
"password": "$2b$10$UcaYkbpnvmLOxUnYIMPBhOx9WM/m0eefhPXDFwcmE0V22fswv5XNa"
},
{
"email": "testuser-1781541360847@example.com",
"password": "$2b$10$1UiWTuwl3W5HispqyqPxDuLYP.f.zZjzINI4wnjoZaUuR3UbY5pcS"
},
{
"email": "testuser2-1781541360962@example.com",
"password": "$2b$10$zMTjccFLerjb5izOiRcIw.KIWr3EiNI98iUTtUDeCthvJc5k3127W"
},
{
"email": "testuser-1781541583462@example.com",
"password": "$2b$10$NZzoL2q8S2sDFN8bM/KUhuUfHcmgekG/uwuoext4Sev80smUIxPoe"
},
{
"email": "testuser2-1781541583647@example.com",
"password": "$2b$10$2DIeGgeCjgcJ37QnmfR5M.kkwFh7MC5XJcddEir68UtqJFDyQV61."
}
]
Loading
Loading