mirror of
https://github.com/outline/outline.git
synced 2026-06-13 03:14:59 +03:00
fix: Update validateUrlNotPrivate to match implementation in SSRF (#12636)
This commit is contained in:
@@ -50,6 +50,41 @@ describe("validateUrlNotPrivate", () => {
|
||||
).rejects.toThrow("is not allowed");
|
||||
});
|
||||
|
||||
it.each([
|
||||
["::ffff:169.254.169.254", "metadata via IPv4-mapped IPv6"],
|
||||
["::ffff:127.0.0.1", "loopback via IPv4-mapped IPv6"],
|
||||
["::ffff:10.0.0.1", "RFC1918 via IPv4-mapped IPv6"],
|
||||
["::ffff:192.168.1.1", "RFC1918 via IPv4-mapped IPv6"],
|
||||
["64:ff9b::a9fe:a9fe", "metadata via NAT64"],
|
||||
["2002:a9fe:a9fe::", "metadata via 6to4"],
|
||||
])("should reject %s in URL (%s)", async (address) => {
|
||||
await expect(
|
||||
validateUrlNotPrivate(`https://[${address}]/api`)
|
||||
).rejects.toThrow("is not allowed");
|
||||
});
|
||||
|
||||
it("should reject IPv4-mapped IPv6 address resolved via DNS", async () => {
|
||||
lookupSpy.mockResolvedValue({
|
||||
address: "::ffff:169.254.169.254",
|
||||
family: 6,
|
||||
});
|
||||
await expect(
|
||||
validateUrlNotPrivate("https://metadata.example.com")
|
||||
).rejects.toThrow("is not allowed");
|
||||
});
|
||||
|
||||
it("should reject carrier-grade NAT address", async () => {
|
||||
await expect(
|
||||
validateUrlNotPrivate("https://100.64.0.1/api")
|
||||
).rejects.toThrow("is not allowed");
|
||||
});
|
||||
|
||||
it("should reject IPv4-mapped IPv6 addresses outright", async () => {
|
||||
await expect(
|
||||
validateUrlNotPrivate("https://[::ffff:8.8.8.8]/")
|
||||
).rejects.toThrow("is not allowed");
|
||||
});
|
||||
|
||||
describe("with ALLOWED_PRIVATE_IP_ADDRESSES", () => {
|
||||
it("should allow exact IP match", async () => {
|
||||
env.ALLOWED_PRIVATE_IP_ADDRESSES = ["10.0.0.1"];
|
||||
|
||||
+6
-11
@@ -7,15 +7,6 @@ import { InvalidRequestError } from "@server/errors";
|
||||
|
||||
const UrlIdLength = 10;
|
||||
|
||||
/** IP ranges that are not allowed for outbound requests. */
|
||||
const privateRanges = new Set([
|
||||
"private",
|
||||
"loopback",
|
||||
"linkLocal",
|
||||
"uniqueLocal",
|
||||
"unspecified",
|
||||
]);
|
||||
|
||||
export const generateUrlId = () => randomString(UrlIdLength);
|
||||
|
||||
// Paths probed by vulnerability scanners.
|
||||
@@ -53,7 +44,9 @@ export function isPrivateIP(ip: string): boolean {
|
||||
if (!ipaddr.isValid(ip)) {
|
||||
return false;
|
||||
}
|
||||
return privateRanges.has(ipaddr.parse(ip).range());
|
||||
|
||||
// Only globally-routable unicast addresses are permitted
|
||||
return ipaddr.parse(ip).range() !== "unicast";
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -102,7 +95,9 @@ function isAllowedPrivateIP(ip: string): boolean {
|
||||
* @throws InternalError if the URL resolves to a private IP that is not allowed.
|
||||
*/
|
||||
export async function validateUrlNotPrivate(url: string) {
|
||||
const { hostname } = new URL(url);
|
||||
// URL.hostname keeps the square brackets around IPv6 literals (e.g.
|
||||
// "[::1]"), which net.isIP does not accept, so strip them before checking.
|
||||
const hostname = new URL(url).hostname.replace(/^\[|\]$/g, "");
|
||||
|
||||
if (net.isIP(hostname)) {
|
||||
if (isPrivateIP(hostname) && !isAllowedPrivateIP(hostname)) {
|
||||
|
||||
Reference in New Issue
Block a user