Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
ulixee
GitHub Repository: ulixee/secret-agent
Path: blob/main/mitm/lib/BrowserRequestMatcher.ts
1030 views
1
import ResourceType from '@secret-agent/interfaces/ResourceType';
2
import IResolvablePromise from '@secret-agent/interfaces/IResolvablePromise';
3
import { IBoundLog } from '@secret-agent/interfaces/ILog';
4
import Log from '@secret-agent/commons/Logger';
5
import { CanceledPromiseError } from '@secret-agent/commons/interfaces/IPendingWaitEvent';
6
import Resolvable from '@secret-agent/commons/Resolvable';
7
import { IPuppetResourceRequest } from '@secret-agent/interfaces/IPuppetNetworkEvents';
8
import IMitmRequestContext from '../interfaces/IMitmRequestContext';
9
import RequestSession from '../handlers/RequestSession';
10
import HeadersHandler from '../handlers/HeadersHandler';
11
12
const { log } = Log(module);
13
14
export default class BrowserRequestMatcher {
15
public requestIdToTabId = new Map<string, number>();
16
17
protected readonly logger: IBoundLog;
18
19
private readonly requestedResources: IRequestedResource[] = [];
20
21
constructor(requestSession: RequestSession) {
22
this.logger = log.createChild(module, {
23
sessionId: requestSession.sessionId,
24
});
25
requestSession.on('response', event => this.clearRequest(event.id));
26
}
27
28
public onMitmRequestedResource(mitmResource: IMitmRequestContext): IRequestedResource {
29
let browserRequest = this.findMatchingRequest(mitmResource, 'noMitmResourceId');
30
31
// if no request from browser (and unmatched), queue a new one
32
if (!browserRequest) {
33
browserRequest = {
34
url: mitmResource.url.href,
35
method: mitmResource.method,
36
...getHeaderDetails(mitmResource),
37
isHttp2Push: mitmResource.isHttp2Push,
38
mitmResourceId: mitmResource.id,
39
requestTime: mitmResource.requestTime,
40
browserRequestedPromise: new Resolvable(),
41
};
42
this.requestedResources.push(browserRequest);
43
}
44
45
browserRequest.mitmResourceId = mitmResource.id;
46
47
const resolveTimeout = setTimeout(
48
() =>
49
this.logger.warn('BrowserRequestMatcher.ResourceNotResolved', {
50
request: browserRequest,
51
}),
52
5e3,
53
).unref();
54
55
const fetchDest = HeadersHandler.getRequestHeader(mitmResource, 'sec-fetch-dest');
56
57
// NOTE: shared workers do not auto-register with chrome as of chrome 83, so we won't get a matching browserRequest
58
if (fetchDest === 'sharedworker' || fetchDest === 'serviceworker') {
59
browserRequest.browserRequestedPromise.resolve(null);
60
}
61
62
mitmResource.browserHasRequested = browserRequest.browserRequestedPromise.promise
63
.then(() => {
64
clearTimeout(resolveTimeout);
65
// copy values to mitm resource
66
if (!browserRequest?.browserRequestId) return;
67
mitmResource.resourceType = browserRequest.resourceType;
68
mitmResource.browserRequestId = browserRequest.browserRequestId;
69
mitmResource.hasUserGesture = browserRequest.hasUserGesture;
70
mitmResource.documentUrl = browserRequest.documentUrl;
71
return null;
72
})
73
// drown errors - we don't want to log cancels
74
.catch(() => clearTimeout(resolveTimeout));
75
76
return browserRequest;
77
}
78
79
public onBrowserRequestedResourceExtraDetails(
80
httpResourceLoad: IPuppetResourceRequest,
81
tabId?: number,
82
): void {
83
const match = this.requestedResources.find(
84
x => x.browserRequestId === httpResourceLoad.browserRequestId,
85
);
86
if (!match) return;
87
Object.assign(match, getHeaderDetails(httpResourceLoad));
88
89
const mitmResourceNeedsResolve = this.findMatchingRequest(
90
httpResourceLoad,
91
'hasMitmResourceId',
92
);
93
if (mitmResourceNeedsResolve && !mitmResourceNeedsResolve.browserRequestedPromise.isResolved) {
94
this.updatePendingResource(httpResourceLoad, mitmResourceNeedsResolve, tabId);
95
}
96
}
97
98
public onBrowserRequestedResource(
99
httpResourceLoad: IPuppetResourceRequest,
100
tabId?: number,
101
): IRequestedResource {
102
const { method } = httpResourceLoad;
103
104
let resource = this.findMatchingRequest(httpResourceLoad);
105
106
if (resource && resource.browserRequestedPromise.isResolved && resource.browserRequestId) {
107
// figure out how long ago this request was
108
const requestTimeDiff = Math.abs(
109
httpResourceLoad.requestTime.getTime() - resource.requestTime.getTime(),
110
);
111
if (requestTimeDiff > 5e3) resource = null;
112
}
113
114
if (!resource) {
115
if (!httpResourceLoad.url) return;
116
resource = {
117
url: httpResourceLoad.url.href,
118
method,
119
requestTime: httpResourceLoad.requestTime,
120
browserRequestedPromise: new Resolvable(),
121
...getHeaderDetails(httpResourceLoad),
122
} as IRequestedResource;
123
this.requestedResources.push(resource);
124
}
125
126
this.updatePendingResource(httpResourceLoad, resource, tabId);
127
128
return resource;
129
}
130
131
public onBrowserRequestFailed(event: {
132
resource: IPuppetResourceRequest;
133
tabId: number;
134
loadError: Error;
135
}): number {
136
this.requestIdToTabId.set(event.resource.browserRequestId, event.tabId);
137
const match =
138
this.requestedResources.find(x => x.browserRequestId === event.resource.browserRequestId) ??
139
this.findMatchingRequest(event.resource, 'hasMitmResourceId');
140
if (match) {
141
match.resourceType = event.resource.resourceType;
142
match.browserRequestId = event.resource.browserRequestId;
143
match.tabId = event.tabId;
144
match.browserRequestedPromise.resolve();
145
const id = match.mitmResourceId;
146
if (id) setTimeout(() => this.clearRequest(id), 500).unref();
147
return id;
148
}
149
this.logger.warn('BrowserViewOfResourceLoad::Failed', {
150
...event,
151
});
152
}
153
154
public cancelPending(): void {
155
for (const pending of this.requestedResources) {
156
pending.browserRequestedPromise.reject(
157
new CanceledPromiseError('Canceling: Mitm Request Session Closing'),
158
);
159
}
160
this.requestedResources.length = 0;
161
}
162
163
private updatePendingResource(
164
httpResourceLoad: IPuppetResourceRequest,
165
pendingResource: IRequestedResource,
166
tabId: number,
167
): void {
168
if (tabId) {
169
pendingResource.tabId = tabId;
170
this.requestIdToTabId.set(httpResourceLoad.browserRequestId, tabId);
171
}
172
pendingResource.browserRequestId = httpResourceLoad.browserRequestId;
173
pendingResource.documentUrl = httpResourceLoad.documentUrl;
174
pendingResource.resourceType = httpResourceLoad.resourceType;
175
pendingResource.hasUserGesture = httpResourceLoad.hasUserGesture;
176
pendingResource.browserRequestedPromise.resolve();
177
}
178
179
private clearRequest(resourceId: number): void {
180
const matchIdx = this.requestedResources.findIndex(x => x.mitmResourceId === resourceId);
181
if (matchIdx >= 0) this.requestedResources.splice(matchIdx, 1);
182
}
183
184
private findMatchingRequest(
185
resourceToMatch: IPuppetResourceRequest,
186
filter?: 'noMitmResourceId' | 'hasMitmResourceId',
187
): IRequestedResource | null {
188
const { method } = resourceToMatch;
189
const url = resourceToMatch.url?.href;
190
if (!url) return;
191
let matches = this.requestedResources.filter(x => {
192
return x.url === url && x.method === method;
193
});
194
195
if (resourceToMatch.browserRequestId) {
196
matches = matches.filter(x => {
197
if (x.browserRequestId) return x.browserRequestId === resourceToMatch.browserRequestId;
198
return true;
199
});
200
}
201
202
if (filter === 'noMitmResourceId') {
203
matches = matches.filter(x => !x.mitmResourceId);
204
}
205
if (filter === 'hasMitmResourceId') {
206
matches = matches.filter(x => !!x.mitmResourceId);
207
}
208
209
// if http2 push, we don't know what referer/origin headers the browser will use
210
// NOTE: we do this because it aligns the browserRequestId. We don't need header info
211
const h2Push = matches.find(x => x.isHttp2Push);
212
if (h2Push) return h2Push;
213
if (resourceToMatch.isHttp2Push && matches.length) return matches[0];
214
215
if (method === 'OPTIONS') {
216
const origin = HeadersHandler.getRequestHeader(resourceToMatch, 'origin');
217
return matches.find(x => x.origin === origin);
218
}
219
220
// if we have sec-fetch-dest headers, make sure they match
221
const secDest = HeadersHandler.getRequestHeader(resourceToMatch, 'sec-fetch-dest');
222
if (secDest) {
223
matches = matches.filter(x => x.secFetchDest === secDest);
224
}
225
// if we have sec-fetch-dest headers, make sure they match
226
const secSite = HeadersHandler.getRequestHeader(resourceToMatch, 'sec-fetch-site');
227
if (secSite) {
228
matches = matches.filter(x => x.secFetchSite === secSite);
229
}
230
231
if (matches.length === 1) return matches[0];
232
// otherwise, use referer
233
const referer = HeadersHandler.getRequestHeader(resourceToMatch, 'referer');
234
return matches.find(x => x.referer === referer);
235
}
236
}
237
238
function getHeaderDetails(
239
httpResourceLoad: IPuppetResourceRequest,
240
): { origin: string; referer: string; secFetchDest: string; secFetchSite: string } {
241
const origin = HeadersHandler.getRequestHeader<string>(httpResourceLoad, 'origin');
242
const referer = HeadersHandler.getRequestHeader<string>(httpResourceLoad, 'referer');
243
const secFetchDest = HeadersHandler.getRequestHeader<string>(httpResourceLoad, 'sec-fetch-dest');
244
const secFetchSite = HeadersHandler.getRequestHeader<string>(httpResourceLoad, 'sec-fetch-site');
245
return { origin, referer, secFetchDest, secFetchSite };
246
}
247
248
interface IRequestedResource {
249
url: string;
250
method: string;
251
origin: string;
252
secFetchSite: string;
253
secFetchDest: string;
254
referer: string;
255
requestTime: Date;
256
browserRequestedPromise: IResolvablePromise<void>;
257
tabId?: number;
258
mitmResourceId?: number;
259
browserRequestId?: string;
260
resourceType?: ResourceType;
261
documentUrl?: string;
262
hasUserGesture?: boolean;
263
isHttp2Push?: boolean;
264
}
265
266