@swf2es/desktop 0.0.0-stage → 0.1.14
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/LICENSE +202 -0
- package/README.md +132 -2
- package/dist/launch/electron.d.ts +14 -0
- package/dist/launch/electron.d.ts.map +1 -0
- package/dist/launch/electron.js +40 -0
- package/dist/launch/electron.js.map +1 -0
- package/dist/launch/launch.d.ts +3 -0
- package/dist/launch/launch.d.ts.map +1 -0
- package/dist/launch/launch.js +69 -0
- package/dist/launch/launch.js.map +1 -0
- package/dist/main/addresses.d.ts +7 -0
- package/dist/main/addresses.d.ts.map +1 -0
- package/dist/main/addresses.js +155 -0
- package/dist/main/addresses.js.map +1 -0
- package/dist/main/air.d.ts +71 -0
- package/dist/main/air.d.ts.map +1 -0
- package/dist/main/air.js +216 -0
- package/dist/main/air.js.map +1 -0
- package/dist/main/connections.d.ts +38 -0
- package/dist/main/connections.d.ts.map +1 -0
- package/dist/main/connections.js +103 -0
- package/dist/main/connections.js.map +1 -0
- package/dist/main/decompress.d.ts +51 -0
- package/dist/main/decompress.d.ts.map +1 -0
- package/dist/main/decompress.js +184 -0
- package/dist/main/decompress.js.map +1 -0
- package/dist/main/files.d.ts +9 -0
- package/dist/main/files.d.ts.map +1 -0
- package/dist/main/files.js +120 -0
- package/dist/main/files.js.map +1 -0
- package/dist/main/main.d.ts +2 -0
- package/dist/main/main.d.ts.map +1 -0
- package/dist/main/main.js +979 -0
- package/dist/main/main.js.map +1 -0
- package/dist/main/network.d.ts +182 -0
- package/dist/main/network.d.ts.map +1 -0
- package/dist/main/network.js +909 -0
- package/dist/main/network.js.map +1 -0
- package/dist/main/policy.d.ts +103 -0
- package/dist/main/policy.d.ts.map +1 -0
- package/dist/main/policy.js +481 -0
- package/dist/main/policy.js.map +1 -0
- package/dist/main/protocol.d.ts +14 -0
- package/dist/main/protocol.d.ts.map +1 -0
- package/dist/main/protocol.js +216 -0
- package/dist/main/protocol.js.map +1 -0
- package/dist/main/sandbox.d.ts +67 -0
- package/dist/main/sandbox.d.ts.map +1 -0
- package/dist/main/sandbox.js +182 -0
- package/dist/main/sandbox.js.map +1 -0
- package/dist/main/settings.d.ts +37 -0
- package/dist/main/settings.d.ts.map +1 -0
- package/dist/main/settings.js +88 -0
- package/dist/main/settings.js.map +1 -0
- package/dist/main/sockets.d.ts +10 -0
- package/dist/main/sockets.d.ts.map +1 -0
- package/dist/main/sockets.js +71 -0
- package/dist/main/sockets.js.map +1 -0
- package/dist/preload/preload.cjs +91 -0
- package/dist/preload/preload.cjs.map +1 -0
- package/dist/preload/preload.d.cts +2 -0
- package/dist/preload/preload.d.cts.map +1 -0
- package/dist/preload/prompt.cjs +12 -0
- package/dist/preload/prompt.cjs.map +1 -0
- package/dist/preload/prompt.d.cts +2 -0
- package/dist/preload/prompt.d.cts.map +1 -0
- package/dist/renderer/air.d.ts +14 -0
- package/dist/renderer/air.d.ts.map +1 -0
- package/dist/renderer/air.js +83 -0
- package/dist/renderer/air.js.map +1 -0
- package/dist/renderer/files.d.ts +4 -0
- package/dist/renderer/files.d.ts.map +1 -0
- package/dist/renderer/files.js +58 -0
- package/dist/renderer/files.js.map +1 -0
- package/dist/renderer/index.d.ts +7 -0
- package/dist/renderer/index.d.ts.map +1 -0
- package/dist/renderer/index.js +126 -0
- package/dist/renderer/index.js.map +1 -0
- package/dist/renderer/network.d.ts +6 -0
- package/dist/renderer/network.d.ts.map +1 -0
- package/dist/renderer/network.js +39 -0
- package/dist/renderer/network.js.map +1 -0
- package/dist/renderer/open-url.d.ts +10 -0
- package/dist/renderer/open-url.d.ts.map +1 -0
- package/dist/renderer/open-url.js +38 -0
- package/dist/renderer/open-url.js.map +1 -0
- package/dist/renderer/sockets.d.ts +4 -0
- package/dist/renderer/sockets.d.ts.map +1 -0
- package/dist/renderer/sockets.js +36 -0
- package/dist/renderer/sockets.js.map +1 -0
- package/dist/renderer/stage3d.d.ts +3 -0
- package/dist/renderer/stage3d.d.ts.map +1 -0
- package/dist/renderer/stage3d.js +26 -0
- package/dist/renderer/stage3d.js.map +1 -0
- package/dist/shared/api.d.ts +205 -0
- package/dist/shared/api.d.ts.map +1 -0
- package/dist/shared/api.js +5 -0
- package/dist/shared/api.js.map +1 -0
- package/package.json +46 -3
- package/src/launch/electron.ts +44 -0
- package/src/launch/launch.ts +72 -0
- package/src/launch/tsconfig.json +14 -0
- package/src/main/addresses.ts +184 -0
- package/src/main/air.ts +275 -0
- package/src/main/connections.ts +125 -0
- package/src/main/decompress.ts +219 -0
- package/src/main/files.ts +147 -0
- package/src/main/main.ts +1146 -0
- package/src/main/network.ts +1205 -0
- package/src/main/policy.ts +630 -0
- package/src/main/protocol.ts +237 -0
- package/src/main/sandbox.ts +213 -0
- package/src/main/settings.ts +117 -0
- package/src/main/sockets.ts +81 -0
- package/src/main/tsconfig.json +15 -0
- package/src/preload/preload.cts +110 -0
- package/src/preload/prompt.cts +11 -0
- package/src/preload/tsconfig.json +12 -0
- package/src/renderer/air.ts +100 -0
- package/src/renderer/files.ts +65 -0
- package/src/renderer/index.ts +145 -0
- package/src/renderer/network.ts +56 -0
- package/src/renderer/open-url.ts +50 -0
- package/src/renderer/sockets.ts +42 -0
- package/src/renderer/stage3d.ts +27 -0
- package/src/renderer/tsconfig.json +17 -0
- package/src/shared/api.ts +204 -0
- package/src/shared/tsconfig.json +11 -0
- package/static/icon.png +0 -0
- package/static/index.html +22 -0
- package/static/open-url.html +21 -0
- package/static/style.css +96 -0
|
@@ -0,0 +1,1205 @@
|
|
|
1
|
+
// The network a SWF reaches through the desktop app: every http and https
|
|
2
|
+
// request it makes, sent by the main process rather than the page, and
|
|
3
|
+
// judged as Flash Player judged it, by its sandbox and the policy files
|
|
4
|
+
// of the sites it reaches, not by CORS; and the socket policies a remote
|
|
5
|
+
// SWF's sockets need. So a SWF reaches http: and servers that never heard
|
|
6
|
+
// of CORS, as it did in Flash, and nothing it should not.
|
|
7
|
+
//
|
|
8
|
+
// - A remote SWF's own origin is open to it. Another origin's data it
|
|
9
|
+
// reads as that origin's policy files grant its domain; content (a
|
|
10
|
+
// Loader's, a Sound's) and what sendToURL sends go anywhere. A
|
|
11
|
+
// local-with-networking SWF has no domain: only a policy granting "*"
|
|
12
|
+
// lets it read data.
|
|
13
|
+
// - Nothing is sent with the user's credentials: no cookies, no
|
|
14
|
+
// authentication, no client certificate. So all a SWF gains by asking
|
|
15
|
+
// from here rather than its own server is this machine's place on the
|
|
16
|
+
// network, and that is the one thing guarded beyond Flash's rules:
|
|
17
|
+
// a request to an address more private than the SWF's own (addresses.ts)
|
|
18
|
+
// needs a policy from that address granting it, whatever its purpose,
|
|
19
|
+
// same origin or not. Each request resolves its host once and connects
|
|
20
|
+
// to that address, its policy file fetched from the same one, so DNS
|
|
21
|
+
// that changes its answer (rebinding) changes nothing.
|
|
22
|
+
// - Requests are bounded: methods, headers, sizes, redirects, time, and
|
|
23
|
+
// how many run at once; and each movie's are aborted as the next opens.
|
|
24
|
+
//
|
|
25
|
+
// Without Electron, so that node tests it (tests/desktop/network.test.ts).
|
|
26
|
+
import { lookup } from "node:dns/promises";
|
|
27
|
+
import { type ClientRequest, request as httpRequest, type IncomingMessage } from "node:http";
|
|
28
|
+
import { request as httpsRequest } from "node:https";
|
|
29
|
+
import { connect, isIP, isIPv4 } from "node:net";
|
|
30
|
+
import * as tls from "node:tls";
|
|
31
|
+
import { type AddressClass, addressClass, badPort, RANK } from "./addresses.ts";
|
|
32
|
+
import { DECODED, Decompressor } from "./decompress.ts";
|
|
33
|
+
import {
|
|
34
|
+
allowsAccess,
|
|
35
|
+
allowsHeaders,
|
|
36
|
+
allowsSocket,
|
|
37
|
+
forbiddenHeader,
|
|
38
|
+
parsePolicy,
|
|
39
|
+
policyContentType,
|
|
40
|
+
type Requester,
|
|
41
|
+
type SocketPolicy,
|
|
42
|
+
socketMetaPolicy,
|
|
43
|
+
type UrlPolicy,
|
|
44
|
+
urlMetaPolicy,
|
|
45
|
+
usablePolicies,
|
|
46
|
+
} from "./policy.ts";
|
|
47
|
+
|
|
48
|
+
export type Purpose = "movie" | "content" | "data" | "send";
|
|
49
|
+
|
|
50
|
+
export interface NetRequest {
|
|
51
|
+
url: string;
|
|
52
|
+
method: string;
|
|
53
|
+
headers: [string, string][];
|
|
54
|
+
body: Uint8Array | null;
|
|
55
|
+
purpose: Purpose;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export interface NetResponse {
|
|
59
|
+
bytes: Uint8Array | null;
|
|
60
|
+
status: number;
|
|
61
|
+
headers: [string, string][];
|
|
62
|
+
/** Where redirects took it, where that differs from the URL asked for. */
|
|
63
|
+
url?: string;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** A request refused, with why, for the terminal; the SWF sees a failed load. */
|
|
67
|
+
export class Refused extends Error {}
|
|
68
|
+
|
|
69
|
+
export interface Limits {
|
|
70
|
+
/** The longest URL. */
|
|
71
|
+
url: number;
|
|
72
|
+
/** The most headers a SWF sends, and their names' and values' length all told. */
|
|
73
|
+
headers: number;
|
|
74
|
+
headerBytes: number;
|
|
75
|
+
/** The largest body a SWF sends. */
|
|
76
|
+
body: number;
|
|
77
|
+
/** The largest response read: a SWF, a sound, a level's data. */
|
|
78
|
+
response: number;
|
|
79
|
+
/** The largest policy file, URL or socket. */
|
|
80
|
+
policy: number;
|
|
81
|
+
redirects: number;
|
|
82
|
+
/** From the request to its response's headers, and from one chunk of its body to the next. */
|
|
83
|
+
timeout: number;
|
|
84
|
+
/**
|
|
85
|
+
* How long a request may take all told, however its bytes trickle in, so
|
|
86
|
+
* that a server sending a byte a second holds no turn for long; a policy
|
|
87
|
+
* file's whole fetch takes `policyTimeout` at most, as a movie keeps it.
|
|
88
|
+
*/
|
|
89
|
+
deadline: number;
|
|
90
|
+
/** How long a policy file may take, and port 843's socket policy, which most servers lack. */
|
|
91
|
+
policyTimeout: number;
|
|
92
|
+
masterSocketTimeout: number;
|
|
93
|
+
/**
|
|
94
|
+
* The bytes a movie's responses may hold at once, all told: past it a
|
|
95
|
+
* response is refused, so many large ones together cannot take the
|
|
96
|
+
* main process's memory, as each alone may come near `response`.
|
|
97
|
+
*/
|
|
98
|
+
buffered: number;
|
|
99
|
+
/** Requests under way at once; the rest wait their turn, up to `queue`. */
|
|
100
|
+
concurrent: number;
|
|
101
|
+
queue: number;
|
|
102
|
+
/** Policy files a movie may name with Security.loadPolicyFile. */
|
|
103
|
+
policyFiles: number;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const LIMITS: Limits = {
|
|
107
|
+
url: 64 * 1024,
|
|
108
|
+
headers: 32,
|
|
109
|
+
headerBytes: 8 * 1024,
|
|
110
|
+
body: 32 * 1024 * 1024,
|
|
111
|
+
response: 128 * 1024 * 1024,
|
|
112
|
+
policy: 1024 * 1024,
|
|
113
|
+
redirects: 10,
|
|
114
|
+
timeout: 30_000,
|
|
115
|
+
deadline: 300_000,
|
|
116
|
+
policyTimeout: 10_000,
|
|
117
|
+
masterSocketTimeout: 3_000,
|
|
118
|
+
buffered: 256 * 1024 * 1024,
|
|
119
|
+
concurrent: 16,
|
|
120
|
+
queue: 1024,
|
|
121
|
+
policyFiles: 64,
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* The movie whose requests are judged: a remote SWF by its URL, a local
|
|
126
|
+
* one with networking, or an AIR application, whose sandbox reaches the
|
|
127
|
+
* network unjudged, as AIR's application sandbox does: any address, no
|
|
128
|
+
* policy file.
|
|
129
|
+
*/
|
|
130
|
+
export type Movie = { kind: "remote"; url: string } | { kind: "local" } | { kind: "application" };
|
|
131
|
+
|
|
132
|
+
export interface NetworkOptions {
|
|
133
|
+
/** A host name's addresses, in order of preference: the system's resolver by default. */
|
|
134
|
+
resolve?: (host: string) => Promise<string[]>;
|
|
135
|
+
/** Where an address is: addresses.ts's by default; null for one never reached. */
|
|
136
|
+
classify?: (address: string) => AddressClass | null;
|
|
137
|
+
/** The certificate authorities HTTPS trusts, in place of Node's and the system's. */
|
|
138
|
+
ca?: string | string[];
|
|
139
|
+
/**
|
|
140
|
+
* Hosts whose HTTPS certificate is not verified: a server with one it
|
|
141
|
+
* signed itself, as a developer's own has (--ignore-certificate-errors).
|
|
142
|
+
* None by default.
|
|
143
|
+
*/
|
|
144
|
+
unverified?: (host: string) => boolean;
|
|
145
|
+
limits?: Partial<Limits>;
|
|
146
|
+
/** Where a socket server keeps its master policy: 843. */
|
|
147
|
+
socketPolicyPort?: number;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** What Flash Player 32 reported, sent as it sent it. */
|
|
151
|
+
const FLASH_VERSION = "32,0,0,465";
|
|
152
|
+
const USER_AGENT = "Mozilla/5.0 (compatible; swf2es) Shockwave Flash";
|
|
153
|
+
|
|
154
|
+
/** A remote movie once its SWF has come, where from. */
|
|
155
|
+
interface Origin {
|
|
156
|
+
/** Its URL after redirects: its loaderInfo.url, and its Referer. */
|
|
157
|
+
url: URL;
|
|
158
|
+
requester: Requester;
|
|
159
|
+
rank: number;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
interface Pinned {
|
|
163
|
+
address: string;
|
|
164
|
+
rank: number;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** What one movie's requests share, and lose as the next opens. */
|
|
168
|
+
/** What one request's responses hold of its movie's `buffered`, given back as it is answered. */
|
|
169
|
+
interface Budget {
|
|
170
|
+
session: Session;
|
|
171
|
+
taken: number;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
interface Session {
|
|
175
|
+
movie: Movie;
|
|
176
|
+
origin: Origin | null;
|
|
177
|
+
abort: AbortController;
|
|
178
|
+
/** URL and socket policies, by where they came from and the address they came from. */
|
|
179
|
+
policies: Map<string, Promise<UrlPolicy | null>>;
|
|
180
|
+
socketPolicies: Map<string, Promise<SocketPolicy | null>>;
|
|
181
|
+
/** What Security.loadPolicyFile named: http(s) URLs, and xmlsocket "host:port"s. */
|
|
182
|
+
urlPolicyFiles: URL[];
|
|
183
|
+
socketPolicyFiles: { host: string; port: number }[];
|
|
184
|
+
/** The bytes its responses hold now, of `buffered`. */
|
|
185
|
+
buffered: number;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const REDIRECTS = new Set([301, 302, 303, 307, 308]);
|
|
189
|
+
const TOKEN = /^[!#$%&'*+.^_`|~0-9A-Za-z-]+$/;
|
|
190
|
+
|
|
191
|
+
/** The system's trusted roots beside Node's, where this Node can tell them. */
|
|
192
|
+
function defaultCa(): string[] | undefined {
|
|
193
|
+
const get = (tls as { getCACertificates?: (type: string) => string[] }).getCACertificates;
|
|
194
|
+
if (typeof get !== "function") {
|
|
195
|
+
return undefined;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
try {
|
|
199
|
+
return [...new Set([...get("default"), ...get("system")])];
|
|
200
|
+
} catch {
|
|
201
|
+
return undefined;
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* The hosts --ignore-certificate-errors names, of the app's arguments up to
|
|
207
|
+
* an AIR application's own after "--": null without the switch; with it
|
|
208
|
+
* alone, every host; with `=a.test,b.test`, those, whatever their case.
|
|
209
|
+
* A host is a name or an address, with no port: every port of it.
|
|
210
|
+
*/
|
|
211
|
+
export function unverifiedHosts(argv: string[]): { all: boolean; hosts: string[] } | null {
|
|
212
|
+
const end = argv.indexOf("--");
|
|
213
|
+
const own = end < 0 ? argv : argv.slice(0, end);
|
|
214
|
+
let named = false;
|
|
215
|
+
let all = false;
|
|
216
|
+
const hosts: string[] = [];
|
|
217
|
+
for (const arg of own) {
|
|
218
|
+
if (arg === "--ignore-certificate-errors") {
|
|
219
|
+
named = true;
|
|
220
|
+
all = true;
|
|
221
|
+
} else if (arg.startsWith("--ignore-certificate-errors=")) {
|
|
222
|
+
// The switch with nothing named ignores nothing, rather than everything by a slip.
|
|
223
|
+
named = true;
|
|
224
|
+
for (const host of arg.slice(arg.indexOf("=") + 1).split(",")) {
|
|
225
|
+
if (host.trim() !== "") {
|
|
226
|
+
hosts.push(host.trim().toLowerCase());
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
return named ? { all, hosts } : null;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
export class Network {
|
|
236
|
+
private readonly limits: Limits;
|
|
237
|
+
private readonly resolveHost: (host: string) => Promise<string[]>;
|
|
238
|
+
private readonly classify: (address: string) => AddressClass | null;
|
|
239
|
+
private readonly ca: string | string[] | undefined;
|
|
240
|
+
private readonly unverified: (host: string) => boolean;
|
|
241
|
+
private readonly socketPolicyPort: number;
|
|
242
|
+
private session: Session | null = null;
|
|
243
|
+
private running = 0;
|
|
244
|
+
private readonly waiting: (() => void)[] = [];
|
|
245
|
+
|
|
246
|
+
constructor(options: NetworkOptions = {}) {
|
|
247
|
+
this.limits = { ...LIMITS, ...options.limits };
|
|
248
|
+
this.resolveHost =
|
|
249
|
+
options.resolve ??
|
|
250
|
+
(async (host) =>
|
|
251
|
+
(await lookup(host, { all: true, verbatim: true })).map(({ address }) => address));
|
|
252
|
+
this.classify = options.classify ?? addressClass;
|
|
253
|
+
this.ca = options.ca ?? defaultCa();
|
|
254
|
+
this.unverified = options.unverified ?? (() => false);
|
|
255
|
+
this.socketPolicyPort = options.socketPolicyPort ?? 843;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* Judge requests for `movie` from now on, or for none: what the last
|
|
260
|
+
* movie had under way is aborted, and its policy files forgotten.
|
|
261
|
+
*/
|
|
262
|
+
play(movie: Movie | null): void {
|
|
263
|
+
this.session?.abort.abort();
|
|
264
|
+
this.session = movie
|
|
265
|
+
? {
|
|
266
|
+
movie,
|
|
267
|
+
origin: null,
|
|
268
|
+
abort: new AbortController(),
|
|
269
|
+
policies: new Map(),
|
|
270
|
+
socketPolicies: new Map(),
|
|
271
|
+
urlPolicyFiles: [],
|
|
272
|
+
socketPolicyFiles: [],
|
|
273
|
+
buffered: 0,
|
|
274
|
+
}
|
|
275
|
+
: null;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/** The movie's URL after its redirects, once it has come; null before, and for a local movie. */
|
|
279
|
+
get origin(): string | null {
|
|
280
|
+
return this.session?.origin?.url.href ?? null;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* A policy file the movie named with Security.loadPolicyFile: an http(s)
|
|
285
|
+
* URL, or "xmlsocket://host:port" for a socket policy. It is fetched
|
|
286
|
+
* only when a request needs it.
|
|
287
|
+
*/
|
|
288
|
+
addPolicyFile(text: unknown): void {
|
|
289
|
+
const session = this.session;
|
|
290
|
+
if (
|
|
291
|
+
!session ||
|
|
292
|
+
typeof text !== "string" ||
|
|
293
|
+
text.length > this.limits.url ||
|
|
294
|
+
session.urlPolicyFiles.length + session.socketPolicyFiles.length >= this.limits.policyFiles
|
|
295
|
+
) {
|
|
296
|
+
return;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
let url: URL;
|
|
300
|
+
try {
|
|
301
|
+
url = new URL(text);
|
|
302
|
+
} catch {
|
|
303
|
+
return;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
if (url.protocol === "xmlsocket:") {
|
|
307
|
+
const port = Number(url.port);
|
|
308
|
+
if (url.hostname && Number.isInteger(port) && port > 0) {
|
|
309
|
+
// Lower case by hand: the URL standard keeps an unknown scheme's host as written.
|
|
310
|
+
session.socketPolicyFiles.push({ host: bare(url.hostname).toLowerCase(), port });
|
|
311
|
+
}
|
|
312
|
+
} else if (
|
|
313
|
+
(url.protocol === "http:" || url.protocol === "https:") &&
|
|
314
|
+
!url.username &&
|
|
315
|
+
!url.password &&
|
|
316
|
+
!session.urlPolicyFiles.some((u) => u.href === url.href)
|
|
317
|
+
) {
|
|
318
|
+
url.hash = "";
|
|
319
|
+
session.urlPolicyFiles.push(url);
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/**
|
|
324
|
+
* Send what the page asks, `request` as it came over IPC, checked here,
|
|
325
|
+
* if the movie may; `signal` aborts it. Refused, it throws a Refused
|
|
326
|
+
* saying why. `deliver` is handed the response while its bytes still
|
|
327
|
+
* count against the movie's budget, for a caller that copies them away
|
|
328
|
+
* (over IPC) before they are given back.
|
|
329
|
+
*/
|
|
330
|
+
async fetch(
|
|
331
|
+
request: unknown,
|
|
332
|
+
signal: AbortSignal,
|
|
333
|
+
deliver?: (response: NetResponse) => void,
|
|
334
|
+
): Promise<NetResponse> {
|
|
335
|
+
const session = this.session;
|
|
336
|
+
if (!session) {
|
|
337
|
+
throw new Refused("no SWF plays");
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
const checked = this.check(request);
|
|
341
|
+
const aborted = AbortSignal.any([signal, session.abort.signal]);
|
|
342
|
+
await this.turn(aborted);
|
|
343
|
+
// All told, from its turn: its redirects and the policy files it waits on included.
|
|
344
|
+
const deadline = AbortSignal.timeout(this.limits.deadline);
|
|
345
|
+
const bounded = AbortSignal.any([aborted, deadline]);
|
|
346
|
+
const budget: Budget = { session, taken: 0 };
|
|
347
|
+
try {
|
|
348
|
+
const response =
|
|
349
|
+
checked.purpose === "movie"
|
|
350
|
+
? await this.fetchMovie(session, checked, bounded, budget)
|
|
351
|
+
: await this.fetchFor(session, checked, bounded, budget);
|
|
352
|
+
deliver?.(response);
|
|
353
|
+
return response;
|
|
354
|
+
} catch (error) {
|
|
355
|
+
throw deadline.aborted && !aborted.aborted ? new Refused("it took too long") : error;
|
|
356
|
+
} finally {
|
|
357
|
+
session.buffered -= budget.taken;
|
|
358
|
+
this.done();
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
/**
|
|
363
|
+
* The address the movie's socket to `host`:`port` connects to, once its
|
|
364
|
+
* socket policy allows it, as Flash Player 9.0.124 and later asked of
|
|
365
|
+
* every socket a remote SWF opened, its own server's included: the
|
|
366
|
+
* master policy on port 843, then, as its meta-policy allows, those
|
|
367
|
+
* Security.loadPolicyFile named and the one on the port itself. Each
|
|
368
|
+
* fetched from the address the socket then connects to.
|
|
369
|
+
*/
|
|
370
|
+
async socketAddress(host: string, port: number): Promise<string> {
|
|
371
|
+
const session = this.session;
|
|
372
|
+
const origin = session?.origin;
|
|
373
|
+
if (!session || !origin) {
|
|
374
|
+
throw new Refused("the SWF has not loaded");
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
// All told, its DNS and the policies of every port it tries included,
|
|
378
|
+
// each of which it stops waiting on when time is up.
|
|
379
|
+
const deadline = AbortSignal.timeout(this.limits.deadline);
|
|
380
|
+
try {
|
|
381
|
+
return await this.socketPolicyAddress(session, origin, host, port, deadline);
|
|
382
|
+
} catch (error) {
|
|
383
|
+
throw deadline.aborted ? new Refused("it took too long") : error;
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
private async socketPolicyAddress(
|
|
388
|
+
session: Session,
|
|
389
|
+
origin: Origin,
|
|
390
|
+
host: string,
|
|
391
|
+
port: number,
|
|
392
|
+
deadline: AbortSignal,
|
|
393
|
+
): Promise<string> {
|
|
394
|
+
const target = await this.pin(host, deadline);
|
|
395
|
+
const policy = (at: number, timeout: number) =>
|
|
396
|
+
abortable(
|
|
397
|
+
cached(session.socketPolicies, `${target.address}|${at}`, () =>
|
|
398
|
+
this.socketPolicy(target.address, at, timeout, session.abort.signal),
|
|
399
|
+
),
|
|
400
|
+
deadline,
|
|
401
|
+
);
|
|
402
|
+
const master = await policy(this.socketPolicyPort, this.limits.masterSocketTimeout);
|
|
403
|
+
if (allowsSocket(master, [], origin.requester, port)) {
|
|
404
|
+
return target.address;
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
if (socketMetaPolicy(master) === "all") {
|
|
408
|
+
const ports = [
|
|
409
|
+
...session.socketPolicyFiles
|
|
410
|
+
.filter((f) => f.host === bare(host).toLowerCase())
|
|
411
|
+
.map((f) => f.port),
|
|
412
|
+
port,
|
|
413
|
+
];
|
|
414
|
+
for (const at of new Set(ports)) {
|
|
415
|
+
if (at === this.socketPolicyPort) {
|
|
416
|
+
continue;
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
const other = await policy(at, this.limits.policyTimeout);
|
|
420
|
+
if (other && allowsSocket(master, [other], origin.requester, port)) {
|
|
421
|
+
return target.address;
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
throw new Refused(`no socket policy on ${host} grants ${describe(origin.requester)}`);
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
/** `request` checked and made plain: only what Flash Player let a SWF send. */
|
|
430
|
+
private check(request: unknown): NetRequest & { target: URL } {
|
|
431
|
+
const r = request as Partial<NetRequest> | null;
|
|
432
|
+
if (typeof r !== "object" || r === null || typeof r.url !== "string") {
|
|
433
|
+
throw new Refused("not a request");
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
if (r.url.length > this.limits.url) {
|
|
437
|
+
throw new Refused("its URL is too long");
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
let target: URL;
|
|
441
|
+
try {
|
|
442
|
+
target = new URL(r.url);
|
|
443
|
+
} catch {
|
|
444
|
+
throw new Refused("not a URL");
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
if (target.protocol !== "http:" && target.protocol !== "https:") {
|
|
448
|
+
throw new Refused(`no ${target.protocol} URL is fetched for a SWF`);
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
if (target.username || target.password) {
|
|
452
|
+
throw new Refused("a URL with credentials");
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
target.hash = "";
|
|
456
|
+
const purpose = r.purpose;
|
|
457
|
+
if (purpose !== "movie" && purpose !== "content" && purpose !== "data" && purpose !== "send") {
|
|
458
|
+
throw new Refused("no purpose");
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
// Flash Player outside AIR sent GET and POST only.
|
|
462
|
+
const method = typeof r.method === "string" ? r.method.toUpperCase() : "";
|
|
463
|
+
if (method !== "GET" && method !== "POST") {
|
|
464
|
+
throw new Refused(`no ${String(r.method)} request is sent for a SWF`);
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
const body = method === "POST" && r.body instanceof Uint8Array ? r.body : null;
|
|
468
|
+
if (body && body.length > this.limits.body) {
|
|
469
|
+
throw new Refused("its body is too large");
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
const headers: [string, string][] = [];
|
|
473
|
+
let bytes = 0;
|
|
474
|
+
if (!Array.isArray(r.headers) || r.headers.length > this.limits.headers) {
|
|
475
|
+
throw new Refused("too many headers");
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
for (const header of r.headers as unknown[]) {
|
|
479
|
+
if (!Array.isArray(header) || header.length !== 2) {
|
|
480
|
+
throw new Refused("not a header");
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
const [name, value] = header as unknown[];
|
|
484
|
+
if (typeof name !== "string" || typeof value !== "string" || !TOKEN.test(name)) {
|
|
485
|
+
throw new Refused("not a header");
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
// No control characters, line breaks among them, which would start a header of
|
|
489
|
+
// their own, and nothing HTTP's Latin-1 does not hold: what Node would throw for.
|
|
490
|
+
if (/[^\t\x20-\x7e\x80-\xff]/.test(value)) {
|
|
491
|
+
throw new Refused(`the header ${name} has a character no header may hold`);
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
if (forbiddenHeader(name)) {
|
|
495
|
+
throw new Refused(`a SWF may not send the header ${name}`);
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
bytes += name.length + value.length;
|
|
499
|
+
headers.push([name, value]);
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
if (bytes > this.limits.headerBytes) {
|
|
503
|
+
throw new Refused("its headers are too long");
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
return { url: target.href, target, method, headers, body, purpose };
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
/** Wait for a turn among the requests under way: at most `concurrent` at once. */
|
|
510
|
+
private async turn(signal: AbortSignal): Promise<void> {
|
|
511
|
+
if (this.running < this.limits.concurrent) {
|
|
512
|
+
this.running++;
|
|
513
|
+
return;
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
if (this.waiting.length >= this.limits.queue) {
|
|
517
|
+
throw new Refused("too many requests at once");
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
await new Promise<void>((go, fail) => {
|
|
521
|
+
const start = () => {
|
|
522
|
+
signal.removeEventListener("abort", stop);
|
|
523
|
+
go();
|
|
524
|
+
};
|
|
525
|
+
const stop = () => {
|
|
526
|
+
const at = this.waiting.indexOf(start);
|
|
527
|
+
if (at >= 0) {
|
|
528
|
+
this.waiting.splice(at, 1);
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
fail(new Refused("aborted"));
|
|
532
|
+
};
|
|
533
|
+
if (signal.aborted) {
|
|
534
|
+
stop();
|
|
535
|
+
return;
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
signal.addEventListener("abort", stop, { once: true });
|
|
539
|
+
this.waiting.push(start);
|
|
540
|
+
});
|
|
541
|
+
this.running++;
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
private done(): void {
|
|
545
|
+
this.running--;
|
|
546
|
+
this.waiting.shift()?.();
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
/** `host`'s address, its resolver's first, and how private; refused where none is reached. */
|
|
550
|
+
private async pin(host: string, signal?: AbortSignal): Promise<Pinned> {
|
|
551
|
+
const name = bare(host);
|
|
552
|
+
let address: string;
|
|
553
|
+
if (isIP(name)) {
|
|
554
|
+
address = name;
|
|
555
|
+
} else {
|
|
556
|
+
let addresses: string[];
|
|
557
|
+
try {
|
|
558
|
+
addresses = await (signal
|
|
559
|
+
? abortable(this.resolveHost(name), signal)
|
|
560
|
+
: this.resolveHost(name));
|
|
561
|
+
} catch {
|
|
562
|
+
throw new Refused(signal?.aborted ? "aborted" : `${name} has no address`);
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
if (addresses.length === 0) {
|
|
566
|
+
throw new Refused(`${name} has no address`);
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
address = addresses[0];
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
const place = this.classify(address);
|
|
573
|
+
if (place === null) {
|
|
574
|
+
throw new Refused(`${name} is at ${address}, which no request reaches`);
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
return { address, rank: RANK[place] };
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
/** The movie's SWF, from wherever the user asked; no redirect takes it somewhere more private. */
|
|
581
|
+
private async fetchMovie(
|
|
582
|
+
session: Session,
|
|
583
|
+
request: NetRequest & { target: URL },
|
|
584
|
+
signal: AbortSignal,
|
|
585
|
+
budget: Budget,
|
|
586
|
+
): Promise<NetResponse> {
|
|
587
|
+
const movie = session.movie;
|
|
588
|
+
if (movie.kind !== "remote" || request.url !== new URL(movie.url).href) {
|
|
589
|
+
throw new Refused("not the SWF that was opened");
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
let url = request.target;
|
|
593
|
+
let first: number | null = null;
|
|
594
|
+
for (let hop = 0; hop <= this.limits.redirects; hop++) {
|
|
595
|
+
const target = await this.pin(url.hostname, signal);
|
|
596
|
+
checkPort(url);
|
|
597
|
+
first ??= target.rank;
|
|
598
|
+
if (target.rank > first) {
|
|
599
|
+
throw new Refused(`a redirect to ${url.href}, somewhere more private`);
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
const response = await this.send(url, target.address, "GET", [], null, signal, {
|
|
603
|
+
read: true,
|
|
604
|
+
max: this.limits.response,
|
|
605
|
+
referer: null,
|
|
606
|
+
budget,
|
|
607
|
+
});
|
|
608
|
+
const next = redirectOf(url, response);
|
|
609
|
+
if (next) {
|
|
610
|
+
url = next;
|
|
611
|
+
continue;
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
if (response.bytes) {
|
|
615
|
+
session.origin = {
|
|
616
|
+
url,
|
|
617
|
+
requester: { host: bare(url.hostname).toLowerCase(), scheme: url.protocol.slice(0, -1) },
|
|
618
|
+
rank: target.rank,
|
|
619
|
+
};
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
return { ...response, ...(url.href === request.url ? {} : { url: url.href }) };
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
throw new Refused("too many redirects");
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
/** A request of the SWF's, each hop of its redirects judged as if asked for itself. */
|
|
629
|
+
private async fetchFor(
|
|
630
|
+
session: Session,
|
|
631
|
+
request: NetRequest & { target: URL },
|
|
632
|
+
signal: AbortSignal,
|
|
633
|
+
budget: Budget,
|
|
634
|
+
): Promise<NetResponse> {
|
|
635
|
+
let requester: Requester;
|
|
636
|
+
let rank: number;
|
|
637
|
+
let origin: string | null;
|
|
638
|
+
const application = session.movie.kind === "application";
|
|
639
|
+
if (application) {
|
|
640
|
+
requester = { host: null, scheme: null };
|
|
641
|
+
// Nothing is more private than an application: it reaches its own machine too.
|
|
642
|
+
rank = Number.POSITIVE_INFINITY;
|
|
643
|
+
origin = null;
|
|
644
|
+
} else if (session.movie.kind === "local") {
|
|
645
|
+
requester = { host: null, scheme: null };
|
|
646
|
+
// A local SWF stands for nothing on the network: it reaches no private address of itself.
|
|
647
|
+
rank = RANK.public;
|
|
648
|
+
origin = null;
|
|
649
|
+
} else if (session.origin) {
|
|
650
|
+
({ requester, rank } = session.origin);
|
|
651
|
+
origin = session.origin.url.origin;
|
|
652
|
+
} else {
|
|
653
|
+
throw new Refused("the SWF has not loaded");
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
let { method, headers, body } = request;
|
|
657
|
+
let url = request.target;
|
|
658
|
+
for (let hop = 0; hop <= this.limits.redirects; hop++) {
|
|
659
|
+
checkPort(url);
|
|
660
|
+
const target = await this.pin(url.hostname, signal);
|
|
661
|
+
const same = origin === url.origin;
|
|
662
|
+
const morePrivate = target.rank > rank;
|
|
663
|
+
const custom = headers.map(([n]) => n).filter((n) => n.toLowerCase() !== "content-type");
|
|
664
|
+
const needsAccess = !application && (morePrivate || (!same && request.purpose === "data"));
|
|
665
|
+
const needsHeaders = !application && !same && custom.length > 0;
|
|
666
|
+
if (needsAccess || needsHeaders) {
|
|
667
|
+
const policies = await abortable(this.policiesFor(session, url, target.address), signal);
|
|
668
|
+
if (needsAccess && !allowsAccess(policies, requester, url)) {
|
|
669
|
+
throw new Refused(
|
|
670
|
+
morePrivate
|
|
671
|
+
? `${url.host} is at ${target.address}, more private than the SWF, ` +
|
|
672
|
+
`and no policy file there grants ${describe(requester)}`
|
|
673
|
+
: `no policy file on ${url.origin} grants ${describe(requester)}`,
|
|
674
|
+
);
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
if (needsHeaders && !allowsHeaders(policies, requester, url, custom)) {
|
|
678
|
+
throw new Refused(`no policy file on ${url.origin} lets it send ${custom.join(", ")}`);
|
|
679
|
+
}
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
const response = await this.send(url, target.address, method, headers, body, signal, {
|
|
683
|
+
// sendToURL's answer is no one's to read.
|
|
684
|
+
read: request.purpose !== "send",
|
|
685
|
+
max: this.limits.response,
|
|
686
|
+
budget,
|
|
687
|
+
referer: refererFor(session.origin?.url ?? null, url),
|
|
688
|
+
});
|
|
689
|
+
const next = redirectOf(url, response);
|
|
690
|
+
if (!next) {
|
|
691
|
+
return { ...response, ...(url.href === request.url ? {} : { url: url.href }) };
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
// As browsers do: a 303, and a 301 or 302 after a POST, go on as a GET without the body.
|
|
695
|
+
if (response.status === 303 || (method === "POST" && response.status <= 302)) {
|
|
696
|
+
method = "GET";
|
|
697
|
+
body = null;
|
|
698
|
+
headers = [];
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
url = next;
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
throw new Refused("too many redirects");
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
/**
|
|
708
|
+
* The policy files of `url`'s site that count, fetched from `address`:
|
|
709
|
+
* its master policy file, and those the movie named under it as the
|
|
710
|
+
* master's meta-policy allows. Each is fetched once a movie and address,
|
|
711
|
+
* and aborted only with the movie, not with the request that first asked:
|
|
712
|
+
* the next finds it as it came.
|
|
713
|
+
*/
|
|
714
|
+
private async policiesFor(session: Session, url: URL, address: string): Promise<UrlPolicy[]> {
|
|
715
|
+
const get = (policyUrl: URL) =>
|
|
716
|
+
cached(session.policies, `${policyUrl.href}|${address}`, () =>
|
|
717
|
+
this.urlPolicy(policyUrl, address, session.abort.signal),
|
|
718
|
+
);
|
|
719
|
+
const master = await get(new URL("/crossdomain.xml", url.origin));
|
|
720
|
+
const meta = urlMetaPolicy(master);
|
|
721
|
+
const others: UrlPolicy[] = [];
|
|
722
|
+
if (meta === "all" || meta === "by-content-type") {
|
|
723
|
+
for (const named of session.urlPolicyFiles) {
|
|
724
|
+
const directory = named.pathname.slice(0, named.pathname.lastIndexOf("/") + 1);
|
|
725
|
+
if (
|
|
726
|
+
named.origin === url.origin &&
|
|
727
|
+
named.pathname !== "/crossdomain.xml" &&
|
|
728
|
+
url.pathname.startsWith(directory)
|
|
729
|
+
) {
|
|
730
|
+
const other = await get(named);
|
|
731
|
+
if (other) {
|
|
732
|
+
others.push(other);
|
|
733
|
+
}
|
|
734
|
+
}
|
|
735
|
+
}
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
return usablePolicies(master, others);
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
/** A URL policy file, or null: missing, redirected, mistyped, refusing to be one, or none. */
|
|
742
|
+
private async urlPolicy(
|
|
743
|
+
url: URL,
|
|
744
|
+
address: string,
|
|
745
|
+
signal: AbortSignal,
|
|
746
|
+
): Promise<UrlPolicy | null> {
|
|
747
|
+
let response: NetResponse;
|
|
748
|
+
try {
|
|
749
|
+
response = await this.send(url, address, "GET", [], null, signal, {
|
|
750
|
+
read: true,
|
|
751
|
+
max: this.limits.policy,
|
|
752
|
+
referer: null,
|
|
753
|
+
timeout: this.limits.policyTimeout,
|
|
754
|
+
deadline: this.limits.policyTimeout,
|
|
755
|
+
});
|
|
756
|
+
} catch {
|
|
757
|
+
return null;
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
// Redirects are not followed: the specification ignores one to another
|
|
761
|
+
// domain, and one within a site would move the file's scope.
|
|
762
|
+
if (response.status !== 200 || !response.bytes) {
|
|
763
|
+
return null;
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
const header = (name: string) =>
|
|
767
|
+
response.headers
|
|
768
|
+
.filter(([n]) => n.toLowerCase() === name)
|
|
769
|
+
.flatMap(([, v]) => v.split(","))
|
|
770
|
+
.map((v) => v.trim().toLowerCase())
|
|
771
|
+
.filter((v) => v !== "");
|
|
772
|
+
const contentType = (header("content-type")[0] ?? "").split(";")[0].trim();
|
|
773
|
+
const permitted = header("x-permitted-cross-domain-policies");
|
|
774
|
+
if (!policyContentType(contentType) || permitted.includes("none-this-response")) {
|
|
775
|
+
return null;
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
const policy = parsePolicy(new TextDecoder().decode(response.bytes));
|
|
779
|
+
return policy && { policy, url, contentType, permitted };
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
/**
|
|
783
|
+
* A socket policy: a connection to `address`:`port` that asks for one
|
|
784
|
+
* with <policy-file-request/> and its NUL, and reads the answer up to
|
|
785
|
+
* its NUL or its close; null where none comes in `timeout`.
|
|
786
|
+
*/
|
|
787
|
+
private socketPolicy(
|
|
788
|
+
address: string,
|
|
789
|
+
port: number,
|
|
790
|
+
timeout: number,
|
|
791
|
+
signal: AbortSignal,
|
|
792
|
+
): Promise<SocketPolicy | null> {
|
|
793
|
+
return new Promise((answer) => {
|
|
794
|
+
const chunks: Buffer[] = [];
|
|
795
|
+
let length = 0;
|
|
796
|
+
const socket = connect({ host: address, port });
|
|
797
|
+
const finish = (policy: SocketPolicy | null) => {
|
|
798
|
+
clearTimeout(timer);
|
|
799
|
+
signal.removeEventListener("abort", abort);
|
|
800
|
+
socket.destroy();
|
|
801
|
+
answer(policy);
|
|
802
|
+
};
|
|
803
|
+
const read = () => {
|
|
804
|
+
const text = Buffer.concat(chunks).toString("utf8");
|
|
805
|
+
const policy = parsePolicy(text.split("\0")[0]);
|
|
806
|
+
finish(policy && { policy, port });
|
|
807
|
+
};
|
|
808
|
+
const abort = () => finish(null);
|
|
809
|
+
const timer = setTimeout(abort, timeout);
|
|
810
|
+
signal.addEventListener("abort", abort, { once: true });
|
|
811
|
+
socket.on("connect", () => socket.write("<policy-file-request/>\0"));
|
|
812
|
+
socket.on("data", (chunk: Buffer) => {
|
|
813
|
+
length += chunk.length;
|
|
814
|
+
if (length > this.limits.policy) {
|
|
815
|
+
finish(null);
|
|
816
|
+
return;
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
chunks.push(chunk);
|
|
820
|
+
if (chunk.includes(0)) {
|
|
821
|
+
read();
|
|
822
|
+
}
|
|
823
|
+
});
|
|
824
|
+
socket.on("end", read);
|
|
825
|
+
socket.on("error", () => finish(null));
|
|
826
|
+
});
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
/**
|
|
830
|
+
* One HTTP exchange with `address`, the URL's host pinned to it: its
|
|
831
|
+
* status and headers, and its body if `read` and the status is a
|
|
832
|
+
* success, up to `max` bytes. No cookies, no credentials, no
|
|
833
|
+
* compression asked for, no connection kept.
|
|
834
|
+
*/
|
|
835
|
+
private send(
|
|
836
|
+
url: URL,
|
|
837
|
+
address: string,
|
|
838
|
+
method: string,
|
|
839
|
+
headers: [string, string][],
|
|
840
|
+
body: Uint8Array | null,
|
|
841
|
+
signal: AbortSignal,
|
|
842
|
+
options: {
|
|
843
|
+
read: boolean;
|
|
844
|
+
max: number;
|
|
845
|
+
referer: string | null;
|
|
846
|
+
timeout?: number;
|
|
847
|
+
deadline?: number;
|
|
848
|
+
/** Where the bytes read count against the movie's `buffered`; a policy file's do not. */
|
|
849
|
+
budget?: Budget;
|
|
850
|
+
},
|
|
851
|
+
): Promise<NetResponse> {
|
|
852
|
+
const timeout = options.timeout ?? this.limits.timeout;
|
|
853
|
+
const deadline = options.deadline ?? this.limits.deadline;
|
|
854
|
+
return new Promise((answer, fail) => {
|
|
855
|
+
if (signal.aborted) {
|
|
856
|
+
fail(new Refused("aborted"));
|
|
857
|
+
return;
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
const host = bare(url.hostname);
|
|
861
|
+
const outgoing: Record<string, string> = {
|
|
862
|
+
"User-Agent": USER_AGENT,
|
|
863
|
+
Accept: "*/*",
|
|
864
|
+
"x-flash-version": FLASH_VERSION,
|
|
865
|
+
};
|
|
866
|
+
if (options.referer) {
|
|
867
|
+
outgoing.Referer = options.referer;
|
|
868
|
+
}
|
|
869
|
+
|
|
870
|
+
for (const [name, value] of headers) {
|
|
871
|
+
outgoing[name] = value;
|
|
872
|
+
}
|
|
873
|
+
|
|
874
|
+
if (body) {
|
|
875
|
+
outgoing["Content-Length"] = String(body.length);
|
|
876
|
+
}
|
|
877
|
+
|
|
878
|
+
const https = url.protocol === "https:";
|
|
879
|
+
const family = isIP(address);
|
|
880
|
+
let request: ClientRequest;
|
|
881
|
+
try {
|
|
882
|
+
request = (https ? httpsRequest : httpRequest)({
|
|
883
|
+
host,
|
|
884
|
+
port: url.port || (https ? 443 : 80),
|
|
885
|
+
path: `${url.pathname}${url.search}`,
|
|
886
|
+
method,
|
|
887
|
+
headers: outgoing,
|
|
888
|
+
agent: false,
|
|
889
|
+
// The address pinned for this request, whatever the resolver would say now.
|
|
890
|
+
lookup: (_name, opts, callback) => {
|
|
891
|
+
if ((opts as { all?: boolean }).all) {
|
|
892
|
+
(callback as (e: null, a: { address: string; family: number }[]) => void)(null, [
|
|
893
|
+
{ address, family },
|
|
894
|
+
]);
|
|
895
|
+
} else {
|
|
896
|
+
(callback as (e: null, a: string, f: number) => void)(null, address, family);
|
|
897
|
+
}
|
|
898
|
+
},
|
|
899
|
+
...(https ? { servername: isIP(host) ? undefined : host, ca: this.ca } : {}),
|
|
900
|
+
// Only where the user asked for this host: its certificate goes unchecked.
|
|
901
|
+
...(https && this.unverified(host) ? { rejectUnauthorized: false } : {}),
|
|
902
|
+
});
|
|
903
|
+
} catch (error) {
|
|
904
|
+
// What check() let through and Node still refuses: a refusal like any other.
|
|
905
|
+
fail(new Refused((error as Error).message));
|
|
906
|
+
return;
|
|
907
|
+
}
|
|
908
|
+
let timer: NodeJS.Timeout;
|
|
909
|
+
const overall = setTimeout(() => stop(new Refused("it took too long")), deadline);
|
|
910
|
+
let finished = false;
|
|
911
|
+
/** What else a stop takes down: a decompressor. */
|
|
912
|
+
const stopping: (() => void)[] = [];
|
|
913
|
+
const stop = (error: Refused) => {
|
|
914
|
+
if (!finished) {
|
|
915
|
+
finished = true;
|
|
916
|
+
for (const down of stopping) {
|
|
917
|
+
down();
|
|
918
|
+
}
|
|
919
|
+
|
|
920
|
+
clearTimeout(timer);
|
|
921
|
+
clearTimeout(overall);
|
|
922
|
+
signal.removeEventListener("abort", abort);
|
|
923
|
+
request.destroy();
|
|
924
|
+
fail(error);
|
|
925
|
+
}
|
|
926
|
+
};
|
|
927
|
+
const arm = () => {
|
|
928
|
+
clearTimeout(timer);
|
|
929
|
+
timer = setTimeout(() => stop(new Refused("it timed out")), timeout);
|
|
930
|
+
};
|
|
931
|
+
const abort = () => stop(new Refused("aborted"));
|
|
932
|
+
signal.addEventListener("abort", abort, { once: true });
|
|
933
|
+
arm();
|
|
934
|
+
request.on("error", (error) => stop(new Refused(error.message)));
|
|
935
|
+
request.on("response", (response: IncomingMessage) => {
|
|
936
|
+
const status = response.statusCode ?? 0;
|
|
937
|
+
const received: [string, string][] = [];
|
|
938
|
+
const raw = response.rawHeaders;
|
|
939
|
+
for (let i = 0; i + 1 < raw.length; i += 2) {
|
|
940
|
+
// The SWF has no cookie jar to keep them in.
|
|
941
|
+
if (!/^set-cookie2?$/i.test(raw[i])) {
|
|
942
|
+
received.push([raw[i], raw[i + 1]]);
|
|
943
|
+
}
|
|
944
|
+
}
|
|
945
|
+
|
|
946
|
+
const ok = status >= 200 && status < 300;
|
|
947
|
+
const end = (bytes: Uint8Array | null) => {
|
|
948
|
+
if (!finished) {
|
|
949
|
+
finished = true;
|
|
950
|
+
clearTimeout(timer);
|
|
951
|
+
clearTimeout(overall);
|
|
952
|
+
signal.removeEventListener("abort", abort);
|
|
953
|
+
request.destroy();
|
|
954
|
+
answer({ bytes, status, headers: received });
|
|
955
|
+
}
|
|
956
|
+
};
|
|
957
|
+
if (!ok || !options.read) {
|
|
958
|
+
end(null);
|
|
959
|
+
return;
|
|
960
|
+
}
|
|
961
|
+
|
|
962
|
+
const declared = Number(response.headers["content-length"]);
|
|
963
|
+
if (declared > options.max) {
|
|
964
|
+
stop(new Refused(`its response is larger than ${options.max} bytes`));
|
|
965
|
+
return;
|
|
966
|
+
}
|
|
967
|
+
|
|
968
|
+
// A body a server compressed though no one asked: decompressed as it
|
|
969
|
+
// comes, the limits counting what comes out. One coding is undone;
|
|
970
|
+
// more than one, which no server needs, is refused.
|
|
971
|
+
const codings = String(response.headers["content-encoding"] ?? "")
|
|
972
|
+
.split(",")
|
|
973
|
+
.map((c) => c.trim().toLowerCase())
|
|
974
|
+
.filter((c) => c !== "" && c !== "identity");
|
|
975
|
+
if (codings.length > 1) {
|
|
976
|
+
stop(new Refused(`its response is encoded ${codings.length} times`));
|
|
977
|
+
return;
|
|
978
|
+
}
|
|
979
|
+
|
|
980
|
+
const coding = codings[0] ?? "";
|
|
981
|
+
const coded = DECODED.has(coding);
|
|
982
|
+
if (coded) {
|
|
983
|
+
const named = (n: string) => /^content-(encoding|length)$/i.test(n);
|
|
984
|
+
received.splice(0, received.length, ...received.filter(([n]) => !named(n)));
|
|
985
|
+
}
|
|
986
|
+
|
|
987
|
+
// The body, copied as it comes into one array that doubles as it
|
|
988
|
+
// fills: never a list of the chunks, which for a body sent a byte at
|
|
989
|
+
// a time would hold hundreds of times its size. It starts at the
|
|
990
|
+
// declared length, where that is believable, and so needs no copy at
|
|
991
|
+
// the end.
|
|
992
|
+
const budget = options.budget;
|
|
993
|
+
const exact = !coded && Number.isSafeInteger(declared) && declared >= 0;
|
|
994
|
+
let body = new Uint8Array(0);
|
|
995
|
+
let length = 0;
|
|
996
|
+
const reserve = (capacity: number): boolean => {
|
|
997
|
+
const more = capacity - body.length;
|
|
998
|
+
if (budget) {
|
|
999
|
+
if (budget.session.buffered + more > this.limits.buffered) {
|
|
1000
|
+
stop(new Refused("the SWF's responses under way hold too much at once"));
|
|
1001
|
+
return false;
|
|
1002
|
+
}
|
|
1003
|
+
|
|
1004
|
+
budget.session.buffered += more;
|
|
1005
|
+
budget.taken += more;
|
|
1006
|
+
}
|
|
1007
|
+
|
|
1008
|
+
const grown = new Uint8Array(capacity);
|
|
1009
|
+
grown.set(body.subarray(0, length));
|
|
1010
|
+
body = grown;
|
|
1011
|
+
return true;
|
|
1012
|
+
};
|
|
1013
|
+
if (exact && declared > 0 && !reserve(declared)) {
|
|
1014
|
+
return;
|
|
1015
|
+
}
|
|
1016
|
+
|
|
1017
|
+
const take = (chunk: Buffer) => {
|
|
1018
|
+
if (finished) {
|
|
1019
|
+
return;
|
|
1020
|
+
}
|
|
1021
|
+
|
|
1022
|
+
if (length + chunk.length > options.max) {
|
|
1023
|
+
stop(new Refused(`its response is larger than ${options.max} bytes`));
|
|
1024
|
+
return;
|
|
1025
|
+
}
|
|
1026
|
+
|
|
1027
|
+
if (length + chunk.length > body.length) {
|
|
1028
|
+
const capacity = Math.min(
|
|
1029
|
+
options.max,
|
|
1030
|
+
Math.max(length + chunk.length, body.length * 2),
|
|
1031
|
+
);
|
|
1032
|
+
if (!reserve(capacity)) {
|
|
1033
|
+
return;
|
|
1034
|
+
}
|
|
1035
|
+
}
|
|
1036
|
+
|
|
1037
|
+
body.set(chunk, length);
|
|
1038
|
+
length += chunk.length;
|
|
1039
|
+
arm();
|
|
1040
|
+
};
|
|
1041
|
+
// Bytes of their own, not a view of a larger array, whose rest IPC
|
|
1042
|
+
// would carry to the page with them; the larger array let go of at
|
|
1043
|
+
// once, so that it may be collected before IPC copies the rest.
|
|
1044
|
+
const done = () => {
|
|
1045
|
+
const bytes = length === body.length ? body : body.slice(0, length);
|
|
1046
|
+
body = new Uint8Array(0);
|
|
1047
|
+
end(bytes);
|
|
1048
|
+
};
|
|
1049
|
+
arm();
|
|
1050
|
+
if (!coded) {
|
|
1051
|
+
response.on("data", take);
|
|
1052
|
+
response.on("end", done);
|
|
1053
|
+
} else {
|
|
1054
|
+
const decompressor = new Decompressor(coding, take, done, () =>
|
|
1055
|
+
stop(new Refused("its response does not decompress")),
|
|
1056
|
+
);
|
|
1057
|
+
stopping.push(() => decompressor.destroy());
|
|
1058
|
+
decompressor.onDrain = () => response.resume();
|
|
1059
|
+
response.on("data", (chunk: Buffer) => {
|
|
1060
|
+
arm();
|
|
1061
|
+
if (!decompressor.write(chunk)) {
|
|
1062
|
+
response.pause();
|
|
1063
|
+
}
|
|
1064
|
+
});
|
|
1065
|
+
response.on("end", () => decompressor.end());
|
|
1066
|
+
}
|
|
1067
|
+
|
|
1068
|
+
response.on("error", (error) => stop(new Refused(error.message)));
|
|
1069
|
+
response.on("aborted", () => stop(new Refused("the response broke off")));
|
|
1070
|
+
});
|
|
1071
|
+
request.end(body ?? undefined);
|
|
1072
|
+
});
|
|
1073
|
+
}
|
|
1074
|
+
}
|
|
1075
|
+
|
|
1076
|
+
/**
|
|
1077
|
+
* Whether `url` is potentially trustworthy, as the Secure Contexts
|
|
1078
|
+
* specification has it for a Referer: https, or this machine by its
|
|
1079
|
+
* loopback address or a name under localhost.
|
|
1080
|
+
*/
|
|
1081
|
+
function trustworthy(url: URL): boolean {
|
|
1082
|
+
if (url.protocol === "https:") {
|
|
1083
|
+
return true;
|
|
1084
|
+
}
|
|
1085
|
+
|
|
1086
|
+
// A name with its root's dot is the same name; of IPv6 addresses the
|
|
1087
|
+
// specification names ::1 alone, not an IPv4 loopback address inside one.
|
|
1088
|
+
const host = bare(url.hostname).toLowerCase().replace(/\.$/, "");
|
|
1089
|
+
return (
|
|
1090
|
+
host === "localhost" ||
|
|
1091
|
+
host.endsWith(".localhost") ||
|
|
1092
|
+
host === "::1" ||
|
|
1093
|
+
(isIPv4(host) && addressClass(host) === "loopback")
|
|
1094
|
+
);
|
|
1095
|
+
}
|
|
1096
|
+
|
|
1097
|
+
/** The longest Referer sent whole, as the Referrer Policy specification has it. */
|
|
1098
|
+
const MAX_REFERER = 4096;
|
|
1099
|
+
|
|
1100
|
+
/**
|
|
1101
|
+
* The Referer a request from the SWF at `swf` to `target` carries, as
|
|
1102
|
+
* browsers' strict-origin-when-cross-origin has it: the SWF's URL to its
|
|
1103
|
+
* own origin, its origin alone to another, and nothing from https to what
|
|
1104
|
+
* is not potentially trustworthy; a URL longer than 4096 characters goes
|
|
1105
|
+
* as its origin.
|
|
1106
|
+
*/
|
|
1107
|
+
export function refererFor(swf: URL | null, target: URL): string | null {
|
|
1108
|
+
if (!swf || (swf.protocol === "https:" && !trustworthy(target))) {
|
|
1109
|
+
return null;
|
|
1110
|
+
}
|
|
1111
|
+
|
|
1112
|
+
const origin = `${swf.origin}/`;
|
|
1113
|
+
if (swf.origin !== target.origin || swf.href.length > MAX_REFERER) {
|
|
1114
|
+
return origin.length > MAX_REFERER ? null : origin;
|
|
1115
|
+
}
|
|
1116
|
+
|
|
1117
|
+
return swf.href;
|
|
1118
|
+
}
|
|
1119
|
+
|
|
1120
|
+
/** `promise`, or a Refused once `signal` aborts: a request stops waiting on a shared policy. */
|
|
1121
|
+
function abortable<T>(promise: Promise<T>, signal: AbortSignal): Promise<T> {
|
|
1122
|
+
if (signal.aborted) {
|
|
1123
|
+
return Promise.reject(new Refused("aborted"));
|
|
1124
|
+
}
|
|
1125
|
+
|
|
1126
|
+
return new Promise((resolve, reject) => {
|
|
1127
|
+
const stop = () => reject(new Refused("aborted"));
|
|
1128
|
+
signal.addEventListener("abort", stop, { once: true });
|
|
1129
|
+
promise.then(
|
|
1130
|
+
(value) => {
|
|
1131
|
+
signal.removeEventListener("abort", stop);
|
|
1132
|
+
resolve(value);
|
|
1133
|
+
},
|
|
1134
|
+
(error) => {
|
|
1135
|
+
signal.removeEventListener("abort", stop);
|
|
1136
|
+
reject(error);
|
|
1137
|
+
},
|
|
1138
|
+
);
|
|
1139
|
+
});
|
|
1140
|
+
}
|
|
1141
|
+
|
|
1142
|
+
/** A host name without an IPv6 address's brackets. */
|
|
1143
|
+
function bare(host: string): string {
|
|
1144
|
+
return host.replace(/^\[(.*)\]$/, "$1");
|
|
1145
|
+
}
|
|
1146
|
+
|
|
1147
|
+
function checkPort(url: URL): void {
|
|
1148
|
+
const port = Number(url.port || (url.protocol === "https:" ? 443 : 80));
|
|
1149
|
+
if (badPort(port)) {
|
|
1150
|
+
throw new Refused(`port ${port} is not for HTTP`);
|
|
1151
|
+
}
|
|
1152
|
+
}
|
|
1153
|
+
|
|
1154
|
+
/** Where a redirect goes, if `response` is one, to http or https; one elsewhere is refused. */
|
|
1155
|
+
function redirectOf(url: URL, response: NetResponse): URL | null {
|
|
1156
|
+
if (!REDIRECTS.has(response.status)) {
|
|
1157
|
+
return null;
|
|
1158
|
+
}
|
|
1159
|
+
|
|
1160
|
+
const location = response.headers.find(([n]) => n.toLowerCase() === "location")?.[1];
|
|
1161
|
+
if (location === undefined) {
|
|
1162
|
+
return null;
|
|
1163
|
+
}
|
|
1164
|
+
|
|
1165
|
+
let next: URL;
|
|
1166
|
+
try {
|
|
1167
|
+
next = new URL(location, url);
|
|
1168
|
+
} catch {
|
|
1169
|
+
throw new Refused("a redirect to no URL");
|
|
1170
|
+
}
|
|
1171
|
+
|
|
1172
|
+
if (next.protocol !== "http:" && next.protocol !== "https:") {
|
|
1173
|
+
throw new Refused(`a redirect to ${next.protocol}`);
|
|
1174
|
+
}
|
|
1175
|
+
|
|
1176
|
+
if (next.username || next.password) {
|
|
1177
|
+
throw new Refused("a redirect to a URL with credentials");
|
|
1178
|
+
}
|
|
1179
|
+
|
|
1180
|
+
next.hash = "";
|
|
1181
|
+
return next;
|
|
1182
|
+
}
|
|
1183
|
+
|
|
1184
|
+
/** `key`'s value in `cache`, made once with `make`; a cache grown past its bound starts again. */
|
|
1185
|
+
function cached<T>(
|
|
1186
|
+
cache: Map<string, Promise<T>>,
|
|
1187
|
+
key: string,
|
|
1188
|
+
make: () => Promise<T>,
|
|
1189
|
+
): Promise<T> {
|
|
1190
|
+
let value = cache.get(key);
|
|
1191
|
+
if (!value) {
|
|
1192
|
+
if (cache.size >= 512) {
|
|
1193
|
+
cache.clear();
|
|
1194
|
+
}
|
|
1195
|
+
|
|
1196
|
+
value = make();
|
|
1197
|
+
cache.set(key, value);
|
|
1198
|
+
}
|
|
1199
|
+
|
|
1200
|
+
return value;
|
|
1201
|
+
}
|
|
1202
|
+
|
|
1203
|
+
function describe(requester: Requester): string {
|
|
1204
|
+
return requester.host === null ? 'a local SWF (domain="*")' : requester.host;
|
|
1205
|
+
}
|