Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
24 changes: 16 additions & 8 deletions index.js
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,14 @@
const rewriteHeaders = opts.rewriteHeaders || rewriteHeadersNoOp
const rewriteRequestHeaders = opts.rewriteRequestHeaders || rewriteRequestHeadersNoOp

const url = getReqUrl(source || req.url, cache, base, opts)
let url
try {
url = getReqUrl(source || req.url, cache, base, opts)
} catch (err) {
res.statusCode = 400
res.end(err.message)

Check failure

Code scanning / CodeQL

Reflected cross-site scripting High

Cross-site scripting vulnerability due to a
user-provided value
.
Cross-site scripting vulnerability due to a
user-provided value
.

Check warning

Code scanning / CodeQL

Exception text reinterpreted as HTML Medium

Exception text
is reinterpreted as HTML without escaping meta-characters.
Exception text
is reinterpreted as HTML without escaping meta-characters.
Comment thread
github-advanced-security[bot] marked this conversation as resolved.
Fixed
Comment thread
github-advanced-security[bot] marked this conversation as resolved.
Fixed
return
}
const sourceHttp2 = req.httpVersionMajor === 2
let headers = { ...sourceHttp2 ? filterPseudoHeaders(req.headers) : req.headers }

Expand Down Expand Up @@ -102,10 +109,10 @@
err.code === 'UND_ERR_HEADERS_TIMEOUT' ||
err.code === 'UND_ERR_BODY_TIMEOUT') {
res.statusCode = 504
res.end(err.message)
res.end('Gateway Timeout')
} else {
res.statusCode = 500
res.end(err.message)
res.end('Bad Gateway')
}

return
Expand All @@ -114,13 +121,14 @@
// destructing response from remote
const { headers, statusCode, stream } = response

// Strip hop-by-hop headers for all responses (HTTP/1.1 and HTTP/2).
// Per RFC 7230 Β§6.1, hop-by-hop headers MUST NOT be forwarded by proxies.
const safeHeaders = stripHttp1ConnectionHeaders(headers)

if (sourceHttp2) {
copyHeaders(
rewriteHeaders(stripHttp1ConnectionHeaders(headers)),
res
)
copyHeaders(rewriteHeaders(safeHeaders), res)
} else {
copyHeaders(rewriteHeaders(headers), res)
copyHeaders(rewriteHeaders(safeHeaders), res)
}

// set origin response code
Expand Down
14 changes: 13 additions & 1 deletion lib/utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,19 @@ function buildURL (source = '', reqBase) {
}
const cleanSource = i > 0 ? '/' + source.substring(i) : source

return new URL(cleanSource, reqBase)
const url = new URL(cleanSource, reqBase)

// SSRF prevention: validate that the resolved URL stays within the
// configured base origin. Absolute-form HTTP requests (RFC 7230 Β§5.3.2)
// set req.url to the full URL, which bypasses base via the URL constructor.
if (reqBase) {
const baseUrl = new URL(reqBase)
if (url.origin !== baseUrl.origin) {
throw new Error('SSRF prevention: source "' + source + '" resolves to origin "' + url.origin + '" but base origin is "' + baseUrl.origin + '"')
}
}

return url
}

module.exports = {
Expand Down
2 changes: 1 addition & 1 deletion test/1.smoke.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -121,7 +121,7 @@ describe('fast-proxy smoke', () => {
.expect(200)
.then((response) => {
expect(response.headers['x-agent']).to.equal('fast-proxy')
expect(response.headers.host).to.equal('127.0.0.1:3000')
// host is a request-only header per RFC 7230, stripped from responses
expect(response.headers['x-forwarded-host']).to.equal('127.0.0.1:8080')
})
})
Expand Down
124 changes: 124 additions & 0 deletions test/13.security.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
/* global describe, it */
"use strict";

const { expect } = require("chai");
const net = require("net");
const http = require("http");

describe("Security: buildURL SSRF prevention", () => {
const { buildURL } = require("../lib/utils");

it("should allow relative paths within base", () => {
const url = buildURL("/hi", "http://localhost:3000");
expect(url.origin).to.equal("http://localhost:3000");
});

it("should block absolute URL bypassing base", () => {
expect(() => buildURL("http://evil.com/admin", "http://127.0.0.1:3000"))
.to.throw(/SSRF prevention/);
});

it("should block HTTPS absolute URL bypass", () => {
expect(() => buildURL("https://internal/api", "http://127.0.0.1:3000"))
.to.throw(/SSRF prevention/);
});

it("should allow absolute URL when no base", () => {
const url = buildURL("http://target.com/api");
expect(url.href).to.equal("http://target.com/api");
});

it("should sanitize protocol-relative within base", () => {
const url = buildURL("//evil.com/hi", "http://localhost");
expect(url.origin).to.equal("http://localhost");
});
});

describe("Security: hop-by-hop header stripping", () => {
const { stripHttp1ConnectionHeaders } = require("../lib/utils");

it("should strip transfer-encoding", () => {
const h = { "transfer-encoding": "gzip, chunked", "x-custom": "val" };
const r = stripHttp1ConnectionHeaders(h);
expect(r).to.not.have.property("transfer-encoding");
expect(r).to.have.property("x-custom", "val");
});

it("should strip connection and keep-alive", () => {
const h = { connection: "close", "keep-alive": "t=5", "x-data": "ok" };
const r = stripHttp1ConnectionHeaders(h);
expect(r).to.not.have.property("connection");
expect(r).to.not.have.property("keep-alive");
expect(r).to.have.property("x-data", "ok");
});

it("should strip host header from response", () => {
const h = { host: "evil.com", "content-type": "text/plain" };
const r = stripHttp1ConnectionHeaders(h);
expect(r).to.not.have.property("host");
expect(r).to.have.property("content-type", "text/plain");
});
});

describe("Security: SSRF end-to-end proxy", () => {
let gateway, service, close, proxy, gHttpServer;

it("setup", async () => {
const fastProxy = require("../index")({ base: "http://127.0.0.1:3000" });
close = fastProxy.close;
proxy = fastProxy.proxy;
gateway = require("restana")();
gateway.all("/*", (req, res) => proxy(req, res, req.url, {}));
gHttpServer = await gateway.start(8080);
service = require("restana")();
service.get("/service/get", (req, res) => res.send("OK"));
service.get("/service/evil", (req, res) => {
res.setHeader("transfer-encoding", "gzip, chunked");
res.setHeader("keep-alive", "timeout=99");
res.setHeader("x-custom", "downstream");
res.end("evil");
});
await service.start(3000);
});

it("should block SSRF via absolute-form request", (done) => {
// Use raw http.createServer instead of restana because
// restana routes cannot match absolute-form req.url values.
const fastProxy = require("../index")({ base: "http://127.0.0.1:3000" });
const { proxy, close } = fastProxy;
const server = http.createServer((req, res) => {
proxy(req, res, req.url, {});
});
server.listen(0, () => {
const port = server.address().port;
const c = net.connect(port, "127.0.0.1", () => {
c.write("GET http://169.254.169.254/latest HTTP/1.1\r\n");
c.write("Host: 127.0.0.1\r\n");
c.write("Connection: close\r\n\r\n");
});
let d = "";
c.on("data", ch => { d += ch.toString(); });
c.on("end", () => {
expect(d).to.include("400");
// 400 status + normal proxy still functional after SSRF attempt
close();
server.close();
done();
});
c.on("error", done);
});
});it("should strip hop-by-hop headers end-to-end", async () => {
const res = await require("supertest")(gHttpServer)
.get("/service/evil")
.expect(200);
expect(res.headers["transfer-encoding"]).to.not.equal("gzip, chunked");
expect(res.headers["keep-alive"]).to.not.equal("timeout=99");
expect(res.headers["x-custom"]).to.equal("downstream");
});

it("teardown", async () => {
close();
await gateway.close();
await service.close();
});
});
Loading