From 591e4ea9763bb1a81ecf289cc497292917f506ee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Richert?= <36002408+SebastienRichert@users.noreply.github.com> Date: Mon, 27 Mar 2023 11:52:00 -0400 Subject: [PATCH] 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. --- docs/src/api/params.md | 2 + .../playwright-core/src/protocol/validator.ts | 7 +++ .../src/server/chromium/crNetworkManager.ts | 13 ++++- packages/playwright-core/src/server/fetch.ts | 8 ++- packages/playwright-core/src/server/types.ts | 1 + .../src/server/webkit/wkPage.ts | 4 +- packages/playwright-core/types/types.d.ts | 48 +++++++++++++-- packages/playwright-test/types/test.d.ts | 3 +- packages/protocol/src/channels.ts | 14 +++++ packages/protocol/src/protocol.yml | 4 ++ .../browsercontext-credentials.spec.ts | 58 +++++++++++++++++++ tests/library/global-fetch.spec.ts | 38 ++++++++++++ 12 files changed, 187 insertions(+), 13 deletions(-) diff --git a/docs/src/api/params.md b/docs/src/api/params.md index 22f3527198..2534012219 100644 --- a/docs/src/api/params.md +++ b/docs/src/api/params.md @@ -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 diff --git a/packages/playwright-core/src/protocol/validator.ts b/packages/playwright-core/src/protocol/validator.ts index 559777b77c..5a660157d1 100644 --- a/packages/playwright-core/src/protocol/validator.ts +++ b/packages/playwright-core/src/protocol/validator.ts @@ -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), diff --git a/packages/playwright-core/src/server/chromium/crNetworkManager.ts b/packages/playwright-core/src/server/chromium/crNetworkManager.ts index d8a305cd1e..8a31b59262 100644 --- a/packages/playwright-core/src/server/chromium/crNetworkManager.ts +++ b/packages/playwright-core/src/server/chromium/crNetworkManager.ts @@ -36,7 +36,7 @@ export class CRNetworkManager { private _parentManager: CRNetworkManager | null; private _requestIdToRequest = new Map(); private _requestIdToRequestWillBeSentEvent = new Map(); - private _credentials: {username: string, password: string} | null = null; + private _credentials: {origin?: string, username: string, password: string} | null = null; private _attemptedAuthentications = new Set(); 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 diff --git a/packages/playwright-core/src/server/fetch.ts b/packages/playwright-core/src/server/fetch.ts index e56d621b60..376dfbeca6 100644 --- a/packages/playwright-core/src/server/fetch.ts +++ b/packages/playwright-core/src/server/fetch.ts @@ -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 { diff --git a/packages/playwright-core/src/server/types.ts b/packages/playwright-core/src/server/types.ts index 319b774839..f3c9b96c65 100644 --- a/packages/playwright-core/src/server/types.ts +++ b/packages/playwright-core/src/server/types.ts @@ -57,6 +57,7 @@ export type PageScreencastOptions = { export type Credentials = { username: string; password: string; + origin?: string; }; export type Geolocation = { diff --git a/packages/playwright-core/src/server/webkit/wkPage.ts b/packages/playwright-core/src/server/webkit/wkPage.ts index d47292373e..1a336841ae 100644 --- a/packages/playwright-core/src/server/webkit/wkPage.ts +++ b/packages/playwright-core/src/server/webkit/wkPage.ts @@ -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() { diff --git a/packages/playwright-core/types/types.d.ts b/packages/playwright-core/types/types.d.ts index 9bc771be15..dfe7f0f4e7 100644 --- a/packages/playwright-core/types/types.d.ts +++ b/packages/playwright-core/types/types.d.ts @@ -12340,12 +12340,18 @@ export interface BrowserType { 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 { diff --git a/packages/playwright-test/types/test.d.ts b/packages/playwright-test/types/test.d.ts index 6f0cf3566d..e2834a7a90 100644 --- a/packages/playwright-test/types/test.d.ts +++ b/packages/playwright-test/types/test.d.ts @@ -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; /** diff --git a/packages/protocol/src/channels.ts b/packages/protocol/src/channels.ts index cf6bdd00b9..85ce51aaeb 100644 --- a/packages/protocol/src/channels.ts +++ b/packages/protocol/src/channels.ts @@ -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, diff --git a/packages/protocol/src/protocol.yml b/packages/protocol/src/protocol.yml index 619519a753..380196a742 100644 --- a/packages/protocol/src/protocol.yml +++ b/packages/protocol/src/protocol.yml @@ -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? diff --git a/tests/library/browsercontext-credentials.spec.ts b/tests/library/browsercontext-credentials.spec.ts index e8705af4a6..d952c5b58f 100644 --- a/tests/library/browsercontext-credentials.spec.ts +++ b/tests/library/browsercontext-credentials.spec.ts @@ -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(); +}); diff --git a/tests/library/global-fetch.spec.ts b/tests/library/global-fetch.spec.ts index f823539de6..d72ce167d6 100644 --- a/tests/library/global-fetch.spec.ts +++ b/tests/library/global-fetch.spec.ts @@ -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) => {