diff --git a/src/domain/javascript/javascriptStaticAnalysis.partialEndpoints.test.ts b/src/domain/javascript/javascriptStaticAnalysis.partialEndpoints.test.ts index 6eb4c78f4e..37be9a1df8 100644 --- a/src/domain/javascript/javascriptStaticAnalysis.partialEndpoints.test.ts +++ b/src/domain/javascript/javascriptStaticAnalysis.partialEndpoints.test.ts @@ -263,3 +263,114 @@ it("keeps exact literal targets exact", () => { }, ]); }); + +it("resolves inline Request constructor targets with literal URLs", () => { + expect( + endpoints(` + fetch(new Request("/api/chat", { method: "POST" })); + window.fetch(new Request("/api/window")); + `), + ).toEqual([ + { + kind: "network", + value: "/api/chat", + mechanism: "call:fetch", + resolution: undefined, + }, + { + kind: "network", + value: "/api/window", + mechanism: "call:window.fetch", + resolution: undefined, + }, + ]); +}); + +it("keeps Request calls without new partial", () => { + expect(endpoints('fetch(Request("/api/stream"));')).toEqual([ + { + kind: "network", + value: 'Request("/api/stream")', + mechanism: "call:fetch", + resolution: "partial", + }, + ]); +}); + +it("resolves unchanged Request objects and aliases", () => { + expect( + endpoints(` + const req = new Request("/api/chat", { method: "POST" }); + const alias = req; + fetch(alias); + `), + ).toEqual([ + { + kind: "network", + value: "/api/chat", + mechanism: "call:fetch", + resolution: undefined, + }, + ]); +}); + +it("keeps Request objects partial when called with toString", () => { + expect( + endpoints(` + const req = new Request("/api/chat"); + fetch(req.toString()); + `), + ).toEqual([ + { + kind: "network", + value: "req.toString()", + mechanism: "call:fetch", + resolution: "partial", + }, + ]); +}); + +it("keeps Request targets partial on non-fetch network APIs", () => { + expect( + endpoints(` + const socketReq = new Request("wss://host/socket"); + new WebSocket(new Request("wss://host/direct")); + new WebSocket(socketReq); + navigator.sendBeacon(new Request("https://api.example.test/beacon")); + `), + ).toEqual([ + { + kind: "network", + value: 'new Request("wss://host/direct")', + mechanism: "call:WebSocket", + resolution: "partial", + }, + { + kind: "network", + value: "socketReq", + mechanism: "call:WebSocket", + resolution: "partial", + }, + { + kind: "network", + value: 'new Request("https://api.example.test/beacon")', + mechanism: "call:navigator.sendBeacon", + resolution: "partial", + }, + ]); +}); + +it("keeps Request objects partial when Request is locally shadowed", () => { + expect( + endpoints(` + function test(Request) { + fetch(new Request("/api/shadowed")); + } + `), + ).toContainEqual({ + kind: "network", + value: 'new Request("/api/shadowed")', + mechanism: "call:fetch", + resolution: "partial", + }); +}); diff --git a/src/domain/javascript/javascriptStaticAnalysisCalls.ts b/src/domain/javascript/javascriptStaticAnalysisCalls.ts index 9509e970e6..0276128449 100644 --- a/src/domain/javascript/javascriptStaticAnalysisCalls.ts +++ b/src/domain/javascript/javascriptStaticAnalysisCalls.ts @@ -434,6 +434,26 @@ export const indexVitePreloadDependencyTable = ( return escapedOrMutated ? dependencies.map(() => undefined) : dependencies; }; +const isFetchCall = ( + node: t.Node, + bindings: JavaScriptSemanticAnalysisState, +): boolean => + (t.isCallExpression(node) || t.isOptionalCallExpression(node)) && + isUnmodifiedGlobalReference(node.callee, bindings, "fetch"); + +const requestConstructorUrl = ( + node: t.Node, + bindings: JavaScriptSemanticAnalysisState, +): string | undefined => { + const target = unwrapJavaScriptExpression(node).node; + if ( + t.isNewExpression(target) && + isUnmodifiedGlobalReference(target.callee, bindings, "Request") + ) + return stringValue(target.arguments[0]); + return undefined; +}; + const inspectEndpointCall = ( node: JavaScriptCallLike, name: string, @@ -444,7 +464,11 @@ const inspectEndpointCall = ( if (isKnownNetworkRequest(node, bindings)) { const target = argumentNode(node.arguments[0]); if (target === undefined) return; - const literal = stringValue(target); + const literal = + stringValue(target) ?? + (isFetchCall(node, bindings) + ? requestConstructorUrl(target, bindings) + : undefined); if (literal !== undefined) { addEndpoint(context, { node, @@ -529,11 +553,7 @@ const isKnownNetworkRequest = ( node: JavaScriptCallLike, bindings: JavaScriptSemanticAnalysisState, ): boolean => { - if ( - (t.isCallExpression(node) || t.isOptionalCallExpression(node)) && - isUnmodifiedGlobalReference(node.callee, bindings, "fetch") - ) - return true; + if (isFetchCall(node, bindings)) return true; if ( t.isNewExpression(node) && (isUnmodifiedGlobalReference(node.callee, bindings, "WebSocket") || @@ -655,6 +675,7 @@ const urlObjectRequestTarget = ( bindings: JavaScriptSemanticAnalysisState, index: JavaScriptBindingIndex, seen = new Set(), + isToString = false, ): string | undefined => { let target = unwrapJavaScriptExpression(node).node; if ( @@ -666,8 +687,10 @@ const urlObjectRequestTarget = ( (t.isMemberExpression(callee) || t.isOptionalMemberExpression(callee)) && semanticStaticPropertyName(callee.property, callee.computed) === "toString" - ) + ) { target = unwrapJavaScriptExpression(callee.object).node; + isToString = true; + } } if (!t.isIdentifier(target) || index.referenceMayHaveChangedAt(site, target)) return undefined; @@ -688,7 +711,18 @@ const urlObjectRequestTarget = ( if (initializer === undefined) return undefined; const initialized = unwrapJavaScriptExpression(initializer).node; if (t.isIdentifier(initialized)) - return urlObjectRequestTarget(initialized, site, bindings, index, seen); + return urlObjectRequestTarget( + initialized, + site, + bindings, + index, + seen, + isToString, + ); + if (!isToString && isFetchCall(site, bindings)) { + const requestUrl = requestConstructorUrl(initialized, bindings); + if (requestUrl !== undefined) return requestUrl; + } if (!t.isNewExpression(initialized)) return undefined; if (!isUnmodifiedGlobalReference(initialized.callee, bindings, "URL")) return undefined;