diff --git a/browser-extension/mv3/src/common/ruleMatcher.ts b/browser-extension/mv3/src/common/ruleMatcher.ts index d8d5c81f9d..626904fd18 100644 --- a/browser-extension/mv3/src/common/ruleMatcher.ts +++ b/browser-extension/mv3/src/common/ruleMatcher.ts @@ -127,13 +127,47 @@ const matchRequestWithRuleSourceFilters = function ( case SourceFilterTypes.RESOURCE_TYPE: return values.includes(requestDetails.type); case SourceFilterTypes.REQUEST_PAYLOAD: - return matchRequestPayload(values, requestDetails.requestData); + return matchRequestPayload(values, getRequestPayloadForMatching(requestDetails)); default: return true; } }); }; +// Picks the object that request payload filters (e.g. GraphQL operationName) should match against. +// POST style requests keep their JSON body. GET/HEAD requests carry no body, so their payload +// (like the GraphQL operationName) lives in the URL query params instead. This mirrors how +// getGraphQLDetails in harLogs/utils.ts reads GQL details from the query string on GET requests. +const getRequestPayloadForMatching = (requestDetails: AJAXRequestDetails) => { + const { requestData, url, method } = requestDetails; + + if (requestData && typeof requestData === "object" && Object.keys(requestData).length > 0) { + return requestData; + } + + const normalizedMethod = (method || "").toUpperCase(); + if (normalizedMethod === "GET" || normalizedMethod === "HEAD") { + return parseQueryParams(url); + } + + return requestData; +}; + +const parseQueryParams = (url: string): Record => { + if (!url) return {}; + + try { + const searchParams = new URL(url).searchParams; + const queryParams: Record = {}; + searchParams.forEach((value, key) => { + queryParams[key] = value; + }); + return queryParams; + } catch (e) { + return {}; + } +}; + const matchRequestPayload = (requestPayloadFilter: RequestPayloadFilter, requestData: any) => { if (!requestPayloadFilter) return true; if (typeof requestPayloadFilter === "object" && Object.keys(requestPayloadFilter).length === 0) return true; diff --git a/common/rule-processor/src/RuleHelper.js b/common/rule-processor/src/RuleHelper.js index a911cddc2b..93d74b00c9 100644 --- a/common/rule-processor/src/RuleHelper.js +++ b/common/rule-processor/src/RuleHelper.js @@ -156,7 +156,7 @@ class RuleMatcher { let destination = typeof pair.destination !== "undefined" ? pair.destination : null; let newResultingUrl = null; - if (RuleMatcher.matchRequestWithRuleSourceFilters(pair.source.filters, requestDetails)) { + if (RuleMatcher.matchRequestWithRuleSourceFilters(pair.source.filters, requestDetails, url)) { newResultingUrl = RuleMatcher.matchUrlWithRuleSource(pair.source, url, destination); } @@ -185,7 +185,7 @@ class RuleMatcher { return resultingUrl !== url ? resultingUrl : null; } - static matchRequestWithRuleSourceFilters(sourceFilters, requestDetails) { + static matchRequestWithRuleSourceFilters(sourceFilters, requestDetails, url) { if (!sourceFilters || !requestDetails) { return true; } @@ -225,12 +225,16 @@ class RuleMatcher { break; case CONSTANTS.RULE_SOURCE_FILTER_TYPES.REQUEST_DATA: + // eslint-disable-next-line no-case-declarations + const requestPayload = RuleMatcher.getRequestPayloadForMatching( + requestDetails.requestData, + url, + requestDetails.method + ); if ( // although currently only accepts one entry from UI // but this is to be compatible with changes made to accept array of filter values - !filterValues.some((filterValue) => - this.isRequestPayloadFilterApplicable(requestDetails.requestData, filterValue) - ) + !filterValues.some((filterValue) => this.isRequestPayloadFilterApplicable(requestPayload, filterValue)) ) { return false; } @@ -391,6 +395,38 @@ class RuleMatcher { return false; } + + // Picks the object that request payload filters (e.g. GraphQL operationName) should match against. + // POST style requests keep their JSON body. GET/HEAD requests carry no body, so their payload + // (like the GraphQL operationName) lives in the URL query params instead. This mirrors how + // getGraphQLDetails in harLogs/utils.ts reads GQL details from the query string on GET requests. + static getRequestPayloadForMatching(requestData, url, method) { + if (requestData && typeof requestData === "object" && Object.keys(requestData).length > 0) { + return requestData; + } + + const normalizedMethod = (method || "").toUpperCase(); + if (normalizedMethod === "GET" || normalizedMethod === "HEAD") { + return RuleMatcher.parseQueryParams(url); + } + + return requestData; + } + + static parseQueryParams(url) { + if (!url) return {}; + + try { + const searchParams = new URL(url).searchParams; + const queryParams = {}; + searchParams.forEach((value, key) => { + queryParams[key] = value; + }); + return queryParams; + } catch (e) { + return {}; + } + } } export default RuleMatcher; diff --git a/common/rule-processor/src/processors/DelayRequestRuleProcessor.js b/common/rule-processor/src/processors/DelayRequestRuleProcessor.js index aecd93ba74..b125fa63b0 100644 --- a/common/rule-processor/src/processors/DelayRequestRuleProcessor.js +++ b/common/rule-processor/src/processors/DelayRequestRuleProcessor.js @@ -17,7 +17,7 @@ class DelayRequestRuleProcessor { // If Source does not match, proceed with next pair if ( - !RuleHelper.matchRequestWithRuleSourceFilters(pair.source.filters, details) || + !RuleHelper.matchRequestWithRuleSourceFilters(pair.source.filters, details, requestURL) || RuleHelper.matchUrlWithRuleSource(pair.source, requestURL) === null ) { continue; diff --git a/common/rule-processor/src/processors/ReplaceRuleProcessor.js b/common/rule-processor/src/processors/ReplaceRuleProcessor.js index a5d59bf916..be3ff0866f 100644 --- a/common/rule-processor/src/processors/ReplaceRuleProcessor.js +++ b/common/rule-processor/src/processors/ReplaceRuleProcessor.js @@ -13,7 +13,7 @@ class ReplaceRuleProcessor { pair = pairs[i]; pair.from = pair.from || ""; - if (pair.source && !RuleHelper.matchRequestWithRuleSourceFilters(pair.source.filters, details)) { + if (pair.source && !RuleHelper.matchRequestWithRuleSourceFilters(pair.source.filters, details, requestURL)) { continue; } diff --git a/common/rule-processor/tests/RuleHelper.spec.js b/common/rule-processor/tests/RuleHelper.spec.js index 7d84b8c3ca..ae8f0af338 100644 --- a/common/rule-processor/tests/RuleHelper.spec.js +++ b/common/rule-processor/tests/RuleHelper.spec.js @@ -345,6 +345,50 @@ describe("RuleHelper: ", function () { expect(RuleHelper.matchRequestWithRuleSourceFilters(sourceFilters, requestDetails)).toBeFalsy(); }); }); + + describe("#matchRequestWithRuleSourceFilters (request payload from query params)", function () { + // GraphQL operationName targeting is a request payload filter with key "operationName". + const gqlSourceFilters = { + [CONSTANTS.RULE_SOURCE_FILTER_TYPES.REQUEST_DATA]: { + key: "operationName", + value: "GetUser", + }, + }; + + it("should match a GET GraphQL request whose operationName is in the query params", function () { + const requestDetails = { + method: "GET", + type: "xmlhttprequest", + pageUrl: URL_SOURCES.EXAMPLE, + }; + const url = "https://example.com/graphql?operationName=GetUser&variables=%7B%7D"; + + expect(RuleHelper.matchRequestWithRuleSourceFilters(gqlSourceFilters, requestDetails, url)).toBeTruthy(); + }); + + it("should not match when the query param operationName is different", function () { + const requestDetails = { + method: "GET", + type: "xmlhttprequest", + pageUrl: URL_SOURCES.EXAMPLE, + }; + const url = "https://example.com/graphql?operationName=GetTeams"; + + expect(RuleHelper.matchRequestWithRuleSourceFilters(gqlSourceFilters, requestDetails, url)).toBeFalsy(); + }); + + it("should still match a POST GraphQL request using the request body", function () { + const requestDetails = { + method: "POST", + type: "xmlhttprequest", + pageUrl: URL_SOURCES.EXAMPLE, + requestData: { operationName: "GetUser", variables: {} }, + }; + const url = "https://example.com/graphql"; + + expect(RuleHelper.matchRequestWithRuleSourceFilters(gqlSourceFilters, requestDetails, url)).toBeTruthy(); + }); + }); }); describe("#headerModificationTypes", function () {