/* -*- 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 * - a stream reset for a connect stream to the proxy does not cause session to * be closed and the request through the proxy will failed. * - a "soft" stream error on a connection to the origin server will close the * stream, but it will not close niether the HTTP/2 session to the proxy nor * to the origin server. * - a "hard" stream error on a connection to the origin server will close the * HTTP/2 session to the origin server, but it will not close the HTTP/2 * session to the proxy. */ /* 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) { 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 createPrincipal(url) { var ssm = Services.scriptSecurityManager; try { return ssm.createContentPrincipal(Services.io.newURI(url), {}); } catch (e) { return null; } } function make_channel(url) { return NetUtil.newChannel({ uri: url, loadingPrincipal: createPrincipal(url), securityFlags: Ci.nsILoadInfo.SEC_ALLOW_CROSS_ORIGIN_INHERITS_SEC_CONTEXT, // 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, delay = 0) { return new Promise(resolve => { var listener = 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 ); if (delay > 0) { do_timeout(delay, function () { channel.asyncOpen(listener); }); } else { channel.asyncOpen(listener); } }); } 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 proxySessionToOriginServersCount() { if (!proxy) { return 0; } return proxy.sessionToOriginServersCount; } static setupProxy() { if (!proxy) { throw new Error("proxy is null"); } proxy.proxy_session_count = 0; proxy.sessionToOriginServersCount = 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; } if (target == "reset.example.com:443") { // always reset the stream. stream.close(0x0); return; } ++proxy.sessionToOriginServersCount; 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 new Error( `Unexpected 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; } async function proxy_session_to_origin_server_counter() { let data = await NodeServer.execute( processId, `http2ProxyCode.proxySessionToOriginServersCount()` ); 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"); let server_port = Services.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 newProxy = await NodeServer.execute( processId, `http2ProxyCode.startNewProxy()` ); proxy_port = newProxy.port; Assert.notEqual(proxy_port, null); Services.prefs.setStringPref( "services.settings.server", `data:,#remote-settings-dummy/v1` ); Services.prefs.setBoolPref("network.http.http2.enabled", 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.http2.enabled"); 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" ); }); add_task(async function proxy_stream_reset_failure() { const { status, http_code, proxy_connect_response_code } = await get_response( make_channel(`https://reset.example.com/`), CL_EXPECT_FAILURE ); Assert.equal(status, Cr.NS_ERROR_NET_INTERRUPT); Assert.equal(proxy_connect_response_code, 0); Assert.equal(http_code, undefined); Assert.equal( await proxy_session_counter(), 1, "No new session created by 429 after 504" ); }); // The soft errors are not closing the session. add_task(async function origin_server_stream_soft_failure() { var current_num_sessions_to_origin_server = await proxy_session_to_origin_server_counter(); const { status, http_code, proxy_connect_response_code } = await get_response( make_channel(`https://foo.example.com/illegalhpacksoft`), CL_EXPECT_FAILURE ); Assert.equal(status, Cr.NS_ERROR_ILLEGAL_VALUE); Assert.equal(proxy_connect_response_code, 200); Assert.equal(http_code, undefined); Assert.equal( await proxy_session_counter(), 1, "No session to the proxy closed by soft stream errors" ); Assert.equal( await proxy_session_to_origin_server_counter(), current_num_sessions_to_origin_server, "No session to the origin server closed by soft stream errors" ); }); // The soft errors are not closing the session. add_task( async function origin_server_stream_soft_failure_multiple_streams_not_affected() { var current_num_sessions_to_origin_server = await proxy_session_to_origin_server_counter(); let should_succeed = get_response( make_channel(`https://foo.example.com/750ms`) ); const failed = await get_response( make_channel(`https://foo.example.com/illegalhpacksoft`), CL_EXPECT_FAILURE, 20 ); const succeeded = await should_succeed; Assert.equal(failed.status, Cr.NS_ERROR_ILLEGAL_VALUE); Assert.equal(failed.proxy_connect_response_code, 200); Assert.equal(failed.http_code, undefined); Assert.equal(succeeded.status, Cr.NS_OK); Assert.equal(succeeded.proxy_connect_response_code, 200); Assert.equal(succeeded.http_code, 200); Assert.equal( await proxy_session_counter(), 1, "No session to the proxy closed by soft stream errors" ); Assert.equal( await proxy_session_to_origin_server_counter(), current_num_sessions_to_origin_server, "No session to the origin server closed by soft stream errors" ); } ); // Make sure that the above error codes don't kill the session to the proxy. 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 to the proxy created after stream 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" ); }); add_task(async function proxy_success_check_number_of_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`) ); 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, "The number of sessions has not changed" ); }); // The hard errors are closing the session. add_task(async function origin_server_stream_hard_failure() { var current_num_sessions_to_origin_server = await proxy_session_to_origin_server_counter(); const { status, http_code, proxy_connect_response_code } = await get_response( make_channel(`https://foo.example.com/illegalhpackhard`), CL_EXPECT_FAILURE ); Assert.equal(status, 0x804b0053); Assert.equal(proxy_connect_response_code, 200); Assert.equal(http_code, undefined); Assert.equal( await proxy_session_counter(), 2, "No new session to the proxy." ); Assert.equal( await proxy_session_to_origin_server_counter(), current_num_sessions_to_origin_server, "No new session to the origin server yet." ); // Check the a new session ill be opened. const foo = await get_response( make_channel(`https://foo.example.com/random-request-1`) ); 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( await proxy_session_counter(), 2, "No new session to the proxy is created after a hard stream failure on the session to the origin server." ); Assert.equal( await proxy_session_to_origin_server_counter(), current_num_sessions_to_origin_server + 1, "A new session to the origin server after a hard stream error" ); }); // The hard errors are closing the session. add_task( async function origin_server_stream_hard_failure_multiple_streams_affected() { var current_num_sessions_to_origin_server = await proxy_session_to_origin_server_counter(); let should_fail = get_response( make_channel(`https://foo.example.com/750msNoData`), CL_EXPECT_FAILURE ); const failed1 = await get_response( make_channel(`https://foo.example.com/illegalhpackhard`), CL_EXPECT_FAILURE, 10 ); const failed2 = await should_fail; Assert.equal(failed1.status, 0x804b0053); Assert.equal(failed1.proxy_connect_response_code, 200); Assert.equal(failed1.http_code, undefined); Assert.equal(failed2.status, 0x804b0053); Assert.equal(failed2.proxy_connect_response_code, 200); Assert.equal(failed2.http_code, undefined); Assert.equal( await proxy_session_counter(), 2, "No new session to the proxy" ); Assert.equal( await proxy_session_to_origin_server_counter(), current_num_sessions_to_origin_server, "No session to the origin server yet." ); // Check the a new session ill be opened. const foo = await get_response( make_channel(`https://foo.example.com/random-request-1`) ); 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( await proxy_session_counter(), 2, "No new session to the proxy is created after a hard stream failure on the session to the origin server." ); Assert.equal( await proxy_session_to_origin_server_counter(), current_num_sessions_to_origin_server + 1, "A new session to the origin server after a hard stream error" ); } );