feat: Restrain sending http credentials on a specific origin (#20374)

For security purpose, we would like to restrain sending HTTP credentials
to only the specified server. The idea is to give the ability to specify
a origin (scheme://host:port) additionally to current pair
username/password. When an authorization response is received from
servers, the credentials are sent only if the server origin in the
request matches case insensitive the specified origin.
This commit is contained in:
Sébastien Richert 2023-03-27 11:52:00 -04:00 committed by GitHub
parent c3d7ffb773
commit 591e4ea976
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
12 changed files with 187 additions and 13 deletions

View file

@ -560,8 +560,10 @@ Whether to emulate network being offline. Defaults to `false`.
- `httpCredentials` <[Object]>
- `username` <[string]>
- `password` <[string]>
- `origin` ?<[string]> Restrain sending http credentials on specific origin (scheme://host:port).
Credentials for [HTTP authentication](https://developer.mozilla.org/en-US/docs/Web/HTTP/Authentication).
If no origin is specified, the username and password are sent to any servers upon unauthorized responses.
## context-option-colorscheme
* langs: js, java

View file

@ -328,6 +328,7 @@ scheme.PlaywrightNewRequestParams = tObject({
httpCredentials: tOptional(tObject({
username: tString,
password: tString,
origin: tOptional(tString),
})),
proxy: tOptional(tObject({
server: tString,
@ -543,6 +544,7 @@ scheme.BrowserTypeLaunchPersistentContextParams = tObject({
httpCredentials: tOptional(tObject({
username: tString,
password: tString,
origin: tOptional(tString),
})),
deviceScaleFactor: tOptional(tNumber),
isMobile: tOptional(tBoolean),
@ -614,6 +616,7 @@ scheme.BrowserNewContextParams = tObject({
httpCredentials: tOptional(tObject({
username: tString,
password: tString,
origin: tOptional(tString),
})),
deviceScaleFactor: tOptional(tNumber),
isMobile: tOptional(tBoolean),
@ -674,6 +677,7 @@ scheme.BrowserNewContextForReuseParams = tObject({
httpCredentials: tOptional(tObject({
username: tString,
password: tString,
origin: tOptional(tString),
})),
deviceScaleFactor: tOptional(tNumber),
isMobile: tOptional(tBoolean),
@ -845,6 +849,7 @@ scheme.BrowserContextSetHTTPCredentialsParams = tObject({
httpCredentials: tOptional(tObject({
username: tString,
password: tString,
origin: tOptional(tString),
})),
});
scheme.BrowserContextSetHTTPCredentialsResult = tOptional(tObject({}));
@ -2201,6 +2206,7 @@ scheme.ElectronLaunchParams = tObject({
httpCredentials: tOptional(tObject({
username: tString,
password: tString,
origin: tOptional(tString),
})),
ignoreHTTPSErrors: tOptional(tBoolean),
locale: tOptional(tString),
@ -2409,6 +2415,7 @@ scheme.AndroidDeviceLaunchBrowserParams = tObject({
httpCredentials: tOptional(tObject({
username: tString,
password: tString,
origin: tOptional(tString),
})),
deviceScaleFactor: tOptional(tNumber),
isMobile: tOptional(tBoolean),

View file

@ -36,7 +36,7 @@ export class CRNetworkManager {
private _parentManager: CRNetworkManager | null;
private _requestIdToRequest = new Map<string, InterceptableRequest>();
private _requestIdToRequestWillBeSentEvent = new Map<string, Protocol.Network.requestWillBeSentPayload>();
private _credentials: {username: string, password: string} | null = null;
private _credentials: {origin?: string, username: string, password: string} | null = null;
private _attemptedAuthentications = new Set<string>();
private _userRequestInterceptionEnabled = false;
private _protocolRequestInterceptionEnabled = false;
@ -161,19 +161,26 @@ export class CRNetworkManager {
_onAuthRequired(event: Protocol.Fetch.authRequiredPayload) {
let response: 'Default' | 'CancelAuth' | 'ProvideCredentials' = 'Default';
const shouldProvideCredentials = this._shouldProvideCredentials(event.request.url);
if (this._attemptedAuthentications.has(event.requestId)) {
response = 'CancelAuth';
} else if (this._credentials) {
} else if (shouldProvideCredentials) {
response = 'ProvideCredentials';
this._attemptedAuthentications.add(event.requestId);
}
const { username, password } = this._credentials || { username: undefined, password: undefined };
const { username, password } = shouldProvideCredentials && this._credentials ? this._credentials : { username: undefined, password: undefined };
this._client._sendMayFail('Fetch.continueWithAuth', {
requestId: event.requestId,
authChallengeResponse: { response, username, password },
});
}
_shouldProvideCredentials(url: string): boolean {
if (!this._credentials)
return false;
return !this._credentials.origin || new URL(url).origin.toLowerCase() === this._credentials.origin.toLowerCase();
}
_onRequestPaused(workerFrame: frames.Frame | undefined, event: Protocol.Fetch.requestPausedPayload) {
if (!event.networkId) {
// Fetch without networkId means that request was not recongnized by inspector, and

View file

@ -339,7 +339,7 @@ export abstract class APIRequestContext extends SdkObject {
}
if (response.statusCode === 401 && !getHeader(options.headers, 'authorization')) {
const auth = response.headers['www-authenticate'];
const credentials = this._defaultOptions().httpCredentials;
const credentials = this._getHttpCredentials(url);
if (auth?.trim().startsWith('Basic') && credentials) {
const { username, password } = credentials;
const encoded = Buffer.from(`${username || ''}:${password || ''}`).toString('base64');
@ -426,6 +426,12 @@ export abstract class APIRequestContext extends SdkObject {
request.end();
});
}
private _getHttpCredentials(url: URL) {
if (!this._defaultOptions().httpCredentials?.origin || url.origin.toLowerCase() === this._defaultOptions().httpCredentials?.origin?.toLowerCase())
return this._defaultOptions().httpCredentials;
return undefined;
}
}
class SafeEmptyStreamTransform extends Transform {

View file

@ -57,6 +57,7 @@ export type PageScreencastOptions = {
export type Credentials = {
username: string;
password: string;
origin?: string;
};
export type Geolocation = {

View file

@ -732,8 +732,8 @@ export class WKPage implements PageDelegate {
}
async updateHttpCredentials() {
const credentials = this._browserContext._options.httpCredentials || { username: '', password: '' };
await this._pageProxySession.send('Emulation.setAuthCredentials', { username: credentials.username, password: credentials.password });
const credentials = this._browserContext._options.httpCredentials || { username: '', password: '', origin: '' };
await this._pageProxySession.send('Emulation.setAuthCredentials', { username: credentials.username, password: credentials.password, origin: credentials.origin });
}
async updateFileChooserInterception() {

View file

@ -12340,12 +12340,18 @@ export interface BrowserType<Unused = {}> {
headless?: boolean;
/**
* Credentials for [HTTP authentication](https://developer.mozilla.org/en-US/docs/Web/HTTP/Authentication).
* Credentials for [HTTP authentication](https://developer.mozilla.org/en-US/docs/Web/HTTP/Authentication). If no
* origin is specified, the username and password are sent to any servers upon unauthorized responses.
*/
httpCredentials?: {
username: string;
password: string;
/**
* Restrain sending http credentials on specific origin (scheme://host:port).
*/
origin?: string;
};
/**
@ -13733,12 +13739,18 @@ export interface AndroidDevice {
hasTouch?: boolean;
/**
* Credentials for [HTTP authentication](https://developer.mozilla.org/en-US/docs/Web/HTTP/Authentication).
* Credentials for [HTTP authentication](https://developer.mozilla.org/en-US/docs/Web/HTTP/Authentication). If no
* origin is specified, the username and password are sent to any servers upon unauthorized responses.
*/
httpCredentials?: {
username: string;
password: string;
/**
* Restrain sending http credentials on specific origin (scheme://host:port).
*/
origin?: string;
};
/**
@ -14443,12 +14455,18 @@ export interface APIRequest {
extraHTTPHeaders?: { [key: string]: string; };
/**
* Credentials for [HTTP authentication](https://developer.mozilla.org/en-US/docs/Web/HTTP/Authentication).
* Credentials for [HTTP authentication](https://developer.mozilla.org/en-US/docs/Web/HTTP/Authentication). If no
* origin is specified, the username and password are sent to any servers upon unauthorized responses.
*/
httpCredentials?: {
username: string;
password: string;
/**
* Restrain sending http credentials on specific origin (scheme://host:port).
*/
origin?: string;
};
/**
@ -15590,12 +15608,18 @@ export interface Browser extends EventEmitter {
hasTouch?: boolean;
/**
* Credentials for [HTTP authentication](https://developer.mozilla.org/en-US/docs/Web/HTTP/Authentication).
* Credentials for [HTTP authentication](https://developer.mozilla.org/en-US/docs/Web/HTTP/Authentication). If no
* origin is specified, the username and password are sent to any servers upon unauthorized responses.
*/
httpCredentials?: {
username: string;
password: string;
/**
* Restrain sending http credentials on specific origin (scheme://host:port).
*/
origin?: string;
};
/**
@ -16451,12 +16475,18 @@ export interface Electron {
};
/**
* Credentials for [HTTP authentication](https://developer.mozilla.org/en-US/docs/Web/HTTP/Authentication).
* Credentials for [HTTP authentication](https://developer.mozilla.org/en-US/docs/Web/HTTP/Authentication). If no
* origin is specified, the username and password are sent to any servers upon unauthorized responses.
*/
httpCredentials?: {
username: string;
password: string;
/**
* Restrain sending http credentials on specific origin (scheme://host:port).
*/
origin?: string;
};
/**
@ -18673,7 +18703,8 @@ export interface BrowserContextOptions {
hasTouch?: boolean;
/**
* Credentials for [HTTP authentication](https://developer.mozilla.org/en-US/docs/Web/HTTP/Authentication).
* Credentials for [HTTP authentication](https://developer.mozilla.org/en-US/docs/Web/HTTP/Authentication). If no
* origin is specified, the username and password are sent to any servers upon unauthorized responses.
*/
httpCredentials?: HTTPCredentials;
@ -18968,6 +18999,11 @@ export interface HTTPCredentials {
username: string;
password: string;
/**
* Restrain sending http credentials on specific origin (scheme://host:port).
*/
origin?: string;
}
export interface Geolocation {

View file

@ -3491,7 +3491,8 @@ export interface PlaywrightTestOptions {
*/
hasTouch: boolean;
/**
* Credentials for [HTTP authentication](https://developer.mozilla.org/en-US/docs/Web/HTTP/Authentication).
* Credentials for [HTTP authentication](https://developer.mozilla.org/en-US/docs/Web/HTTP/Authentication). If no
* origin is specified, the username and password are sent to any servers upon unauthorized responses.
*/
httpCredentials: HTTPCredentials | undefined;
/**

View file

@ -573,6 +573,7 @@ export type PlaywrightNewRequestParams = {
httpCredentials?: {
username: string,
password: string,
origin?: string,
},
proxy?: {
server: string,
@ -595,6 +596,7 @@ export type PlaywrightNewRequestOptions = {
httpCredentials?: {
username: string,
password: string,
origin?: string,
},
proxy?: {
server: string,
@ -953,6 +955,7 @@ export type BrowserTypeLaunchPersistentContextParams = {
httpCredentials?: {
username: string,
password: string,
origin?: string,
},
deviceScaleFactor?: number,
isMobile?: boolean,
@ -1023,6 +1026,7 @@ export type BrowserTypeLaunchPersistentContextOptions = {
httpCredentials?: {
username: string,
password: string,
origin?: string,
},
deviceScaleFactor?: number,
isMobile?: boolean,
@ -1118,6 +1122,7 @@ export type BrowserNewContextParams = {
httpCredentials?: {
username: string,
password: string,
origin?: string,
},
deviceScaleFactor?: number,
isMobile?: boolean,
@ -1175,6 +1180,7 @@ export type BrowserNewContextOptions = {
httpCredentials?: {
username: string,
password: string,
origin?: string,
},
deviceScaleFactor?: number,
isMobile?: boolean,
@ -1235,6 +1241,7 @@ export type BrowserNewContextForReuseParams = {
httpCredentials?: {
username: string,
password: string,
origin?: string,
},
deviceScaleFactor?: number,
isMobile?: boolean,
@ -1292,6 +1299,7 @@ export type BrowserNewContextForReuseOptions = {
httpCredentials?: {
username: string,
password: string,
origin?: string,
},
deviceScaleFactor?: number,
isMobile?: boolean,
@ -1556,12 +1564,14 @@ export type BrowserContextSetHTTPCredentialsParams = {
httpCredentials?: {
username: string,
password: string,
origin?: string,
},
};
export type BrowserContextSetHTTPCredentialsOptions = {
httpCredentials?: {
username: string,
password: string,
origin?: string,
},
};
export type BrowserContextSetHTTPCredentialsResult = void;
@ -3965,6 +3975,7 @@ export type ElectronLaunchParams = {
httpCredentials?: {
username: string,
password: string,
origin?: string,
},
ignoreHTTPSErrors?: boolean,
locale?: string,
@ -3998,6 +4009,7 @@ export type ElectronLaunchOptions = {
httpCredentials?: {
username: string,
password: string,
origin?: string,
},
ignoreHTTPSErrors?: boolean,
locale?: string,
@ -4367,6 +4379,7 @@ export type AndroidDeviceLaunchBrowserParams = {
httpCredentials?: {
username: string,
password: string,
origin?: string,
},
deviceScaleFactor?: number,
isMobile?: boolean,
@ -4422,6 +4435,7 @@ export type AndroidDeviceLaunchBrowserOptions = {
httpCredentials?: {
username: string,
password: string,
origin?: string,
},
deviceScaleFactor?: number,
isMobile?: boolean,

View file

@ -448,6 +448,7 @@ ContextOptions:
properties:
username: string
password: string
origin: string?
deviceScaleFactor: number?
isMobile: boolean?
hasTouch: boolean?
@ -654,6 +655,7 @@ Playwright:
properties:
username: string
password: string
origin: string?
proxy:
type: object?
properties:
@ -1065,6 +1067,7 @@ BrowserContext:
properties:
username: string
password: string
origin: string?
setNetworkInterceptionPatterns:
parameters:
@ -3088,6 +3091,7 @@ Electron:
properties:
username: string
password: string
origin: string?
ignoreHTTPSErrors: boolean?
locale: string?
offline: boolean?

View file

@ -76,3 +76,61 @@ it('should return resource body', async ({ browser, server }) => {
expect((await response.body()).toString()).toContain('Playground');
await context.close();
});
it('should work with correct credentials and matching origin', async ({ browser, server }) => {
server.setAuth('/empty.html', 'user', 'pass');
const context = await browser.newContext({
httpCredentials: { username: 'user', password: 'pass', origin: server.PREFIX }
});
const page = await context.newPage();
const response = await page.goto(server.EMPTY_PAGE);
expect(response.status()).toBe(200);
await context.close();
});
it('should work with correct credentials and matching origin case insensitive', async ({ browser, server }) => {
server.setAuth('/empty.html', 'user', 'pass');
const context = await browser.newContext({
httpCredentials: { username: 'user', password: 'pass', origin: server.PREFIX.toUpperCase() }
});
const page = await context.newPage();
const response = await page.goto(server.EMPTY_PAGE);
expect(response.status()).toBe(200);
await context.close();
});
it('should fail with correct credentials and mismatching scheme', async ({ browser, server }) => {
server.setAuth('/empty.html', 'user', 'pass');
const context = await browser.newContext({
httpCredentials: { username: 'user', password: 'pass', origin: server.PREFIX.replace('http://', 'https://') }
});
const page = await context.newPage();
const response = await page.goto(server.EMPTY_PAGE);
expect(response.status()).toBe(401);
await context.close();
});
it('should fail with correct credentials and mismatching hostname', async ({ browser, server }) => {
server.setAuth('/empty.html', 'user', 'pass');
const hostname = new URL(server.PREFIX).hostname;
const origin = server.PREFIX.replace(hostname, 'mismatching-hostname');
const context = await browser.newContext({
httpCredentials: { username: 'user', password: 'pass', origin: origin }
});
const page = await context.newPage();
const response = await page.goto(server.EMPTY_PAGE);
expect(response.status()).toBe(401);
await context.close();
});
it('should fail with correct credentials and mismatching port', async ({ browser, server }) => {
server.setAuth('/empty.html', 'user', 'pass');
const origin = server.PREFIX.replace(server.PORT.toString(), (server.PORT + 1).toString());
const context = await browser.newContext({
httpCredentials: { username: 'user', password: 'pass', origin: origin }
});
const page = await context.newPage();
const response = await page.goto(server.EMPTY_PAGE);
expect(response.status()).toBe(401);
await context.close();
});

View file

@ -97,6 +97,44 @@ it('should return error with wrong credentials', async ({ playwright, server })
expect(response.status()).toBe(401);
});
it('should work with correct credentials and matching origin', async ({ playwright, server }) => {
server.setAuth('/empty.html', 'user', 'pass');
const request = await playwright.request.newContext({ httpCredentials: { username: 'user', password: 'pass', origin: server.PREFIX } });
const response = await request.get(server.EMPTY_PAGE);
expect(response.status()).toBe(200);
});
it('should work with correct credentials and matching origin case insensitive', async ({ playwright, server }) => {
server.setAuth('/empty.html', 'user', 'pass');
const request = await playwright.request.newContext({ httpCredentials: { username: 'user', password: 'pass', origin: server.PREFIX.toUpperCase() } });
const response = await request.get(server.EMPTY_PAGE);
expect(response.status()).toBe(200);
});
it('should return error with correct credentials and mismatching scheme', async ({ playwright, server }) => {
server.setAuth('/empty.html', 'user', 'pass');
const request = await playwright.request.newContext({ httpCredentials: { username: 'user', password: 'pass', origin: server.PREFIX.replace('http://', 'https://') } });
const response = await request.get(server.EMPTY_PAGE);
expect(response.status()).toBe(401);
});
it('should return error with correct credentials and mismatching hostname', async ({ playwright, server }) => {
server.setAuth('/empty.html', 'user', 'pass');
const hostname = new URL(server.PREFIX).hostname;
const origin = server.PREFIX.replace(hostname, 'mismatching-hostname');
const request = await playwright.request.newContext({ httpCredentials: { username: 'user', password: 'pass', origin: origin } });
const response = await request.get(server.EMPTY_PAGE);
expect(response.status()).toBe(401);
});
it('should return error with correct credentials and mismatching port', async ({ playwright, server }) => {
server.setAuth('/empty.html', 'user', 'pass');
const origin = server.PREFIX.replace(server.PORT.toString(), (server.PORT + 1).toString());
const request = await playwright.request.newContext({ httpCredentials: { username: 'user', password: 'pass', origin: origin } });
const response = await request.get(server.EMPTY_PAGE);
expect(response.status()).toBe(401);
});
it('should support WWW-Authenticate: Basic', async ({ playwright, server }) => {
let credentials;
server.setRoute('/empty.html', (req, res) => {