From c5d621048c391cefb009c8598ed83a7b62d43a14 Mon Sep 17 00:00:00 2001 From: Abhishek B R Date: Fri, 11 Sep 2026 20:20:16 +0530 Subject: [PATCH] fix(rules): match GraphQL operationName in query params on GET requests Request payload filters (used for GraphQL operationName targeting) only read the parsed request body, so they never matched GET GraphQL requests where the operationName lives in the URL query string. For GET/HEAD requests with no body, derive the payload object from the query params, mirroring how getGraphQLDetails in harLogs/utils.ts reads GQL details from the query string. The same fix is applied in both the extension matcher (ruleMatcher.ts) and the shared rule-processor matcher (RuleHelper.js) so the extension and desktop app behave the same. Fixes #35 --- .../mv3/src/common/ruleMatcher.ts | 36 ++++++++++++++- common/rule-processor/src/RuleHelper.js | 46 +++++++++++++++++-- .../processors/DelayRequestRuleProcessor.js | 2 +- .../src/processors/ReplaceRuleProcessor.js | 2 +- .../rule-processor/tests/RuleHelper.spec.js | 44 ++++++++++++++++++ 5 files changed, 122 insertions(+), 8 deletions(-) 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 () {