diff --git a/package.json b/package.json index 22f14254..b9f02b7d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "privacy-pass", - "version": "3.6.4", + "version": "3.6.5", "private": true, "contributors": [ "Suphanat Chunhapanya ", diff --git a/public/manifest.json b/public/manifest.json index 5068c99a..ed8bcc2c 100644 --- a/public/manifest.json +++ b/public/manifest.json @@ -1,7 +1,7 @@ { "name": "__MSG_appName__", "description": "__MSG_appDescription__", - "version": "3.6.4", + "version": "3.6.5", "manifest_version": 2, "default_locale": "en", "icons": { diff --git a/src/background/providers/cloudflare.test.ts b/src/background/providers/cloudflare.test.ts index 6a0d75b9..177e9e26 100644 --- a/src/background/providers/cloudflare.test.ts +++ b/src/background/providers/cloudflare.test.ts @@ -116,6 +116,10 @@ describe('issuance', () => { }, }; + const flattenFormData: { [key: string]: string[] | string } = { + 'h-captcha-response': 'body-param', + }; + test('valid request', async () => { const storage = new StorageMock(); const updateIcon = jest.fn(); @@ -137,7 +141,7 @@ describe('issuance', () => { issueInfo = provider['issueInfo']; expect(issueInfo!.requestId).toEqual(bodyDetails.requestId); expect(issueInfo!.url).toEqual(bodyDetails.url); - expect(issueInfo!.formData).toEqual(bodyDetails.requestBody!.formData); + expect(issueInfo!.formData).toEqual(flattenFormData); const headDetails: any = { ...validDetails, @@ -199,7 +203,7 @@ describe('issuance', () => { issueInfo = provider['issueInfo']; expect(issueInfo!.requestId).toEqual(bodyDetails.requestId); expect(issueInfo!.url).toEqual(bodyDetails.url); - expect(issueInfo!.formData).toEqual(bodyDetails.requestBody!.formData); + expect(issueInfo!.formData).toEqual(flattenFormData); const headDetails: any = { ...validDetails, @@ -271,7 +275,7 @@ describe('issuance', () => { issueInfo = provider['issueInfo']; expect(issueInfo!.requestId).toEqual(bodyDetails.requestId); expect(issueInfo!.url).toEqual(bodyDetails.url); - expect(issueInfo!.formData).toEqual(bodyDetails.requestBody!.formData); + expect(issueInfo!.formData).toEqual(flattenFormData); const headDetails: any = { ...validDetails, diff --git a/src/background/providers/cloudflare.ts b/src/background/providers/cloudflare.ts index a7fdcdfc..27f78659 100644 --- a/src/background/providers/cloudflare.ts +++ b/src/background/providers/cloudflare.ts @@ -1,6 +1,6 @@ import * as voprf from '../crypto/voprf'; -import { Provider, EarnedTokenCookie, Callbacks, QUALIFIED_HOSTNAMES, QUALIFIED_PATHNAMES, QUALIFIED_PARAMS, isIssuingHostname, isQualifiedPathname, areQualifiedQueryParams, areQualifiedBodyFormParams } from './provider'; +import { Provider, EarnedTokenCookie, Callbacks, QUALIFIED_HOSTNAMES, QUALIFIED_PATHNAMES, QUALIFIED_PARAMS, isIssuingHostname, isQualifiedPathname, areQualifiedQueryParams, areQualifiedBodyFormParams, getNormalizedFormData } from './provider'; import { Storage } from '../storage'; import Token from '../token'; import axios from 'axios'; @@ -16,23 +16,22 @@ const COMMITMENT_URL: string = 'https://raw.githubusercontent.com/privacypass/ec-commitments/master/commitments-p256.json'; const ALL_ISSUING_CRITERIA: { - HOSTNAMES: QUALIFIED_HOSTNAMES; - PATHNAMES: QUALIFIED_PATHNAMES; - QUERY_PARAMS: QUALIFIED_PARAMS; - BODY_PARAMS: QUALIFIED_PARAMS; + HOSTNAMES: QUALIFIED_HOSTNAMES | void; + PATHNAMES: QUALIFIED_PATHNAMES | void; + QUERY_PARAMS: QUALIFIED_PARAMS | void; + BODY_PARAMS: QUALIFIED_PARAMS | void; } = { HOSTNAMES: { exact : [DEFAULT_ISSUING_HOSTNAME], contains: [`.${DEFAULT_ISSUING_HOSTNAME}`], }, - PATHNAMES: { - }, + PATHNAMES: undefined, QUERY_PARAMS: { - some: ['__cf_chl_captcha_tk__', '__cf_chl_managed_tk__'], + some: ['__cf_chl_captcha_tk__', '__cf_chl_managed_tk__'], }, BODY_PARAMS: { - some: ['g-recaptcha-response', 'h-captcha-response', 'cf_captcha_kind'], - } + some: ['g-recaptcha-response', 'h-captcha-response', 'cf_captcha_kind'], + }, } const VERIFICATION_KEY: string = `-----BEGIN PUBLIC KEY----- @@ -110,15 +109,8 @@ export class CloudflareProvider extends Provider { handleBeforeRequest( details: chrome.webRequest.WebRequestBodyDetails, ): chrome.webRequest.BlockingResponse | void { - const url = new URL(details.url); - const formData: { [key: string]: string[] | string } = (details.requestBody && details.requestBody.formData) - ? details.requestBody.formData - : {} - ; - - if (this.matchesIssuingBodyCriteria(details, url, formData)) { - this.issueInfo = { requestId: details.requestId, url: details.url, formData }; + if (this.matchesIssuingBodyCriteria(details)) { // do NOT cancel the request with captcha solution. // note: "handleBeforeSendHeaders" will cancel this request if additional criteria are satisfied. return { cancel: false }; @@ -126,10 +118,9 @@ export class CloudflareProvider extends Provider { } private matchesIssuingBodyCriteria( - details: chrome.webRequest.WebRequestBodyDetails, - url: URL, - formData: { [key: string]: string[] | string }, + details: chrome.webRequest.WebRequestBodyDetails, ): boolean { + // Only issue tokens for POST requests that contain data in body. if ( (details.method.toUpperCase() !== 'POST' ) || @@ -139,6 +130,8 @@ export class CloudflareProvider extends Provider { return false; } + const url: URL = new URL(details.url); + // Only issue tokens to hosts belonging to the provider. if (!isIssuingHostname(ALL_ISSUING_CRITERIA.HOSTNAMES, url)) { return false; @@ -149,11 +142,15 @@ export class CloudflareProvider extends Provider { return false; } - // Only issue tokens when 'application/x-www-form-urlencoded' data parameters in POST body pass defined criteria. + const formData: { [key: string]: string[] | string } = getNormalizedFormData(details, /* flatten= */ true); + + // Only issue tokens when 'application/x-www-form-urlencoded' or 'application/json' data parameters in POST body pass defined criteria. if (!areQualifiedBodyFormParams(ALL_ISSUING_CRITERIA.BODY_PARAMS, formData)) { return false; } + this.issueInfo = { requestId: details.requestId, url: details.url, formData }; + return true; } @@ -321,19 +318,8 @@ export class CloudflareProvider extends Provider { formData: { [key: string]: string[] | string }, ): Promise { try { - // Normalize 'application/x-www-form-urlencoded' data parameters in POST body - const flattenFormData: { [key: string]: string[] | string } = {}; - for (const key in formData) { - if (Array.isArray(formData[key]) && (formData[key].length === 1)) { - const [value] = formData[key]; - flattenFormData[key] = value; - } else { - flattenFormData[key] = formData[key]; - } - } - // Issue tokens. - const tokens = await this.issue(url, flattenFormData); + const tokens = await this.issue(url, formData); // Store tokens. const cached = this.getStoredTokens(); diff --git a/src/background/providers/hcaptcha.ts b/src/background/providers/hcaptcha.ts index 9d5e96ba..3cef9745 100644 --- a/src/background/providers/hcaptcha.ts +++ b/src/background/providers/hcaptcha.ts @@ -1,6 +1,6 @@ import * as voprf from '../crypto/voprf'; -import { Provider, EarnedTokenCookie, Callbacks, QUALIFIED_HOSTNAMES, QUALIFIED_PATHNAMES, QUALIFIED_PARAMS, isIssuingHostname, isQualifiedPathname, areQualifiedQueryParams, areQualifiedBodyFormParams } from './provider'; +import { Provider, EarnedTokenCookie, Callbacks, QUALIFIED_HOSTNAMES, QUALIFIED_PATHNAMES, QUALIFIED_PARAMS, isIssuingHostname, isQualifiedPathname, areQualifiedQueryParams, areQualifiedBodyFormParams, getNormalizedFormData } from './provider'; import { Storage } from '../storage'; import Token from '../token'; import axios from 'axios'; @@ -15,10 +15,10 @@ const COMMITMENT_URL: string = 'https://raw.githubusercontent.com/privacypass/ec-commitments/master/commitments-p256.json'; const ALL_ISSUING_CRITERIA: { - HOSTNAMES: QUALIFIED_HOSTNAMES; - PATHNAMES: QUALIFIED_PATHNAMES; - QUERY_PARAMS: QUALIFIED_PARAMS; - BODY_PARAMS: QUALIFIED_PARAMS; + HOSTNAMES: QUALIFIED_HOSTNAMES | void; + PATHNAMES: QUALIFIED_PATHNAMES | void; + QUERY_PARAMS: QUALIFIED_PARAMS | void; + BODY_PARAMS: QUALIFIED_PARAMS | void; } = { HOSTNAMES: { exact : [DEFAULT_ISSUING_HOSTNAME], @@ -28,17 +28,16 @@ const ALL_ISSUING_CRITERIA: { contains: ['/checkcaptcha'], }, QUERY_PARAMS: { - some: ['s=00000000-0000-0000-0000-000000000000'], + some: ['s=00000000-0000-0000-0000-000000000000'], }, - BODY_PARAMS: { - } + BODY_PARAMS: undefined, } const ALL_REDEMPTION_CRITERIA: { - HOSTNAMES: QUALIFIED_HOSTNAMES; - PATHNAMES: QUALIFIED_PATHNAMES; - QUERY_PARAMS: QUALIFIED_PARAMS; - BODY_PARAMS: QUALIFIED_PARAMS; + HOSTNAMES: QUALIFIED_HOSTNAMES | void; + PATHNAMES: QUALIFIED_PATHNAMES | void; + QUERY_PARAMS: QUALIFIED_PARAMS | void; + BODY_PARAMS: QUALIFIED_PARAMS | void; } = { HOSTNAMES: { exact : [DEFAULT_ISSUING_HOSTNAME], @@ -48,11 +47,11 @@ const ALL_REDEMPTION_CRITERIA: { contains: ['/getcaptcha'], }, QUERY_PARAMS: { - some: ['s!=00000000-0000-0000-0000-000000000000'], + some: ['s!=00000000-0000-0000-0000-000000000000'], }, BODY_PARAMS: { - every: ['sitekey!=00000000-0000-0000-0000-000000000000', 'motionData', 'host!=www.hcaptcha.com'], - } + every: ['sitekey!=00000000-0000-0000-0000-000000000000', 'motionData', 'host!=www.hcaptcha.com'], + }, } const VERIFICATION_KEY: string = `-----BEGIN PUBLIC KEY----- @@ -128,22 +127,13 @@ export class HcaptchaProvider extends Provider { handleBeforeRequest( details: chrome.webRequest.WebRequestBodyDetails, ): chrome.webRequest.BlockingResponse | void { - const url = new URL(details.url); - const formData: { [key: string]: string[] | string } = (details.requestBody && details.requestBody.formData) - ? details.requestBody.formData - : {} - ; - - if (this.matchesIssuingCriteria(details, url, formData)) { - this.issueInfo = { requestId: details.requestId, url: details.url }; + if (this.matchesIssuingCriteria(details)) { // do NOT cancel the request with captcha solution. return { cancel: false }; } - if (this.matchesRedemptionCriteria(details, url, formData)) { - this.redeemInfo = { requestId: details.requestId }; - + if (this.matchesRedemptionCriteria(details)) { // do NOT cancel the request to generate a new captcha. // note: "handleBeforeSendHeaders" will add request headers to embed a token. return { cancel: false }; @@ -151,10 +141,9 @@ export class HcaptchaProvider extends Provider { } private matchesIssuingCriteria( - details: chrome.webRequest.WebRequestBodyDetails, - url: URL, - formData: { [key: string]: string[] | string } + details: chrome.webRequest.WebRequestBodyDetails, ): boolean { + // Only issue tokens for POST requests that contain data in body. if ( (details.method.toUpperCase() !== 'POST' ) || @@ -164,6 +153,8 @@ export class HcaptchaProvider extends Provider { return false; } + const url: URL = new URL(details.url); + // Only issue tokens to hosts belonging to the provider. if (!isIssuingHostname(ALL_ISSUING_CRITERIA.HOSTNAMES, url)) { return false; @@ -179,19 +170,25 @@ export class HcaptchaProvider extends Provider { return false; } - // Only issue tokens when 'application/x-www-form-urlencoded' data parameters in POST body pass defined criteria. - if (!areQualifiedBodyFormParams(ALL_ISSUING_CRITERIA.BODY_PARAMS, formData)) { - return false; + // conditionally short-circuit an expensive operation + if (ALL_ISSUING_CRITERIA.BODY_PARAMS !== undefined) { + const formData: { [key: string]: string[] | string } = getNormalizedFormData(details); + + // Only issue tokens when 'application/x-www-form-urlencoded' or 'application/json' data parameters in POST body pass defined criteria. + if (!areQualifiedBodyFormParams(ALL_ISSUING_CRITERIA.BODY_PARAMS, formData)) { + return false; + } } + this.issueInfo = { requestId: details.requestId, url: details.url }; + return true; } private matchesRedemptionCriteria( - details: chrome.webRequest.WebRequestBodyDetails, - url: URL, - formData: { [key: string]: string[] | string } + details: chrome.webRequest.WebRequestBodyDetails, ): boolean { + // Only redeem tokens for POST requests that contain data in body. if ( (details.method.toUpperCase() !== 'POST' ) || @@ -201,6 +198,8 @@ export class HcaptchaProvider extends Provider { return false; } + const url: URL = new URL(details.url); + // Only redeem tokens to hosts belonging to the provider. if (!isIssuingHostname(ALL_REDEMPTION_CRITERIA.HOSTNAMES, url)) { return false; @@ -216,11 +215,18 @@ export class HcaptchaProvider extends Provider { return false; } - // Only redeem tokens when 'application/x-www-form-urlencoded' data parameters in POST body pass defined criteria. - if (!areQualifiedBodyFormParams(ALL_REDEMPTION_CRITERIA.BODY_PARAMS, formData)) { - return false; + // conditionally short-circuit an expensive operation + if (ALL_REDEMPTION_CRITERIA.BODY_PARAMS !== undefined) { + const formData: { [key: string]: string[] | string } = getNormalizedFormData(details); + + // Only redeem tokens when 'application/x-www-form-urlencoded' or 'application/json' data parameters in POST body pass defined criteria. + if (!areQualifiedBodyFormParams(ALL_REDEMPTION_CRITERIA.BODY_PARAMS, formData)) { + return false; + } } + this.redeemInfo = { requestId: details.requestId }; + return true; } diff --git a/src/background/providers/provider.ts b/src/background/providers/provider.ts index 3fdeb7a7..968e65e0 100644 --- a/src/background/providers/provider.ts +++ b/src/background/providers/provider.ts @@ -1,4 +1,5 @@ import { Storage } from '../storage'; +import qs from 'qs'; export interface Callbacks { updateIcon(text: string): void; @@ -171,3 +172,61 @@ export function areQualifiedBodyFormParams(params: QUALIFIED_PARAMS | void, form const test: (param: string) => boolean = isQualifiedBodyFormParam.bind(null, formData); return areQualifiedParamsFound(params, test, true); } + +export function getNormalizedFormData( + details: chrome.webRequest.WebRequestBodyDetails, + flatten: boolean = false, +): { [key: string]: string[] | string } { + let formData: { [key: string]: string[] | string } = {}; + + if (details.requestBody instanceof Object) { + if (details.requestBody.formData instanceof Object) { + formData = details.requestBody.formData; + } + else if (Array.isArray(details.requestBody.raw) && (details.requestBody.raw.length > 0)) { + try { + const decodedData = details.requestBody.raw.map(val => (val && val.bytes) ? new TextDecoder().decode(new Uint8Array(val.bytes!)) : '').join(''); + let isParsed: boolean = false; + + if (!isParsed) { + // content-type: application/x-www-form-urlencoded + try { + const parsedData: any = qs.parse(decodedData); + + if ((parsedData !== undefined) && (parsedData instanceof Object)) { + isParsed = true; + formData = parsedData; + } + } + catch(e1) {} + } + + if (!isParsed) { + // content-type: application/json + try { + const parsedData: any = JSON.parse(decodedData); + + if ((parsedData !== undefined) && (parsedData instanceof Object)) { + formData = parsedData; + } + } + catch(e2) {} + } + } + catch(error) {} + } + } + + if (flatten) { + for (const key in formData) { + if ( + Array.isArray(formData[key]) && + (formData[key].length === 1) + ) { + formData[key] = formData[key][0]; + } + } + } + + return formData; +}