diff options
Diffstat (limited to 'netwerk/test/unit/test_http2-proxy.js')
-rw-r--r-- | netwerk/test/unit/test_http2-proxy.js | 615 |
1 files changed, 615 insertions, 0 deletions
diff --git a/netwerk/test/unit/test_http2-proxy.js b/netwerk/test/unit/test_http2-proxy.js new file mode 100644 index 0000000000..1c9d6151be --- /dev/null +++ b/netwerk/test/unit/test_http2-proxy.js @@ -0,0 +1,615 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim:set ts=2 sw=2 sts=2 et: */ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +/** + * This test checks following expectations when using HTTP/2 proxy: + * + * - when we request https access, we don't create different sessions for + * different origins, only new tunnels inside a single session + * - when the isolation key (`proxy_isolation`) is changed, new single session + * is created for new requests to same origins as before + * - error code returned from the tunnel (a proxy error - not end-server + * error!) doesn't kill the existing session + * - check we are seeing expected nsresult error codes on channels + * (nsIChannel.status) corresponding to different proxy status code + * responses (502, 504, 407, ...) + * - check we don't try to ask for credentials or otherwise authenticate to + * the proxy when 407 is returned and there is no Proxy-Authenticate + * response header sent + */ + +/* eslint-env node */ +/* global serverPort */ + +"use strict"; + +const pps = Cc["@mozilla.org/network/protocol-proxy-service;1"].getService(); + +let proxy_port; +let filter; +let proxy; + +// See moz-http2 +const proxy_auth = "authorization-token"; +let proxy_isolation; + +class ProxyFilter { + constructor(type, host, port, flags) { + this._type = type; + this._host = host; + this._port = port; + this._flags = flags; + this.QueryInterface = ChromeUtils.generateQI(["nsIProtocolProxyFilter"]); + } + applyFilter(uri, pi, cb) { + if ( + uri.pathQueryRef.startsWith("/execute") || + uri.pathQueryRef.startsWith("/fork") || + uri.pathQueryRef.startsWith("/kill") + ) { + // So we allow NodeServer.execute to work + cb.onProxyFilterResult(pi); + return; + } + cb.onProxyFilterResult( + pps.newProxyInfo( + this._type, + this._host, + this._port, + proxy_auth, + proxy_isolation, + this._flags, + 1000, + null + ) + ); + } +} + +class UnxpectedAuthPrompt2 { + constructor(signal) { + this.signal = signal; + this.QueryInterface = ChromeUtils.generateQI(["nsIAuthPrompt2"]); + } + asyncPromptAuth() { + this.signal.triggered = true; + throw Components.Exception("", Cr.ERROR_UNEXPECTED); + } +} + +class SimpleAuthPrompt2 { + constructor(signal) { + this.signal = signal; + this.QueryInterface = ChromeUtils.generateQI(["nsIAuthPrompt2"]); + } + asyncPromptAuth(channel, callback, context, encryptionLevel, authInfo) { + this.signal.triggered = true; + executeSoon(function() { + authInfo.username = "user"; + authInfo.password = "pass"; + callback.onAuthAvailable(context, authInfo); + }); + } +} + +class AuthRequestor { + constructor(prompt) { + this.prompt = prompt; + this.QueryInterface = ChromeUtils.generateQI(["nsIInterfaceRequestor"]); + } + getInterface(iid) { + if (iid.equals(Ci.nsIAuthPrompt2)) { + return this.prompt(); + } + throw Components.Exception("", Cr.NS_ERROR_NO_INTERFACE); + } +} + +function make_channel(url) { + return NetUtil.newChannel({ + uri: url, + loadUsingSystemPrincipal: true, + // Using TYPE_DOCUMENT for the authentication dialog test, it'd be blocked for other types + contentPolicyType: Ci.nsIContentPolicy.TYPE_DOCUMENT, + }); +} + +function get_response(channel, flags = CL_ALLOW_UNKNOWN_CL) { + return new Promise(resolve => { + channel.asyncOpen( + new ChannelListener( + (request, data) => { + request.QueryInterface(Ci.nsIHttpChannel); + const status = request.status; + const http_code = status ? undefined : request.responseStatus; + request.QueryInterface(Ci.nsIProxiedChannel); + const proxy_connect_response_code = + request.httpProxyConnectResponseCode; + resolve({ status, http_code, data, proxy_connect_response_code }); + }, + null, + flags + ) + ); + }); +} + +let initial_session_count = 0; + +class http2ProxyCode { + static listen(server, envport) { + if (!server) { + return Promise.resolve(0); + } + + let portSelection = 0; + if (envport !== undefined) { + try { + portSelection = parseInt(envport, 10); + } catch (e) { + portSelection = -1; + } + } + return new Promise(resolve => { + server.listen(portSelection, "0.0.0.0", 2000, () => { + resolve(server.address().port); + }); + }); + } + + static startNewProxy() { + const fs = require("fs"); + const options = { + key: fs.readFileSync(__dirname + "/http2-cert.key"), + cert: fs.readFileSync(__dirname + "/http2-cert.pem"), + }; + const http2 = require("http2"); + global.proxy = http2.createSecureServer(options); + this.setupProxy(); + return http2ProxyCode.listen(proxy).then(port => { + return { port, success: true }; + }); + } + + static closeProxy() { + proxy.closeSockets(); + return new Promise(resolve => { + proxy.close(resolve); + }); + } + + static proxySessionCount() { + if (!proxy) { + return 0; + } + return proxy.proxy_session_count; + } + + static setupProxy() { + if (!proxy) { + throw new Error("proxy is null"); + } + proxy.proxy_session_count = 0; + proxy.on("session", () => { + ++proxy.proxy_session_count; + }); + + // We need to track active connections so we can forcefully close keep-alive + // connections when shutting down the proxy. + proxy.socketIndex = 0; + proxy.socketMap = {}; + proxy.on("connection", function(socket) { + let index = proxy.socketIndex++; + proxy.socketMap[index] = socket; + socket.on("close", function() { + delete proxy.socketMap[index]; + }); + }); + proxy.closeSockets = function() { + for (let i in proxy.socketMap) { + proxy.socketMap[i].destroy(); + } + }; + + proxy.on("stream", (stream, headers) => { + if (headers[":method"] !== "CONNECT") { + // Only accept CONNECT requests + stream.respond({ ":status": 405 }); + stream.end(); + return; + } + + const target = headers[":authority"]; + + const authorization_token = headers["proxy-authorization"]; + if (target == "407.example.com:443") { + stream.respond({ ":status": 407 }); + // Deliberately send no Proxy-Authenticate header + stream.end(); + return; + } + if (target == "407.basic.example.com:443") { + // we want to return a different response than 407 to not re-request + // credentials (and thus loop) but also not 200 to not let the channel + // attempt to waste time connecting a non-existing https server - hence + // 418 I'm a teapot :) + if ("Basic dXNlcjpwYXNz" == authorization_token) { + stream.respond({ ":status": 418 }); + stream.end(); + return; + } + stream.respond({ + ":status": 407, + "proxy-authenticate": "Basic realm='foo'", + }); + stream.end(); + return; + } + if (target == "404.example.com:443") { + // 404 Not Found, a response code that a proxy should return when the host can't be found + stream.respond({ ":status": 404 }); + stream.end(); + return; + } + if (target == "429.example.com:443") { + // 429 Too Many Requests, a response code that a proxy should return when receiving too many requests + stream.respond({ ":status": 429 }); + stream.end(); + return; + } + if (target == "502.example.com:443") { + // 502 Bad Gateway, a response code mostly resembling immediate connection error + stream.respond({ ":status": 502 }); + stream.end(); + return; + } + if (target == "504.example.com:443") { + // 504 Gateway Timeout, did not receive a timely response from an upstream server + stream.respond({ ":status": 504 }); + stream.end(); + return; + } + + const net = require("net"); + const socket = net.connect(serverPort, "127.0.0.1", () => { + try { + stream.respond({ ":status": 200 }); + socket.pipe(stream); + stream.pipe(socket); + } catch (exception) { + console.log(exception); + stream.close(); + } + }); + socket.on("error", error => { + throw `Unxpected error when conneting the HTTP/2 server from the HTTP/2 proxy during CONNECT handling: '${error}'`; + }); + }); + } +} + +async function proxy_session_counter() { + let data = await NodeServer.execute( + processId, + `http2ProxyCode.proxySessionCount()` + ); + return parseInt(data) - initial_session_count; +} +let processId; +add_task(async function setup() { + // Set to allow the cert presented by our H2 server + do_get_profile(); + + // The moz-http2 cert is for foo.example.com and is signed by http2-ca.pem + // so add that cert to the trust list as a signing cert. + let certdb = Cc["@mozilla.org/security/x509certdb;1"].getService( + Ci.nsIX509CertDB + ); + addCertFromFile(certdb, "http2-ca.pem", "CTu,u,u"); + + const env = Cc["@mozilla.org/process/environment;1"].getService( + Ci.nsIEnvironment + ); + let server_port = env.get("MOZHTTP2_PORT"); + Assert.notEqual(server_port, null); + processId = await NodeServer.fork(); + await NodeServer.execute(processId, `serverPort = ${server_port}`); + await NodeServer.execute(processId, http2ProxyCode); + let proxy = await NodeServer.execute( + processId, + `http2ProxyCode.startNewProxy()` + ); + proxy_port = proxy.port; + Assert.notEqual(proxy_port, null); + + Services.prefs.setStringPref( + "services.settings.server", + `data:text/html,test` + ); + + Services.prefs.setBoolPref("network.http.spdy.enabled", true); + Services.prefs.setBoolPref("network.http.spdy.enabled.http2", true); + + // Even with network state isolation active, we don't end up using the + // partitioned principal. + Services.prefs.setBoolPref("privacy.partition.network_state", true); + + // make all native resolve calls "secretly" resolve localhost instead + Services.prefs.setBoolPref("network.dns.native-is-localhost", true); + + filter = new ProxyFilter("https", "localhost", proxy_port, 0); + pps.registerFilter(filter, 10); + + initial_session_count = await proxy_session_counter(); + info(`Initial proxy session count = ${initial_session_count}`); +}); + +registerCleanupFunction(async () => { + Services.prefs.clearUserPref("services.settings.server"); + Services.prefs.clearUserPref("network.http.spdy.enabled"); + Services.prefs.clearUserPref("network.http.spdy.enabled.http2"); + Services.prefs.clearUserPref("network.dns.native-is-localhost"); + + pps.unregisterFilter(filter); + + await NodeServer.execute(processId, `http2ProxyCode.closeProxy()`); + await NodeServer.kill(processId); +}); + +/** + * Test series beginning. + */ + +// Check we reach the h2 end server and keep only one session with the proxy for two different origin. +// Here we use the first isolation token. +add_task(async function proxy_success_one_session() { + proxy_isolation = "TOKEN1"; + + const foo = await get_response( + make_channel(`https://foo.example.com/random-request-1`) + ); + const alt1 = await get_response( + make_channel(`https://alt1.example.com/random-request-2`) + ); + + Assert.equal(foo.status, Cr.NS_OK); + Assert.equal(foo.proxy_connect_response_code, 200); + Assert.equal(foo.http_code, 200); + Assert.ok(foo.data.match("random-request-1")); + Assert.ok(foo.data.match("You Win!")); + Assert.equal(alt1.status, Cr.NS_OK); + Assert.equal(alt1.proxy_connect_response_code, 200); + Assert.equal(alt1.http_code, 200); + Assert.ok(alt1.data.match("random-request-2")); + Assert.ok(alt1.data.match("You Win!")); + Assert.equal( + await proxy_session_counter(), + 1, + "Created just one session with the proxy" + ); +}); + +// The proxy responses with 407 instead of 200 Connected, make sure we get a proper error +// code from the channel and not try to ask for any credentials. +add_task(async function proxy_auth_failure() { + const chan = make_channel(`https://407.example.com/`); + const auth_prompt = { triggered: false }; + chan.notificationCallbacks = new AuthRequestor( + () => new UnxpectedAuthPrompt2(auth_prompt) + ); + const { status, http_code, proxy_connect_response_code } = await get_response( + chan, + CL_EXPECT_FAILURE + ); + + Assert.equal(status, Cr.NS_ERROR_PROXY_AUTHENTICATION_FAILED); + Assert.equal(proxy_connect_response_code, 407); + Assert.equal(http_code, undefined); + Assert.equal(auth_prompt.triggered, false, "Auth prompt didn't trigger"); + Assert.equal( + await proxy_session_counter(), + 1, + "No new session created by 407" + ); +}); + +// The proxy responses with 407 with Proxy-Authenticate header presence. Make +// sure that we prompt the auth prompt to ask for credentials. +add_task(async function proxy_auth_basic() { + const chan = make_channel(`https://407.basic.example.com/`); + const auth_prompt = { triggered: false }; + chan.notificationCallbacks = new AuthRequestor( + () => new SimpleAuthPrompt2(auth_prompt) + ); + const { status, http_code, proxy_connect_response_code } = await get_response( + chan, + CL_EXPECT_FAILURE + ); + + // 418 indicates we pass the basic authentication. + Assert.equal(status, Cr.NS_ERROR_PROXY_CONNECTION_REFUSED); + Assert.equal(proxy_connect_response_code, 418); + Assert.equal(http_code, undefined); + Assert.equal(auth_prompt.triggered, true, "Auth prompt should trigger"); + Assert.equal( + await proxy_session_counter(), + 1, + "No new session created by 407" + ); +}); + +// 502 Bad gateway code returned by the proxy, still one session only, proper different code +// from the channel. +add_task(async function proxy_bad_gateway_failure() { + const { status, http_code, proxy_connect_response_code } = await get_response( + make_channel(`https://502.example.com/`), + CL_EXPECT_FAILURE + ); + + Assert.equal(status, Cr.NS_ERROR_PROXY_BAD_GATEWAY); + Assert.equal(proxy_connect_response_code, 502); + Assert.equal(http_code, undefined); + Assert.equal( + await proxy_session_counter(), + 1, + "No new session created by 502 after 407" + ); +}); + +// Second 502 Bad gateway code returned by the proxy, still one session only with the proxy. +add_task(async function proxy_bad_gateway_failure_two() { + const { status, http_code, proxy_connect_response_code } = await get_response( + make_channel(`https://502.example.com/`), + CL_EXPECT_FAILURE + ); + + Assert.equal(status, Cr.NS_ERROR_PROXY_BAD_GATEWAY); + Assert.equal(proxy_connect_response_code, 502); + Assert.equal(http_code, undefined); + Assert.equal( + await proxy_session_counter(), + 1, + "No new session created by second 502" + ); +}); + +// 504 Gateway timeout code returned by the proxy, still one session only, proper different code +// from the channel. +add_task(async function proxy_gateway_timeout_failure() { + const { status, http_code, proxy_connect_response_code } = await get_response( + make_channel(`https://504.example.com/`), + CL_EXPECT_FAILURE + ); + + Assert.equal(status, Cr.NS_ERROR_PROXY_GATEWAY_TIMEOUT); + Assert.equal(proxy_connect_response_code, 504); + Assert.equal(http_code, undefined); + Assert.equal( + await proxy_session_counter(), + 1, + "No new session created by 504 after 502" + ); +}); + +// 404 Not Found means the proxy could not resolve the host. As for other error responses +// we still expect this not to close the existing session. +add_task(async function proxy_host_not_found_failure() { + const { status, http_code, proxy_connect_response_code } = await get_response( + make_channel(`https://404.example.com/`), + CL_EXPECT_FAILURE + ); + + Assert.equal(status, Cr.NS_ERROR_UNKNOWN_HOST); + Assert.equal(proxy_connect_response_code, 404); + Assert.equal(http_code, undefined); + Assert.equal( + await proxy_session_counter(), + 1, + "No new session created by 404 after 504" + ); +}); + +add_task(async function proxy_too_many_requests_failure() { + const { status, http_code, proxy_connect_response_code } = await get_response( + make_channel(`https://429.example.com/`), + CL_EXPECT_FAILURE + ); + + Assert.equal(status, Cr.NS_ERROR_PROXY_TOO_MANY_REQUESTS); + Assert.equal(proxy_connect_response_code, 429); + Assert.equal(http_code, undefined); + Assert.equal( + await proxy_session_counter(), + 1, + "No new session created by 429 after 504" + ); +}); + +// Make sure that the above error codes don't kill the session and we still reach the end server +add_task(async function proxy_success_still_one_session() { + const foo = await get_response( + make_channel(`https://foo.example.com/random-request-1`) + ); + const alt1 = await get_response( + make_channel(`https://alt1.example.com/random-request-2`) + ); + + Assert.equal(foo.status, Cr.NS_OK); + Assert.equal(foo.http_code, 200); + Assert.equal(foo.proxy_connect_response_code, 200); + Assert.ok(foo.data.match("random-request-1")); + Assert.equal(alt1.status, Cr.NS_OK); + Assert.equal(alt1.proxy_connect_response_code, 200); + Assert.equal(alt1.http_code, 200); + Assert.ok(alt1.data.match("random-request-2")); + Assert.equal( + await proxy_session_counter(), + 1, + "No new session created after proxy error codes" + ); +}); + +// Have a new isolation key, this means we are expected to create a new, and again one only, +// session with the proxy to reach the end server. +add_task(async function proxy_success_isolated_session() { + Assert.notEqual(proxy_isolation, "TOKEN2"); + proxy_isolation = "TOKEN2"; + + const foo = await get_response( + make_channel(`https://foo.example.com/random-request-1`) + ); + const alt1 = await get_response( + make_channel(`https://alt1.example.com/random-request-2`) + ); + const lh = await get_response( + make_channel(`https://localhost/random-request-3`) + ); + + Assert.equal(foo.status, Cr.NS_OK); + Assert.equal(foo.proxy_connect_response_code, 200); + Assert.equal(foo.http_code, 200); + Assert.ok(foo.data.match("random-request-1")); + Assert.ok(foo.data.match("You Win!")); + Assert.equal(alt1.status, Cr.NS_OK); + Assert.equal(alt1.proxy_connect_response_code, 200); + Assert.equal(alt1.http_code, 200); + Assert.ok(alt1.data.match("random-request-2")); + Assert.ok(alt1.data.match("You Win!")); + Assert.equal(lh.status, Cr.NS_OK); + Assert.equal(lh.proxy_connect_response_code, 200); + Assert.equal(lh.http_code, 200); + Assert.ok(lh.data.match("random-request-3")); + Assert.ok(lh.data.match("You Win!")); + Assert.equal( + await proxy_session_counter(), + 2, + "Just one new session seen after changing the isolation key" + ); +}); + +// Check that error codes are still handled the same way with new isolation, just in case. +add_task(async function proxy_bad_gateway_failure_isolated() { + const failure1 = await get_response( + make_channel(`https://502.example.com/`), + CL_EXPECT_FAILURE + ); + const failure2 = await get_response( + make_channel(`https://502.example.com/`), + CL_EXPECT_FAILURE + ); + + Assert.equal(failure1.status, Cr.NS_ERROR_PROXY_BAD_GATEWAY); + Assert.equal(failure1.proxy_connect_response_code, 502); + Assert.equal(failure1.http_code, undefined); + Assert.equal(failure2.status, Cr.NS_ERROR_PROXY_BAD_GATEWAY); + Assert.equal(failure2.proxy_connect_response_code, 502); + Assert.equal(failure2.http_code, undefined); + Assert.equal( + await proxy_session_counter(), + 2, + "No new session created by 502" + ); +}); |