@zeldrisho/pi-web-fetch 0.15.0 → 0.16.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +15 -1
- package/README.md +3 -5
- package/package.json +1 -1
- package/src/acquisition/fetch.ts +86 -1
- package/src/acquisition/llms.ts +1 -1
- package/src/cache.ts +20 -0
- package/src/challenges/aws-waf-resolver.ts +806 -0
- package/src/challenges/aws-waf-solver.ts +65 -0
- package/src/challenges/aws-waf-worker.ts +14 -0
- package/src/challenges/aws-waf.ts +34 -0
- package/src/index.ts +21 -8
- package/src/network/cookies.ts +41 -0
- package/src/network/network-redirects.ts +15 -2
- package/src/network/network-transport.ts +21 -3
- package/src/render.ts +34 -9
- package/src/service.ts +5 -4
- package/workers/aws-waf-solver.js +56 -0
- package/workers/aws-waf-worker.js +14 -0
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { createHash, scryptSync } from "node:crypto";
|
|
2
|
+
|
|
3
|
+
/** Bounded proof-of-work request sent to the AWS WAF worker. */
|
|
4
|
+
export interface AwsWafWork {
|
|
5
|
+
type: string;
|
|
6
|
+
input: string;
|
|
7
|
+
checksum: string;
|
|
8
|
+
difficulty: number;
|
|
9
|
+
memory: number;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/** Returns whether the first `difficulty` bits of a hash are zero. */
|
|
13
|
+
export function hasLeadingZeroBits(hash: Uint8Array, difficulty: number): boolean {
|
|
14
|
+
for (let bit = 0; bit < difficulty; bit += 1) {
|
|
15
|
+
const byte = hash[Math.floor(bit / 8)];
|
|
16
|
+
|
|
17
|
+
if (byte === undefined || (byte & (1 << (7 - (bit % 8)))) !== 0) return false;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
return true;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Solves the bandwidth challenge using a bounded zero-filled buffer. */
|
|
24
|
+
export function solveAwsWafBandwidth(difficulty: number): string {
|
|
25
|
+
const sizes = [1_024, 1_024, 10_240, 102_400, 1_048_576, 10_485_760];
|
|
26
|
+
|
|
27
|
+
return Buffer.alloc(sizes[Math.min(5, Math.max(0, difficulty))] ?? 1_024).toString("base64");
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Computes bounded proof-of-work inside a disposable worker. */
|
|
31
|
+
export function solveAwsWafWork(work: AwsWafWork): string | undefined {
|
|
32
|
+
if (!Number.isInteger(work.difficulty) || work.difficulty < 1 || work.difficulty > 32)
|
|
33
|
+
return undefined;
|
|
34
|
+
|
|
35
|
+
if (work.type === "NetworkBandwidth") return solveAwsWafBandwidth(work.difficulty);
|
|
36
|
+
|
|
37
|
+
if (work.type === "SHA256") {
|
|
38
|
+
for (let nonce = 0; nonce < 2_000_000; nonce += 1) {
|
|
39
|
+
const digest = createHash("sha256").update(`${work.input}${work.checksum}${nonce}`).digest();
|
|
40
|
+
|
|
41
|
+
if (hasLeadingZeroBits(digest, work.difficulty)) return String(nonce);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
if (work.type === "HashcashScrypt") {
|
|
46
|
+
const cost = work.memory > 0 ? work.memory : 128;
|
|
47
|
+
|
|
48
|
+
// Reject large or invalid costs before invoking OpenSSL.
|
|
49
|
+
if (!Number.isInteger(cost) || cost < 2 || cost > 16_384 || (cost & (cost - 1)) !== 0)
|
|
50
|
+
return undefined;
|
|
51
|
+
|
|
52
|
+
for (let nonce = 0; nonce < 100_000; nonce += 1) {
|
|
53
|
+
const digest = scryptSync(`${work.input}${work.checksum}${nonce}`, work.checksum, 32, {
|
|
54
|
+
N: cost,
|
|
55
|
+
r: 8,
|
|
56
|
+
p: 1,
|
|
57
|
+
maxmem: 64 * 1_024 * 1_024,
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
if (hasLeadingZeroBits(digest, work.difficulty)) return String(nonce);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return undefined;
|
|
65
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/* v8 ignore file -- exercised through worker integration tests. */
|
|
2
|
+
import { parentPort, workerData } from "node:worker_threads";
|
|
3
|
+
import { solveAwsWafWork, type AwsWafWork } from "./aws-waf-solver.ts";
|
|
4
|
+
|
|
5
|
+
const work: AwsWafWork = workerData;
|
|
6
|
+
|
|
7
|
+
try {
|
|
8
|
+
parentPort?.postMessage({ kind: "solved", solution: solveAwsWafWork(work) });
|
|
9
|
+
} catch (error) {
|
|
10
|
+
parentPort?.postMessage({
|
|
11
|
+
kind: "error",
|
|
12
|
+
message: error instanceof Error ? error.message : "AWS WAF worker failed.",
|
|
13
|
+
});
|
|
14
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import type { IncomingMessage } from "node:http";
|
|
2
|
+
import type { RedirectDependencies } from "../network/network-redirects.ts";
|
|
3
|
+
|
|
4
|
+
/** Cookie that carries an AWS WAF challenge token. */
|
|
5
|
+
export const AWS_WAF_TOKEN_COOKIE = "aws-waf-token";
|
|
6
|
+
|
|
7
|
+
/** A resolved AWS WAF token and the browser identity it was issued for. */
|
|
8
|
+
export interface AwsWafSolution {
|
|
9
|
+
token: string;
|
|
10
|
+
userAgent?: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** A request to resolve one AWS WAF challenge. */
|
|
14
|
+
export interface AwsWafResolveContext {
|
|
15
|
+
/** The page URL withheld by the challenge. */
|
|
16
|
+
url: URL;
|
|
17
|
+
/** Cancels the solver request. */
|
|
18
|
+
signal: AbortSignal;
|
|
19
|
+
/** Network dependencies used by the resolver. */
|
|
20
|
+
network?: RedirectDependencies;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Resolves an AWS WAF challenge to a token accepted by the target origin. */
|
|
24
|
+
export interface AwsWafResolver {
|
|
25
|
+
resolve(context: AwsWafResolveContext): Promise<AwsWafSolution | undefined>;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Returns whether a response carries an AWS WAF challenge. */
|
|
29
|
+
export function isAwsWafChallengeResponse(response: Pick<IncomingMessage, "headers">): boolean {
|
|
30
|
+
const value = response.headers["x-amzn-waf-action"];
|
|
31
|
+
const action = Array.isArray(value) ? value[0] : value;
|
|
32
|
+
|
|
33
|
+
return action?.trim().toLowerCase() === "challenge";
|
|
34
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
1
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { Text } from "@earendil-works/pi-tui";
|
|
3
3
|
import { Type } from "typebox";
|
|
4
4
|
import {
|
|
5
5
|
FETCH_MAX_OFFSET_CHARACTERS,
|
|
@@ -7,7 +7,7 @@ import {
|
|
|
7
7
|
FETCH_MAX_URL_CHARACTERS,
|
|
8
8
|
} from "./limits";
|
|
9
9
|
import { redactUrlForDisplay } from "./redact";
|
|
10
|
-
import {
|
|
10
|
+
import { renderCollapsibleOutput } from "./render";
|
|
11
11
|
import { executeWebFetch, webFetchOutputSchema, type WebFetchDetails } from "./service";
|
|
12
12
|
|
|
13
13
|
export { ExpiringLruCache } from "./cache";
|
|
@@ -20,6 +20,13 @@ export {
|
|
|
20
20
|
type AnubisSolveContext,
|
|
21
21
|
} from "./challenges/anubis.ts";
|
|
22
22
|
|
|
23
|
+
export {
|
|
24
|
+
AWS_WAF_TOKEN_COOKIE,
|
|
25
|
+
isAwsWafChallengeResponse,
|
|
26
|
+
type AwsWafResolveContext,
|
|
27
|
+
type AwsWafResolver,
|
|
28
|
+
} from "./challenges/aws-waf.ts";
|
|
29
|
+
|
|
23
30
|
export { CookieJar } from "./network/cookies.ts";
|
|
24
31
|
|
|
25
32
|
export type { FetchResult } from "./document/content.ts";
|
|
@@ -101,7 +108,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
101
108
|
/** Renders the redacted URL, optional focus query, and character offset in the call preview. */
|
|
102
109
|
renderCall(args, theme) {
|
|
103
110
|
return new Text(
|
|
104
|
-
`${theme.fg("toolTitle", theme.bold("web_fetch"))} ${theme.fg("accent", redactUrlForDisplay(args.url))}`,
|
|
111
|
+
`${theme.fg("toolTitle", theme.bold("web_fetch"))} ${theme.fg("accent", redactUrlForDisplay(args.url))}${args.query === undefined ? "" : theme.fg("muted", ` query=${JSON.stringify(args.query)}`)}`,
|
|
105
112
|
0,
|
|
106
113
|
0,
|
|
107
114
|
);
|
|
@@ -115,15 +122,21 @@ export default function (pi: ExtensionAPI) {
|
|
|
115
122
|
* Renders partial text or "Fetching…" while pending. Completed text gets a
|
|
116
123
|
* detail summary when available and a collapsible body; absent text shows "No content".
|
|
117
124
|
*/
|
|
118
|
-
renderResult(result, { expanded, isPartial }, theme) {
|
|
125
|
+
renderResult(result, { expanded, isPartial }, theme, context) {
|
|
119
126
|
const content = result.content.find((item) => item.type === "text");
|
|
120
127
|
|
|
121
128
|
if (isPartial) return new Text(theme.fg("warning", content?.text || "Fetching…"), 0, 0);
|
|
122
129
|
|
|
123
130
|
return content?.type === "text"
|
|
124
|
-
?
|
|
125
|
-
|
|
126
|
-
|
|
131
|
+
? renderCollapsibleOutput(
|
|
132
|
+
content.text,
|
|
133
|
+
expanded,
|
|
134
|
+
theme,
|
|
135
|
+
context?.isError,
|
|
136
|
+
result.details?.truncated && result.details.nextOffset !== undefined
|
|
137
|
+
? `[Content truncated. Continue with offset=${result.details.nextOffset} to read the next chunk.]`
|
|
138
|
+
: undefined,
|
|
139
|
+
)
|
|
127
140
|
: new Text(theme.fg("dim", "No content"), 0, 0);
|
|
128
141
|
},
|
|
129
142
|
});
|
package/src/network/cookies.ts
CHANGED
|
@@ -211,6 +211,47 @@ export class CookieJar {
|
|
|
211
211
|
this.#evict();
|
|
212
212
|
}
|
|
213
213
|
|
|
214
|
+
/**
|
|
215
|
+
* Stores a challenge token returned by a resolver.
|
|
216
|
+
*
|
|
217
|
+
* The token is untrusted input. It is accepted only for the target origin,
|
|
218
|
+
* scoped to the whole origin, and held in memory for the current acquisition.
|
|
219
|
+
*
|
|
220
|
+
* @param name - Cookie name
|
|
221
|
+
* @param value - Cookie value
|
|
222
|
+
* @param url - The page URL the cookie belongs to
|
|
223
|
+
* @returns Whether the cookie was accepted
|
|
224
|
+
*/
|
|
225
|
+
set(name: string, value: string, url: URL): boolean {
|
|
226
|
+
const token = value.trim();
|
|
227
|
+
|
|
228
|
+
if (
|
|
229
|
+
name.length === 0 ||
|
|
230
|
+
name.length > MAX_COOKIE_NAME_CHARACTERS ||
|
|
231
|
+
!COOKIE_NAME.test(name) ||
|
|
232
|
+
token.length === 0 ||
|
|
233
|
+
token.length > MAX_COOKIE_VALUE_CHARACTERS ||
|
|
234
|
+
hasInvalidCookieCharacter(token) ||
|
|
235
|
+
/[\s";,\\]/u.test(token)
|
|
236
|
+
) {
|
|
237
|
+
return false;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
const key = `${url.origin}\0${name}\0/`;
|
|
241
|
+
this.#cookies.delete(key);
|
|
242
|
+
this.#cookies.set(key, {
|
|
243
|
+
name,
|
|
244
|
+
value: token,
|
|
245
|
+
origin: url.origin,
|
|
246
|
+
path: "/",
|
|
247
|
+
secure: url.protocol === "https:",
|
|
248
|
+
createdAt: Date.now(),
|
|
249
|
+
});
|
|
250
|
+
this.#evict();
|
|
251
|
+
|
|
252
|
+
return true;
|
|
253
|
+
}
|
|
254
|
+
|
|
214
255
|
/**
|
|
215
256
|
* Returns a Cookie header value for a URL, excluding expired, insecure, and
|
|
216
257
|
* cross-origin cookies.
|
|
@@ -29,11 +29,16 @@ export interface RedirectDependencies {
|
|
|
29
29
|
target: ValidatedTarget,
|
|
30
30
|
signal: AbortSignal,
|
|
31
31
|
headers?: Readonly<Record<string, string>>,
|
|
32
|
+
options?: { method?: string; body?: string | Uint8Array },
|
|
32
33
|
) => Promise<IncomingMessage>;
|
|
33
34
|
random?: () => number;
|
|
34
35
|
sleep?: (milliseconds: number, signal: AbortSignal) => Promise<void>;
|
|
35
36
|
/** Origin-scoped cookies for the current acquisition, including challenge handshakes. */
|
|
36
37
|
cookies?: CookieJar;
|
|
38
|
+
/** User-Agent override that persists across redirects. */
|
|
39
|
+
userAgent?: string;
|
|
40
|
+
/** Headers that survive a redirect, used by challenge handshakes that need browser identity. */
|
|
41
|
+
persistentHeaders?: Readonly<Record<string, string>>;
|
|
37
42
|
}
|
|
38
43
|
|
|
39
44
|
/** Creates the consistent abort error used by redirect and retry coordination. */
|
|
@@ -144,7 +149,14 @@ async function coordinatedRequest(
|
|
|
144
149
|
const release = await acquireOrigin(target.url.origin, signal);
|
|
145
150
|
let response: IncomingMessage;
|
|
146
151
|
const cookieHeader = dependencies.cookies?.headerFor(target.url);
|
|
147
|
-
|
|
152
|
+
|
|
153
|
+
const headersWithUserAgent = dependencies.userAgent
|
|
154
|
+
? { "User-Agent": dependencies.userAgent, ...headers }
|
|
155
|
+
: headers;
|
|
156
|
+
|
|
157
|
+
const requestHeaders = cookieHeader
|
|
158
|
+
? { Cookie: cookieHeader, ...headersWithUserAgent }
|
|
159
|
+
: headersWithUserAgent;
|
|
148
160
|
|
|
149
161
|
try {
|
|
150
162
|
response = await request(target, signal, requestHeaders);
|
|
@@ -197,7 +209,8 @@ export async function requestFollowingRedirects(
|
|
|
197
209
|
response.resume();
|
|
198
210
|
target = await awaitWithAbort(validateUrl(new URL(location, target.url)), signal);
|
|
199
211
|
// Validators describe the originally cached resource and must not leak to a redirect target.
|
|
200
|
-
|
|
212
|
+
// Challenge handshakes opt into a fixed browser identity instead.
|
|
213
|
+
requestHeaders = dependencies.persistentHeaders ?? {};
|
|
201
214
|
}
|
|
202
215
|
|
|
203
216
|
throw new Error("web_fetch followed too many redirects.");
|
|
@@ -8,6 +8,9 @@ import type { ValidatedTarget } from "./network-policy.ts";
|
|
|
8
8
|
/** Maximum size in bytes for raw HTTP response bodies. */
|
|
9
9
|
export const FETCH_MAX_BYTES = 5 * 1_024 * 1_024;
|
|
10
10
|
|
|
11
|
+
/** Fixed User-Agent used for page requests and challenge solvers. */
|
|
12
|
+
export const WEB_FETCH_USER_AGENT = "Mozilla/5.0 (compatible; PiWebFetch/1.0; +https://pi.dev)";
|
|
13
|
+
|
|
11
14
|
/**
|
|
12
15
|
* Per-address connect deadline. A hanging address is abandoned after this
|
|
13
16
|
* budget so the next validated address can be tried inside the overall
|
|
@@ -67,6 +70,8 @@ async function requestOnce(
|
|
|
67
70
|
signal: AbortSignal,
|
|
68
71
|
attemptTimeoutMs: number,
|
|
69
72
|
headers: Readonly<Record<string, string>>,
|
|
73
|
+
method = "GET",
|
|
74
|
+
body?: string | Uint8Array,
|
|
70
75
|
): Promise<IncomingMessage> {
|
|
71
76
|
const family = isIP(address);
|
|
72
77
|
|
|
@@ -114,11 +119,12 @@ async function requestOnce(
|
|
|
114
119
|
target.url,
|
|
115
120
|
{
|
|
116
121
|
lookup,
|
|
122
|
+
method,
|
|
117
123
|
signal: controller.signal,
|
|
118
124
|
headers: {
|
|
119
125
|
Accept:
|
|
120
126
|
"text/markdown;q=1.0, text/x-markdown;q=0.9, text/plain;q=0.8, text/html;q=0.7, application/xhtml+xml;q=0.7, application/json;q=0.7, application/xml;q=0.7, */*;q=0.1",
|
|
121
|
-
"User-Agent":
|
|
127
|
+
"User-Agent": WEB_FETCH_USER_AGENT,
|
|
122
128
|
...headers,
|
|
123
129
|
},
|
|
124
130
|
},
|
|
@@ -139,7 +145,7 @@ async function requestOnce(
|
|
|
139
145
|
if (attemptExpired) finish(() => reject(unreachableError()));
|
|
140
146
|
else finish(() => reject(error));
|
|
141
147
|
});
|
|
142
|
-
outgoing.end();
|
|
148
|
+
outgoing.end(body);
|
|
143
149
|
});
|
|
144
150
|
}
|
|
145
151
|
|
|
@@ -148,6 +154,10 @@ export interface RequestPinnedOptions {
|
|
|
148
154
|
attemptTimeoutMs?: number;
|
|
149
155
|
/** Additional fixed request headers, used for conditional cache revalidation. */
|
|
150
156
|
headers?: Readonly<Record<string, string>>;
|
|
157
|
+
/** HTTP method; defaults to GET. */
|
|
158
|
+
method?: string;
|
|
159
|
+
/** Request body for non-GET requests. */
|
|
160
|
+
body?: string | Uint8Array;
|
|
151
161
|
}
|
|
152
162
|
|
|
153
163
|
/**
|
|
@@ -169,7 +179,15 @@ export async function requestPinned(
|
|
|
169
179
|
|
|
170
180
|
for (const address of addresses) {
|
|
171
181
|
try {
|
|
172
|
-
return await requestOnce(
|
|
182
|
+
return await requestOnce(
|
|
183
|
+
target,
|
|
184
|
+
address,
|
|
185
|
+
signal,
|
|
186
|
+
attemptTimeoutMs,
|
|
187
|
+
options.headers ?? {},
|
|
188
|
+
options.method,
|
|
189
|
+
options.body,
|
|
190
|
+
);
|
|
173
191
|
} catch (error) {
|
|
174
192
|
lastError = error;
|
|
175
193
|
|
package/src/render.ts
CHANGED
|
@@ -1,21 +1,46 @@
|
|
|
1
|
-
import { keyHint } from "@earendil-works/pi-coding-agent";
|
|
1
|
+
import { getMarkdownTheme, keyHint } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { Container, Markdown, Spacer, Text } from "@earendil-works/pi-tui";
|
|
2
3
|
|
|
3
4
|
const COLLAPSED_LINES = 10;
|
|
4
5
|
|
|
5
|
-
/**
|
|
6
|
-
export function
|
|
6
|
+
/** Render document content separately from Pi-style interface notices. */
|
|
7
|
+
export function renderCollapsibleOutput(
|
|
8
|
+
output: string,
|
|
9
|
+
expanded: boolean,
|
|
10
|
+
theme: { fg(color: "toolOutput" | "muted" | "warning", text: string): string },
|
|
11
|
+
isError = false,
|
|
12
|
+
warning?: string,
|
|
13
|
+
): Container {
|
|
7
14
|
const lines = output.replace(/\r\n/g, "\n").split("\n");
|
|
8
15
|
|
|
9
16
|
while (lines.at(-1) === "") lines.pop();
|
|
10
|
-
const
|
|
11
|
-
const
|
|
12
|
-
|
|
17
|
+
const visible = expanded ? lines : lines.slice(0, COLLAPSED_LINES);
|
|
18
|
+
const remaining = lines.length - visible.length;
|
|
19
|
+
const component = new Container();
|
|
20
|
+
const body = `\n${visible.join("\n")}`;
|
|
13
21
|
|
|
14
|
-
|
|
22
|
+
component.addChild(
|
|
23
|
+
isError
|
|
24
|
+
? new Text(theme.fg("toolOutput", body), 0, 0)
|
|
25
|
+
: new Markdown(body, 0, 0, getMarkdownTheme(), {
|
|
26
|
+
color: (text) => theme.fg("toolOutput", text),
|
|
27
|
+
}),
|
|
28
|
+
);
|
|
15
29
|
|
|
16
30
|
if (remaining > 0) {
|
|
17
|
-
|
|
31
|
+
component.addChild(
|
|
32
|
+
new Text(
|
|
33
|
+
`${theme.fg("muted", `... (${remaining} more lines, ${lines.length} total,`)} ${keyHint("app.tools.expand", "to expand")}${theme.fg("muted", ")")}`,
|
|
34
|
+
0,
|
|
35
|
+
0,
|
|
36
|
+
),
|
|
37
|
+
);
|
|
18
38
|
}
|
|
19
39
|
|
|
20
|
-
|
|
40
|
+
if (warning) {
|
|
41
|
+
component.addChild(new Spacer(1));
|
|
42
|
+
component.addChild(new Text(theme.fg("warning", warning), 0, 0));
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
return component;
|
|
21
46
|
}
|
package/src/service.ts
CHANGED
|
@@ -8,7 +8,7 @@ import {
|
|
|
8
8
|
truncateHead,
|
|
9
9
|
type AgentToolResult,
|
|
10
10
|
} from "@earendil-works/pi-coding-agent";
|
|
11
|
-
import { ExpiringLruCache, stableKeyHash, type CachePersistence } from "./cache";
|
|
11
|
+
import { ExpiringLruCache, stableKeyHash, canonicalKey, type CachePersistence } from "./cache";
|
|
12
12
|
import { sliceCompleteDocument, type CompleteDocument } from "./document/content.ts";
|
|
13
13
|
import { revalidateCompleteDocument, type FetchRemoteDependencies } from "./acquisition/fetch.ts";
|
|
14
14
|
import { fetchDocumentWithLlmsTxtSupport } from "./acquisition/llms.ts";
|
|
@@ -430,7 +430,8 @@ export async function executeWebFetch(
|
|
|
430
430
|
|
|
431
431
|
const displayRequestedUrl = redactUrlForDisplay(params.url);
|
|
432
432
|
const sourceUrl = urlWithoutFragment(params.url);
|
|
433
|
-
|
|
433
|
+
const cacheKey = canonicalKey({ url: sourceUrl });
|
|
434
|
+
let document = fetchCache.get(cacheKey);
|
|
434
435
|
const now = Date.now();
|
|
435
436
|
|
|
436
437
|
const fresh = Boolean(
|
|
@@ -459,7 +460,7 @@ export async function executeWebFetch(
|
|
|
459
460
|
const stale = document;
|
|
460
461
|
|
|
461
462
|
const acquisition = await inflightFetches.run(
|
|
462
|
-
|
|
463
|
+
cacheKey,
|
|
463
464
|
async (sharedSignal) => {
|
|
464
465
|
let fetched: CompleteDocument;
|
|
465
466
|
let acquisitionStatus: FetchAcquisition["cacheStatus"] = "miss";
|
|
@@ -479,7 +480,7 @@ export async function executeWebFetch(
|
|
|
479
480
|
}
|
|
480
481
|
|
|
481
482
|
if (!fetched.extractionDiagnostics?.botWall) {
|
|
482
|
-
fetchCache.set(
|
|
483
|
+
fetchCache.set(cacheKey, fetched, Date.now() + CACHE_STALE_RETENTION_MS);
|
|
483
484
|
}
|
|
484
485
|
|
|
485
486
|
return { document: fetched, cacheStatus: acquisitionStatus };
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { createHash, scryptSync } from "node:crypto";
|
|
2
|
+
|
|
3
|
+
/** Returns whether the first `difficulty` bits of a hash are zero. */
|
|
4
|
+
export function hasLeadingZeroBits(hash, difficulty) {
|
|
5
|
+
for (let bit = 0; bit < difficulty; bit += 1) {
|
|
6
|
+
const byte = hash[Math.floor(bit / 8)];
|
|
7
|
+
|
|
8
|
+
if (byte === undefined || (byte & (1 << (7 - (bit % 8)))) !== 0) return false;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
return true;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** Solves the bandwidth challenge using a bounded zero-filled buffer. */
|
|
15
|
+
export function solveAwsWafBandwidth(difficulty) {
|
|
16
|
+
const sizes = [1_024, 1_024, 10_240, 102_400, 1_048_576, 10_485_760];
|
|
17
|
+
|
|
18
|
+
return Buffer.alloc(sizes[Math.min(5, Math.max(0, difficulty))] ?? 1_024).toString("base64");
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Computes bounded proof-of-work inside a disposable worker. */
|
|
22
|
+
export function solveAwsWafWork(work) {
|
|
23
|
+
if (!Number.isInteger(work.difficulty) || work.difficulty < 1 || work.difficulty > 32)
|
|
24
|
+
return undefined;
|
|
25
|
+
|
|
26
|
+
if (work.type === "NetworkBandwidth") return solveAwsWafBandwidth(work.difficulty);
|
|
27
|
+
|
|
28
|
+
if (work.type === "SHA256") {
|
|
29
|
+
for (let nonce = 0; nonce < 2_000_000; nonce += 1) {
|
|
30
|
+
const digest = createHash("sha256").update(`${work.input}${work.checksum}${nonce}`).digest();
|
|
31
|
+
|
|
32
|
+
if (hasLeadingZeroBits(digest, work.difficulty)) return String(nonce);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
if (work.type === "HashcashScrypt") {
|
|
37
|
+
const cost = work.memory > 0 ? work.memory : 128;
|
|
38
|
+
|
|
39
|
+
// Reject large or invalid costs before invoking OpenSSL.
|
|
40
|
+
if (!Number.isInteger(cost) || cost < 2 || cost > 16_384 || (cost & (cost - 1)) !== 0)
|
|
41
|
+
return undefined;
|
|
42
|
+
|
|
43
|
+
for (let nonce = 0; nonce < 100_000; nonce += 1) {
|
|
44
|
+
const digest = scryptSync(`${work.input}${work.checksum}${nonce}`, work.checksum, 32, {
|
|
45
|
+
N: cost,
|
|
46
|
+
r: 8,
|
|
47
|
+
p: 1,
|
|
48
|
+
maxmem: 64 * 1_024 * 1_024,
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
if (hasLeadingZeroBits(digest, work.difficulty)) return String(nonce);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
return undefined;
|
|
56
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/* v8 ignore file -- exercised through worker integration tests. */
|
|
2
|
+
import { parentPort, workerData } from "node:worker_threads";
|
|
3
|
+
import { solveAwsWafWork } from "./aws-waf-solver.js";
|
|
4
|
+
|
|
5
|
+
const work = workerData;
|
|
6
|
+
|
|
7
|
+
try {
|
|
8
|
+
parentPort?.postMessage({ kind: "solved", solution: solveAwsWafWork(work) });
|
|
9
|
+
} catch (error) {
|
|
10
|
+
parentPort?.postMessage({
|
|
11
|
+
kind: "error",
|
|
12
|
+
message: error instanceof Error ? error.message : "AWS WAF worker failed.",
|
|
13
|
+
});
|
|
14
|
+
}
|