Bug 1826198 - [devtools] Add an optional auth prompt listener to the NetworkObserver r=bomsy,devtools-reviewers,valentin,necko-reviewers

Differential Revision: https://phabricator.services.mozilla.com/D189515
This commit is contained in:
Julian Descottes 2023-11-27 11:27:20 +00:00
parent 1f1c8ec3da
commit 1a70dd3342
9 changed files with 662 additions and 21 deletions

View file

@ -0,0 +1,185 @@
/* 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/. */
const lazy = {};
ChromeUtils.defineESModuleGetters(lazy, {
setTimeout: "resource://gre/modules/Timer.sys.mjs",
});
/**
* This class is a simplified version from the AuthRequestor used by the
* WebExtensions codebase at:
* https://searchfox.org/mozilla-central/rev/fd2325f5b2a5be8f8f2acf9307285f2b7de06582/toolkit/components/extensions/webrequest/WebRequest.sys.mjs#434-579
*
* The NetworkAuthListener will monitor the provided channel and will invoke the
* owner's `onAuthPrompt` end point whenever an auth challenge is requested.
*
* The owner will receive several callbacks to proceed with the prompt:
* - cancelAuthPrompt(): cancel the authentication attempt
* - forwardAuthPrompt(): forward the auth prompt request to the next
* notification callback. If no other custom callback is set, this will
* typically lead to show the auth prompt dialog in the browser UI.
* - provideAuthCredentials(username, password): attempt to authenticate with
* the provided username and password.
*
* Please note that the request will be blocked until the consumer calls one of
* the callbacks listed above. Make sure to eventually unblock the request if
* you implement `onAuthPrompt`.
*
* @param {nsIChannel} channel
* The channel to monitor.
* @param {object} owner
* The owner object, expected to implement `onAuthPrompt`.
*/
export class NetworkAuthListener {
constructor(channel, owner) {
this.notificationCallbacks = channel.notificationCallbacks;
this.loadGroupCallbacks =
channel.loadGroup && channel.loadGroup.notificationCallbacks;
this.owner = owner;
// Setup the channel's notificationCallbacks to be handled by this instance.
channel.notificationCallbacks = this;
}
// See https://searchfox.org/mozilla-central/source/netwerk/base/nsIAuthPrompt2.idl
asyncPromptAuth(channel, callback, context, level, authInfo) {
const isProxy = !!(authInfo.flags & authInfo.AUTH_PROXY);
const cancelAuthPrompt = () => {
if (channel.canceled) {
return;
}
try {
callback.onAuthCancelled(context, false);
} catch (e) {
console.error(`NetworkAuthListener failed to cancel auth prompt ${e}`);
}
};
const forwardAuthPrompt = () => {
if (channel.canceled) {
return;
}
const prompt = this.#getForwardPrompt(isProxy);
prompt.asyncPromptAuth(channel, callback, context, level, authInfo);
};
const provideAuthCredentials = (username, password) => {
if (channel.canceled) {
return;
}
authInfo.username = username;
authInfo.password = password;
try {
callback.onAuthAvailable(context, authInfo);
} catch (e) {
console.error(
`NetworkAuthListener failed to provide auth credentials ${e}`
);
}
};
const authDetails = {
isProxy,
realm: authInfo.realm,
scheme: authInfo.authenticationScheme,
};
const authCallbacks = {
cancelAuthPrompt,
forwardAuthPrompt,
provideAuthCredentials,
};
// The auth callbacks may only be called asynchronously after this method
// successfully returned.
lazy.setTimeout(() => this.#notifyOwner(authDetails, authCallbacks), 1);
return {
QueryInterface: ChromeUtils.generateQI(["nsICancelable"]),
cancel: cancelAuthPrompt,
};
}
getInterface(iid) {
if (iid.equals(Ci.nsIAuthPromptProvider) || iid.equals(Ci.nsIAuthPrompt2)) {
return this;
}
try {
return this.notificationCallbacks.getInterface(iid);
} catch (e) {}
throw Components.Exception("", Cr.NS_ERROR_NO_INTERFACE);
}
// See https://searchfox.org/mozilla-central/source/netwerk/base/nsIAuthPromptProvider.idl
getAuthPrompt(reason, iid) {
// This should never get called without getInterface having been called first.
if (iid.equals(Ci.nsIAuthPrompt2)) {
return this;
}
return this.#getForwardedInterface(Ci.nsIAuthPromptProvider).getAuthPrompt(
reason,
iid
);
}
// See https://searchfox.org/mozilla-central/source/netwerk/base/nsIAuthPrompt2.idl
promptAuth(channel, level, authInfo) {
this.#getForwardedInterface(Ci.nsIAuthPrompt2).promptAuth(
channel,
level,
authInfo
);
}
#getForwardedInterface(iid) {
try {
return this.notificationCallbacks.getInterface(iid);
} catch (e) {
return this.loadGroupCallbacks.getInterface(iid);
}
}
#getForwardPrompt(isProxy) {
const reason = isProxy
? Ci.nsIAuthPromptProvider.PROMPT_PROXY
: Ci.nsIAuthPromptProvider.PROMPT_NORMAL;
for (const callbacks of [
this.notificationCallbacks,
this.loadGroupCallbacks,
]) {
try {
return callbacks
.getInterface(Ci.nsIAuthPromptProvider)
.getAuthPrompt(reason, Ci.nsIAuthPrompt2);
} catch (e) {}
try {
return callbacks.getInterface(Ci.nsIAuthPrompt2);
} catch (e) {}
}
throw Components.Exception("", Cr.NS_ERROR_NO_INTERFACE);
}
#notifyOwner(authDetails, authCallbacks) {
if (typeof this.owner.onAuthPrompt == "function") {
this.owner.onAuthPrompt(authDetails, authCallbacks);
} else {
console.error(
"NetworkObserver owner enabled the auth prompt listener " +
"but does not implement 'onAuthPrompt'. " +
"Forwarding the auth prompt to the next notification callback."
);
authCallbacks.forwardAuthPrompt();
}
}
}
NetworkAuthListener.prototype.QueryInterface = ChromeUtils.generateQI([
"nsIInterfaceRequestor",
"nsIAuthPromptProvider",
"nsIAuthPrompt2",
]);

View file

@ -21,6 +21,8 @@ import { DevToolsInfaillibleUtils } from "resource://devtools/shared/DevToolsInf
ChromeUtils.defineESModuleGetters(lazy, {
ChannelMap: "resource://devtools/shared/network-observer/ChannelMap.sys.mjs",
NetworkAuthListener:
"resource://devtools/shared/network-observer/NetworkAuthListener.sys.mjs",
NetworkHelper:
"resource://devtools/shared/network-observer/NetworkHelper.sys.mjs",
NetworkOverride:
@ -123,6 +125,12 @@ export class NetworkObserver {
* @type {Map}
*/
#decodedCertificateCache = new Map();
/**
* Whether the consumer supports listening and handling auth prompts.
*
* @type {boolean}
*/
#authPromptListenerEnabled = false;
/**
* See constructor argument of the same name.
*
@ -229,6 +237,10 @@ export class NetworkObserver {
);
}
setAuthPromptListenerEnabled(enabled) {
this.#authPromptListenerEnabled = enabled;
}
setSaveRequestAndResponseBodies(save) {
this.#saveRequestAndResponseBodies = save;
}
@ -705,6 +717,10 @@ export class NetworkObserver {
});
}
if (this.#authPromptListenerEnabled) {
new lazy.NetworkAuthListener(httpActivity.channel, httpActivity.owner);
}
return httpActivity;
}
@ -1375,6 +1391,10 @@ export class NetworkObserver {
* listening.
*/
destroy() {
if (this.#isDestroyed) {
return;
}
if (Services.appinfo.processType != Ci.nsIXULRuntime.PROCESS_TYPE_CONTENT) {
gActivityDistributor.removeObserver(this);
Services.obs.removeObserver(

View file

@ -10,6 +10,7 @@ XPCSHELL_TESTS_MANIFESTS += ["test/xpcshell/xpcshell.toml"]
DevToolsModules(
"ChannelMap.sys.mjs",
"NetworkAuthListener.sys.mjs",
"NetworkHelper.sys.mjs",
"NetworkObserver.sys.mjs",
"NetworkOverride.sys.mjs",

View file

@ -7,6 +7,7 @@ support-files = [
"gzipped.sjs",
"override.html",
"override.js",
"sjs_network-auth-listener-test-server.sjs",
"sjs_network-observer-test-server.sjs",
]
@ -16,6 +17,8 @@ skip-if = [
"http2",
]
["browser_networkobserver_auth_listener.js"]
["browser_networkobserver_invalid_constructor.js"]
["browser_networkobserver_override.js"]

View file

@ -17,8 +17,8 @@ add_task(async function testSingleRequest() {
content.wrappedJSObject.sendRequest(_url);
});
const eventsCount = await onNetworkEvents;
is(eventsCount, 1, "Received the expected number of network events");
const events = await onNetworkEvents;
is(events.length, 1, "Received the expected number of network events");
});
add_task(async function testMultipleRequests() {
@ -39,9 +39,9 @@ add_task(async function testMultipleRequests() {
}
);
const eventsCount = await onNetworkEvents;
const events = await onNetworkEvents;
is(
eventsCount,
events.length,
EXPECTED_REQUESTS_COUNT,
"Received the expected number of network events"
);

View file

@ -0,0 +1,374 @@
/* Any copyright is dedicated to the Public Domain.
http://creativecommons.org/publicdomain/zero/1.0/ */
"use strict";
const { PromptTestUtils } = ChromeUtils.importESModule(
"resource://testing-common/PromptTestUtils.sys.mjs"
);
const TEST_URL = URL_ROOT + "doc_network-observer.html";
const AUTH_URL = URL_ROOT + `sjs_network-auth-listener-test-server.sjs`;
// Correct credentials for sjs_network-auth-listener-test-server.sjs.
const USERNAME = "guest";
const PASSWORD = "guest";
const BAD_PASSWORD = "bad";
// NetworkEventOwner which will cancel all auth prompt requests.
class AuthCancellingOwner extends NetworkEventOwner {
hasAuthPrompt = false;
onAuthPrompt(authDetails, authCallbacks) {
this.hasAuthPrompt = true;
authCallbacks.cancelAuthPrompt();
}
}
// NetworkEventOwner which will forward all auth prompt requests to the browser.
class AuthForwardingOwner extends NetworkEventOwner {
hasAuthPrompt = false;
onAuthPrompt(authDetails, authCallbacks) {
this.hasAuthPrompt = true;
authCallbacks.forwardAuthPrompt();
}
}
// NetworkEventOwner which will answer provided credentials to auth prompts.
class AuthCredentialsProvidingOwner extends NetworkEventOwner {
hasAuthPrompt = false;
constructor(channel, username, password) {
super();
this.channel = channel;
this.username = username;
this.password = password;
}
async onAuthPrompt(authDetails, authCallbacks) {
this.hasAuthPrompt = true;
// Providing credentials immediately can lead to intermittent failures.
// TODO: Investigate and remove.
// eslint-disable-next-line mozilla/no-arbitrary-setTimeout
await new Promise(r => setTimeout(r, 100));
await authCallbacks.provideAuthCredentials(this.username, this.password);
}
addResponseContent(content) {
super.addResponseContent();
this.responseContent = content.text;
}
}
add_task(async function testAuthRequestWithoutListener() {
cleanupAuthManager();
const tab = await addTab(TEST_URL);
const events = [];
const networkObserver = new NetworkObserver({
ignoreChannelFunction: channel => channel.URI.spec !== AUTH_URL,
onNetworkEvent: event => {
const owner = new AuthForwardingOwner();
events.push(owner);
return owner;
},
});
registerCleanupFunction(() => networkObserver.destroy());
const onAuthPrompt = waitForAuthPrompt(tab);
await SpecialPowers.spawn(gBrowser.selectedBrowser, [AUTH_URL], _url => {
content.wrappedJSObject.sendRequest(_url);
});
info("Wait for a network event to be created");
await BrowserTestUtils.waitForCondition(() => events.length >= 1);
is(events.length, 1, "Received the expected number of network events");
info("Wait for the auth prompt to be displayed");
await onAuthPrompt;
ok(
getTabAuthPrompts(tab).length == 1,
"The auth prompt was not blocked by the network observer"
);
// The event owner should have been called for ResponseStart and EventTimings
assertEventOwner(events[0], {
hasResponseStart: true,
hasEventTimings: true,
});
networkObserver.destroy();
gBrowser.removeTab(tab);
});
add_task(async function testAuthRequestWithForwardingListener() {
cleanupAuthManager();
const tab = await addTab(TEST_URL);
const events = [];
const networkObserver = new NetworkObserver({
ignoreChannelFunction: channel => channel.URI.spec !== AUTH_URL,
onNetworkEvent: event => {
info("waitForNetworkEvents received a new event");
const owner = new AuthForwardingOwner();
events.push(owner);
return owner;
},
});
registerCleanupFunction(() => networkObserver.destroy());
info("Enable the auth prompt listener for this network observer");
networkObserver.setAuthPromptListenerEnabled(true);
const onAuthPrompt = waitForAuthPrompt(tab);
await SpecialPowers.spawn(gBrowser.selectedBrowser, [AUTH_URL], _url => {
content.wrappedJSObject.sendRequest(_url);
});
info("Wait for a network event to be received");
await BrowserTestUtils.waitForCondition(() => events.length >= 1);
is(events.length, 1, "Received the expected number of network events");
// The auth prompt should still be displayed since the network event owner
// forwards the auth notification immediately.
info("Wait for the auth prompt to be displayed");
await onAuthPrompt;
ok(
getTabAuthPrompts(tab).length == 1,
"The auth prompt was not blocked by the network observer"
);
// The event owner should have been called for ResponseStart, EventTimings and
// AuthPrompt
assertEventOwner(events[0], {
hasResponseStart: true,
hasEventTimings: true,
hasAuthPrompt: true,
});
networkObserver.destroy();
gBrowser.removeTab(tab);
});
add_task(async function testAuthRequestWithCancellingListener() {
cleanupAuthManager();
const tab = await addTab(TEST_URL);
const events = [];
const networkObserver = new NetworkObserver({
ignoreChannelFunction: channel => channel.URI.spec !== AUTH_URL,
onNetworkEvent: event => {
const owner = new AuthCancellingOwner();
events.push(owner);
return owner;
},
});
registerCleanupFunction(() => networkObserver.destroy());
info("Enable the auth prompt listener for this network observer");
networkObserver.setAuthPromptListenerEnabled(true);
await SpecialPowers.spawn(gBrowser.selectedBrowser, [AUTH_URL], _url => {
content.wrappedJSObject.sendRequest(_url);
});
info("Wait for a network event to be received");
await BrowserTestUtils.waitForCondition(() => events.length >= 1);
is(events.length, 1, "Received the expected number of network events");
await BrowserTestUtils.waitForCondition(() => events[0].hasResponseContent);
// The auth prompt should not be displayed since the authentication was
// cancelled.
ok(
!getTabAuthPrompts(tab).length,
"The auth prompt was cancelled by the network event owner"
);
assertEventOwner(events[0], {
hasResponseStart: true,
hasResponseContent: true,
hasEventTimings: true,
hasServerTimings: true,
hasAuthPrompt: true,
hasSecurityInfo: true,
});
networkObserver.destroy();
gBrowser.removeTab(tab);
});
add_task(async function testAuthRequestWithWrongCredentialsListener() {
cleanupAuthManager();
const tab = await addTab(TEST_URL);
const events = [];
const networkObserver = new NetworkObserver({
ignoreChannelFunction: channel => channel.URI.spec !== AUTH_URL,
onNetworkEvent: (event, channel) => {
const owner = new AuthCredentialsProvidingOwner(
channel,
USERNAME,
BAD_PASSWORD
);
events.push(owner);
return owner;
},
});
registerCleanupFunction(() => networkObserver.destroy());
info("Enable the auth prompt listener for this network observer");
networkObserver.setAuthPromptListenerEnabled(true);
await SpecialPowers.spawn(gBrowser.selectedBrowser, [AUTH_URL], _url => {
content.wrappedJSObject.sendRequest(_url);
});
info("Wait for all network events to be received");
await BrowserTestUtils.waitForCondition(() => events.length >= 1);
is(events.length, 1, "Received the expected number of network events");
// The auth prompt should not be displayed since the authentication was
// fulfilled.
ok(
!getTabAuthPrompts(tab).length,
"The auth prompt was handled by the network event owner"
);
assertEventOwner(events[0], {
hasAuthPrompt: true,
hasResponseStart: true,
hasEventTimings: true,
});
networkObserver.destroy();
gBrowser.removeTab(tab);
});
add_task(async function testAuthRequestWithCredentialsListener() {
cleanupAuthManager();
const tab = await addTab(TEST_URL);
const events = [];
const networkObserver = new NetworkObserver({
ignoreChannelFunction: channel => channel.URI.spec !== AUTH_URL,
onNetworkEvent: (event, channel) => {
const owner = new AuthCredentialsProvidingOwner(
channel,
USERNAME,
PASSWORD
);
events.push(owner);
return owner;
},
});
registerCleanupFunction(() => networkObserver.destroy());
info("Enable the auth prompt listener for this network observer");
networkObserver.setAuthPromptListenerEnabled(true);
await SpecialPowers.spawn(gBrowser.selectedBrowser, [AUTH_URL], _url => {
content.wrappedJSObject.sendRequest(_url);
});
// TODO: At the moment, providing credentials will result in additional
// network events collected by the NetworkObserver, whereas we would expect
// to keep the same event.
// For successful auth prompts, we receive an additional event.
// The last event will contain the responseContent flag.
info("Wait for all network events to be received");
await BrowserTestUtils.waitForCondition(() => events.length >= 2);
is(events.length, 2, "Received the expected number of network events");
// Since the auth prompt was canceled we should also receive the security
// information for the channel, but we still don't get response content.
await BrowserTestUtils.waitForCondition(() => events[1].hasResponseContent);
// The auth prompt should not be displayed since the authentication was
// fulfilled.
ok(
!getTabAuthPrompts(tab).length,
"The auth prompt was handled by the network event owner"
);
assertEventOwner(events[1], {
hasResponseStart: true,
hasEventTimings: true,
hasSecurityInfo: true,
hasServerTimings: true,
hasResponseContent: true,
});
is(events[1].responseContent, "success", "Auth prompt was successful");
networkObserver.destroy();
gBrowser.removeTab(tab);
});
function assertEventOwner(event, expectedFlags) {
is(
event.hasResponseStart,
!!expectedFlags.hasResponseStart,
"network event has the expected ResponseStart flag"
);
is(
event.hasEventTimings,
!!expectedFlags.hasEventTimings,
"network event has the expected EventTimings flag"
);
is(
event.hasAuthPrompt,
!!expectedFlags.hasAuthPrompt,
"network event has the expected AuthPrompt flag"
);
is(
event.hasResponseCache,
!!expectedFlags.hasResponseCache,
"network event has the expected ResponseCache flag"
);
is(
event.hasResponseContent,
!!expectedFlags.hasResponseContent,
"network event has the expected ResponseContent flag"
);
is(
event.hasSecurityInfo,
!!expectedFlags.hasSecurityInfo,
"network event has the expected SecurityInfo flag"
);
is(
event.hasServerTimings,
!!expectedFlags.hasServerTimings,
"network event has the expected ServerTimings flag"
);
}
function getTabAuthPrompts(tab) {
const tabDialogBox = gBrowser.getTabDialogBox(tab.linkedBrowser);
return tabDialogBox
.getTabDialogManager()
._dialogs.filter(
d => d.frameContentWindow?.Dialog.args.promptType == "promptUserAndPass"
);
}
function waitForAuthPrompt(tab) {
return PromptTestUtils.waitForPrompt(tab.linkedBrowser, {
modalType: Services.prompt.MODAL_TYPE_TAB,
promptType: "promptUserAndPass",
});
}
// Cleanup potentially stored credentials before running any test.
function cleanupAuthManager() {
const authManager = SpecialPowers.Cc[
"@mozilla.org/network/http-auth-manager;1"
].getService(SpecialPowers.Ci.nsIHttpAuthManager);
authManager.clearAll();
}

View file

@ -46,18 +46,43 @@ async function addTab(url) {
}
/**
* Create a simple network event owner, with empty implementations of all
* Base network event owner class implementing all mandatory callbacks and
* keeping track of which callbacks have been called.
*/
class NetworkEventOwner {
hasEventTimings = false;
hasResponseCache = false;
hasResponseContent = false;
hasResponseStart = false;
hasSecurityInfo = false;
hasServerTimings = false;
addEventTimings() {
this.hasEventTimings = true;
}
addResponseCache() {
this.hasResponseCache = true;
}
addResponseContent() {
this.hasResponseContent = true;
}
addResponseStart() {
this.hasResponseStart = true;
}
addSecurityInfo() {
this.hasSecurityInfo = true;
}
addServerTimings() {
this.hasServerTimings = true;
}
}
/**
* Create a simple network event owner, with mock implementations of all
* the expected APIs for a NetworkEventOwner.
*/
function createNetworkEventOwner(event) {
return {
addEventTimings: () => {},
addResponseCache: () => {},
addResponseContent: () => {},
addResponseStart: () => {},
addSecurityInfo: () => {},
addServerTimings: () => {},
};
return new NetworkEventOwner();
}
/**
@ -69,24 +94,25 @@ function createNetworkEventOwner(event) {
* @param {number} expectedRequestsCount
* How many different events (requests) are expected.
* @returns {Promise}
* A promise which will resolve with the current count, when the expected
* count is reached.
* A promise which will resolve with an array of network event owners, when
* the expected event count is reached.
*/
async function waitForNetworkEvents(expectedUrl, expectedRequestsCount) {
let eventsCount = 0;
const events = [];
const networkObserver = new NetworkObserver({
ignoreChannelFunction: channel => channel.URI.spec !== expectedUrl,
onNetworkEvent: event => {
onNetworkEvent: () => {
info("waitForNetworkEvents received a new event");
eventsCount++;
return createNetworkEventOwner(event);
const owner = createNetworkEventOwner();
events.push(owner);
return owner;
},
});
registerCleanupFunction(() => networkObserver.destroy());
info("Wait until the events count reaches " + expectedRequestsCount);
await BrowserTestUtils.waitForCondition(
() => eventsCount >= expectedRequestsCount
() => events.length >= expectedRequestsCount
);
return eventsCount;
return events;
}

View file

@ -0,0 +1,31 @@
/* Any copyright is dedicated to the Public Domain.
http://creativecommons.org/publicdomain/zero/1.0/ */
"use strict";
function handleRequest(request, response) {
let body;
// Expect guest/guest as correct credentials, but `btoa` is unavailable in sjs
// "Z3Vlc3Q6Z3Vlc3Q=" == btoa("guest:guest")
const expectedHeader = "Basic Z3Vlc3Q6Z3Vlc3Q=";
// correct login credentials provided
if (
request.hasHeader("Authorization") &&
request.getHeader("Authorization") == expectedHeader
) {
response.setStatusLine(request.httpVersion, 200, "OK, authorized");
response.setHeader("Content-Type", "text", false);
body = "success";
} else {
// incorrect credentials
response.setStatusLine(request.httpVersion, 401, "Unauthorized");
response.setHeader("WWW-Authenticate", 'Basic realm="secret"', false);
response.setHeader("Content-Type", "text", false);
body = "failed";
}
response.bodyOutputStream.write(body, body.length);
}

View file

@ -5557,6 +5557,7 @@ NS_IMETHODIMP nsHttpChannel::OnAuthAvailable() {
NS_IMETHODIMP nsHttpChannel::OnAuthCancelled(bool userCancel) {
LOG(("nsHttpChannel::OnAuthCancelled [this=%p]", this));
MOZ_ASSERT(mAuthRetryPending, "OnAuthCancelled should not be called twice");
if (mTransactionPump) {
// If the channel is trying to authenticate to a proxy and