@bedolla/enrivision 0.1.5 → 0.1.6
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/README.md +31 -6
- package/dist/client/EnriProxyClient.d.ts +289 -244
- package/dist/client/EnriProxyClient.d.ts.map +1 -1
- package/dist/client/EnriProxyClient.js +841 -115
- package/dist/client/EnriProxyClient.js.map +1 -1
- package/dist/client/EnriProxyClientContract.d.ts +425 -0
- package/dist/client/EnriProxyClientContract.d.ts.map +1 -0
- package/dist/client/EnriProxyClientContract.js +87 -0
- package/dist/client/EnriProxyClientContract.js.map +1 -0
- package/dist/index.js +23 -12
- package/dist/index.js.map +1 -1
- package/dist/package-info.d.ts +28 -0
- package/dist/package-info.d.ts.map +1 -1
- package/dist/package-info.js +28 -0
- package/dist/package-info.js.map +1 -1
- package/dist/server/EnriVisionServer.d.ts +186 -0
- package/dist/server/EnriVisionServer.d.ts.map +1 -1
- package/dist/server/EnriVisionServer.js +780 -93
- package/dist/server/EnriVisionServer.js.map +1 -1
- package/dist/shared/codepointTruncation.d.ts +61 -0
- package/dist/shared/codepointTruncation.d.ts.map +1 -0
- package/dist/shared/codepointTruncation.js +73 -0
- package/dist/shared/codepointTruncation.js.map +1 -0
- package/dist/shared/mediaUrlFetcher.d.ts +247 -9
- package/dist/shared/mediaUrlFetcher.d.ts.map +1 -1
- package/dist/shared/mediaUrlFetcher.js +712 -53
- package/dist/shared/mediaUrlFetcher.js.map +1 -1
- package/dist/shared/tar.d.ts +82 -2
- package/dist/shared/tar.d.ts.map +1 -1
- package/dist/shared/tar.js +106 -43
- package/dist/shared/tar.js.map +1 -1
- package/dist/shared/validation.d.ts +96 -2
- package/dist/shared/validation.d.ts.map +1 -1
- package/dist/shared/validation.js +169 -10
- package/dist/shared/validation.js.map +1 -1
- package/dist/tools/AnalyzeMediaContract.d.ts +457 -0
- package/dist/tools/AnalyzeMediaContract.d.ts.map +1 -0
- package/dist/tools/AnalyzeMediaContract.js +161 -0
- package/dist/tools/AnalyzeMediaContract.js.map +1 -0
- package/dist/tools/AnalyzeMediaExtractionSanitizer.d.ts +35 -0
- package/dist/tools/AnalyzeMediaExtractionSanitizer.d.ts.map +1 -0
- package/dist/tools/AnalyzeMediaExtractionSanitizer.js +214 -0
- package/dist/tools/AnalyzeMediaExtractionSanitizer.js.map +1 -0
- package/dist/tools/AnalyzeMediaInputResolver.d.ts +250 -0
- package/dist/tools/AnalyzeMediaInputResolver.d.ts.map +1 -0
- package/dist/tools/AnalyzeMediaInputResolver.js +430 -0
- package/dist/tools/AnalyzeMediaInputResolver.js.map +1 -0
- package/dist/tools/AnalyzeMediaParamParser.d.ts +299 -0
- package/dist/tools/AnalyzeMediaParamParser.d.ts.map +1 -0
- package/dist/tools/AnalyzeMediaParamParser.js +824 -0
- package/dist/tools/AnalyzeMediaParamParser.js.map +1 -0
- package/dist/tools/AnalyzeMediaResumableUploader.d.ts +244 -0
- package/dist/tools/AnalyzeMediaResumableUploader.d.ts.map +1 -0
- package/dist/tools/AnalyzeMediaResumableUploader.js +549 -0
- package/dist/tools/AnalyzeMediaResumableUploader.js.map +1 -0
- package/dist/tools/AnalyzeMediaTarPackager.d.ts +42 -0
- package/dist/tools/AnalyzeMediaTarPackager.d.ts.map +1 -0
- package/dist/tools/AnalyzeMediaTarPackager.js +245 -0
- package/dist/tools/AnalyzeMediaTarPackager.js.map +1 -0
- package/dist/tools/AnalyzeMediaTool.d.ts +155 -294
- package/dist/tools/AnalyzeMediaTool.d.ts.map +1 -1
- package/dist/tools/AnalyzeMediaTool.js +600 -457
- package/dist/tools/AnalyzeMediaTool.js.map +1 -1
- package/package.json +2 -1
|
@@ -4,34 +4,41 @@
|
|
|
4
4
|
* Downloads one http(s) media resource into a temporary directory so the
|
|
5
5
|
* upload pipeline can treat it like any local file.
|
|
6
6
|
*
|
|
7
|
-
* Protections: 64 MiB size cap, 60 s timeout,
|
|
7
|
+
* Protections: 64 MiB size cap, 60 s timeout, 30 s stall watchdog (a hung
|
|
8
|
+
* `reader.read()` aborts the download), SSRF guard (literal private
|
|
8
9
|
* addresses rejected, DNS-resolved addresses rejected, per-hop validation of
|
|
9
|
-
* up to
|
|
10
|
+
* up to 5 redirects), and an owned cleanup callback.
|
|
10
11
|
*
|
|
11
12
|
* @module shared/mediaUrlFetcher
|
|
12
13
|
*/
|
|
13
14
|
import { isIP } from "node:net";
|
|
14
15
|
import { lookup as dnsLookup } from "node:dns/promises";
|
|
15
|
-
import {
|
|
16
|
+
import { createWriteStream } from "node:fs";
|
|
17
|
+
import { mkdtemp, rm } from "node:fs/promises";
|
|
16
18
|
import { tmpdir } from "node:os";
|
|
17
19
|
import { join } from "node:path";
|
|
20
|
+
import { finished } from "node:stream/promises";
|
|
21
|
+
import { extension as mimeExtension, lookup as mimeLookup } from "mime-types";
|
|
18
22
|
/**
|
|
19
23
|
* Immutable media URL fetch result record.
|
|
20
24
|
*/
|
|
21
25
|
class MediaUrlFetchResultRecord {
|
|
22
26
|
localPath;
|
|
23
27
|
contentType;
|
|
28
|
+
extensionSynthesized;
|
|
24
29
|
cleanupCallback;
|
|
25
30
|
/**
|
|
26
31
|
* Creates one fetch result.
|
|
27
32
|
*
|
|
28
33
|
* @param localPath - Downloaded file path.
|
|
29
34
|
* @param contentType - Reported content type.
|
|
35
|
+
* @param extensionSynthesized - Whether the file extension was synthesized.
|
|
30
36
|
* @param cleanupCallback - Owned cleanup callback.
|
|
31
37
|
*/
|
|
32
|
-
constructor(localPath, contentType, cleanupCallback) {
|
|
38
|
+
constructor(localPath, contentType, extensionSynthesized, cleanupCallback) {
|
|
33
39
|
this.localPath = localPath;
|
|
34
40
|
this.contentType = contentType;
|
|
41
|
+
this.extensionSynthesized = extensionSynthesized;
|
|
35
42
|
this.cleanupCallback = cleanupCallback;
|
|
36
43
|
}
|
|
37
44
|
/**
|
|
@@ -49,14 +56,46 @@ export class MediaUrlFetcher {
|
|
|
49
56
|
* Maximum accepted download size in bytes.
|
|
50
57
|
*/
|
|
51
58
|
static MAX_BYTES = 64 * 1024 * 1024;
|
|
59
|
+
/**
|
|
60
|
+
* Stable size-cap marker embedded in every size-cap rejection message.
|
|
61
|
+
*
|
|
62
|
+
* Routers match on this marker (via {@link MediaUrlFetcher.isSizeCapError}),
|
|
63
|
+
* never on the human-scale budget prose.
|
|
64
|
+
*/
|
|
65
|
+
static URL_SIZE_CAP_MARKER = "[ENRIVISION_MEDIA_URL_SIZE_CAP]";
|
|
66
|
+
/**
|
|
67
|
+
* Reports whether an error is a client-side URL size-cap rejection.
|
|
68
|
+
*
|
|
69
|
+
* @param error - Unknown caught failure.
|
|
70
|
+
* @returns True only for size-cap rejections carrying the stable marker.
|
|
71
|
+
*/
|
|
72
|
+
static isSizeCapError(error) {
|
|
73
|
+
return error instanceof Error && error.message.includes(MediaUrlFetcher.URL_SIZE_CAP_MARKER);
|
|
74
|
+
}
|
|
52
75
|
/**
|
|
53
76
|
* Download timeout in milliseconds.
|
|
54
77
|
*/
|
|
55
78
|
static TIMEOUT_MS = 60_000;
|
|
56
79
|
/**
|
|
57
|
-
* Maximum followed redirects (each hop re-validated).
|
|
80
|
+
* Maximum followed redirects (each hop re-validated, mirrors EnriCode).
|
|
81
|
+
*/
|
|
82
|
+
static MAX_REDIRECTS = 5;
|
|
83
|
+
/**
|
|
84
|
+
* Redirect statuses that carry a `Location` hop.
|
|
85
|
+
*
|
|
86
|
+
* @remarks
|
|
87
|
+
* Mirrors EnriCode `VisionAnalyzeMediaUrlFetcher.REDIRECT_STATUSES`: 304
|
|
88
|
+
* (Not Modified, a cache validator without `Location` semantics), 305
|
|
89
|
+
* (deprecated proxy directive), and 306 (unused since HTTP/1.1) never
|
|
90
|
+
* start a new hop. Treating them as terminal keeps redirect-chasing
|
|
91
|
+
* predictable and avoids method/body-rewrite surprises.
|
|
92
|
+
*/
|
|
93
|
+
static REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308]);
|
|
94
|
+
/**
|
|
95
|
+
* Stall watchdog in milliseconds: abort when no body bytes arrive within
|
|
96
|
+
* this window (mirrors EnriCode `BODY_STALL_MS`).
|
|
58
97
|
*/
|
|
59
|
-
static
|
|
98
|
+
static BODY_STALL_MS = 30_000;
|
|
60
99
|
/**
|
|
61
100
|
* Fetch implementation (injectable for tests).
|
|
62
101
|
*/
|
|
@@ -89,105 +128,661 @@ export class MediaUrlFetcher {
|
|
|
89
128
|
static isHttpUrl(value) {
|
|
90
129
|
return /^https?:\/\//iu.test(String(value ?? "").trim());
|
|
91
130
|
}
|
|
131
|
+
/**
|
|
132
|
+
* Reports whether one served content type is analyzable media.
|
|
133
|
+
*
|
|
134
|
+
* @remarks
|
|
135
|
+
* Exact-match set mirroring EnriCode `VisionAnalyzeMediaUrlFetcher`: Office
|
|
136
|
+
* types never match by substring, so crafted types such as
|
|
137
|
+
* `application/x-wordprocessing-evil` are rejected.
|
|
138
|
+
*
|
|
139
|
+
* @param contentType - Served content type without parameters.
|
|
140
|
+
* @returns True for image, video, audio, PDF, and Office documents.
|
|
141
|
+
*/
|
|
142
|
+
static isAllowedMediaContentType(contentType) {
|
|
143
|
+
const normalized = String(contentType ?? "").trim().toLowerCase();
|
|
144
|
+
if (normalized.length === 0) {
|
|
145
|
+
return false;
|
|
146
|
+
}
|
|
147
|
+
if (normalized.startsWith("image/")
|
|
148
|
+
|| normalized.startsWith("video/")
|
|
149
|
+
|| normalized.startsWith("audio/")) {
|
|
150
|
+
return true;
|
|
151
|
+
}
|
|
152
|
+
return ALLOWED_EXACT_CONTENT_TYPES.has(normalized);
|
|
153
|
+
}
|
|
154
|
+
/**
|
|
155
|
+
* Reports whether a URL path carries a known media extension.
|
|
156
|
+
*
|
|
157
|
+
* @remarks
|
|
158
|
+
* Fallback for servers that omit `content-type`: the extension is mapped
|
|
159
|
+
* through the canonical `mime-types` table and the mapped type must pass
|
|
160
|
+
* {@link isAllowedMediaContentType}, so this fallback can never drift
|
|
161
|
+
* from the allowlist (mirrors EnriCode
|
|
162
|
+
* `VisionAnalyzeMediaUrlFetcher.hasKnownMediaExtension`). A lying
|
|
163
|
+
* extension still resolves through the authoritative server type
|
|
164
|
+
* downstream, exactly like the EnriCode contract.
|
|
165
|
+
*
|
|
166
|
+
* @param url - Final URL (after redirects).
|
|
167
|
+
* @returns True when the URL path ends with an extension mapping to analyzable media.
|
|
168
|
+
*/
|
|
169
|
+
static hasKnownMediaExtension(url) {
|
|
170
|
+
const withoutQuery = String(url ?? "").split(/[?#]/)[0] ?? "";
|
|
171
|
+
const baseName = withoutQuery.split("/").pop() ?? "";
|
|
172
|
+
const match = /\.([A-Za-z0-9]{1,10})$/u.exec(baseName);
|
|
173
|
+
if (match === null) {
|
|
174
|
+
return false;
|
|
175
|
+
}
|
|
176
|
+
const mapped = mimeLookup(match[1].toLowerCase());
|
|
177
|
+
if (typeof mapped !== "string") {
|
|
178
|
+
return false;
|
|
179
|
+
}
|
|
180
|
+
return MediaUrlFetcher.isAllowedMediaContentType(mapped.split(";", 1)[0].trim());
|
|
181
|
+
}
|
|
92
182
|
/**
|
|
93
183
|
* Downloads one http(s) URL into a temporary directory.
|
|
94
184
|
*
|
|
185
|
+
* @remarks
|
|
186
|
+
* The response body is streamed directly to disk so large downloads never
|
|
187
|
+
* sit fully in memory; the 64 MiB cap is enforced incrementally while
|
|
188
|
+
* streaming.
|
|
189
|
+
*
|
|
95
190
|
* @param url - Absolute http(s) URL.
|
|
191
|
+
* @param options - Optional fetch options (cancellation signal).
|
|
96
192
|
* @returns Bounded fetch result with owned cleanup.
|
|
97
|
-
* @throws Error when the destination is blocked, too large, or the download fails.
|
|
193
|
+
* @throws Error when the destination is blocked, too large, cancelled, or the download fails.
|
|
98
194
|
*/
|
|
99
|
-
async fetch(url) {
|
|
195
|
+
async fetch(url, options) {
|
|
196
|
+
const callerSignal = options?.signal;
|
|
197
|
+
if (callerSignal?.aborted) {
|
|
198
|
+
throw new Error("La descarga de media fue cancelada por el cliente. / Media download cancelled by the client.");
|
|
199
|
+
}
|
|
100
200
|
let currentUrl = String(url ?? "").trim();
|
|
101
201
|
if (!MediaUrlFetcher.isHttpUrl(currentUrl)) {
|
|
102
|
-
throw new Error("Sólo se aceptan URLs http(s) en path/paths.");
|
|
202
|
+
throw new Error("Sólo se aceptan URLs http(s) en path/paths. / Only http(s) URLs are accepted in path/paths.");
|
|
103
203
|
}
|
|
104
204
|
const timeoutSignal = AbortSignal.timeout(MediaUrlFetcher.TIMEOUT_MS);
|
|
105
|
-
|
|
205
|
+
const combinedSignal = callerSignal
|
|
206
|
+
? AbortSignal.any([timeoutSignal, callerSignal])
|
|
207
|
+
: timeoutSignal;
|
|
208
|
+
let response = await this.requestValidated(currentUrl, combinedSignal, callerSignal);
|
|
106
209
|
for (let redirectCount = 0; redirectCount < MediaUrlFetcher.MAX_REDIRECTS; redirectCount += 1) {
|
|
107
|
-
if (
|
|
210
|
+
if (!MediaUrlFetcher.REDIRECT_STATUSES.has(response.status)) {
|
|
108
211
|
break;
|
|
109
212
|
}
|
|
110
213
|
const location = response.headers.get("location");
|
|
111
214
|
if (!location) {
|
|
112
|
-
|
|
215
|
+
throw new Error(`La redirección HTTP ${String(response.status)} no incluyó Location. / HTTP redirect ${String(response.status)} included no Location.`);
|
|
216
|
+
}
|
|
217
|
+
try {
|
|
218
|
+
currentUrl = new URL(location, currentUrl).toString();
|
|
219
|
+
}
|
|
220
|
+
catch {
|
|
221
|
+
throw new Error(`La redirección trae una URL inválida: '${location}'. / Redirect carries an invalid URL: '${location}'.`);
|
|
113
222
|
}
|
|
114
|
-
currentUrl = new URL(location, currentUrl).toString();
|
|
115
223
|
if (!MediaUrlFetcher.isHttpUrl(currentUrl)) {
|
|
116
|
-
throw new Error("La redirección apunta fuera de http(s).");
|
|
224
|
+
throw new Error("La redirección apunta fuera de http(s). / Redirect points outside http(s).");
|
|
225
|
+
}
|
|
226
|
+
// Release the intermediate hop body before following the redirect so
|
|
227
|
+
// a hostile redirect chain with large bodies cannot retain a socket
|
|
228
|
+
// and buffered bytes per hop.
|
|
229
|
+
try {
|
|
230
|
+
await response.body?.cancel();
|
|
231
|
+
}
|
|
232
|
+
catch {
|
|
233
|
+
// Best-effort only.
|
|
117
234
|
}
|
|
118
|
-
response = await this.requestValidated(currentUrl,
|
|
235
|
+
response = await this.requestValidated(currentUrl, combinedSignal, callerSignal);
|
|
236
|
+
}
|
|
237
|
+
// A redirect status surviving the loop means the chain is exhausted
|
|
238
|
+
// (more than MAX_REDIRECTS hops, or a hop without Location): coach in
|
|
239
|
+
// Spanish like EnriCode instead of surfacing a bare HTTP 3xx. The body
|
|
240
|
+
// is always released first so the terminal hop never retains a socket.
|
|
241
|
+
if (MediaUrlFetcher.REDIRECT_STATUSES.has(response.status)) {
|
|
242
|
+
try {
|
|
243
|
+
await response.body?.cancel();
|
|
244
|
+
}
|
|
245
|
+
catch {
|
|
246
|
+
// Best-effort only.
|
|
247
|
+
}
|
|
248
|
+
throw new Error(`La URL excede el máximo de ${String(MediaUrlFetcher.MAX_REDIRECTS)} redirecciones. / URL exceeds the maximum of ${String(MediaUrlFetcher.MAX_REDIRECTS)} redirects.`);
|
|
119
249
|
}
|
|
120
250
|
if (!response.ok) {
|
|
121
|
-
|
|
251
|
+
try {
|
|
252
|
+
await response.body?.cancel();
|
|
253
|
+
}
|
|
254
|
+
catch {
|
|
255
|
+
// Best-effort only.
|
|
256
|
+
}
|
|
257
|
+
throw new Error(`La URL respondió HTTP ${String(response.status)}. / URL answered HTTP ${String(response.status)}.`);
|
|
122
258
|
}
|
|
123
259
|
const declaredLengthHeader = response.headers.get("content-length");
|
|
124
260
|
if (declaredLengthHeader !== null &&
|
|
125
261
|
Number(declaredLengthHeader) > MediaUrlFetcher.MAX_BYTES) {
|
|
126
|
-
throw new Error(`El archivo remoto excede el límite de 64 MiB.`);
|
|
127
|
-
}
|
|
128
|
-
const buffer = await response.arrayBuffer();
|
|
129
|
-
if (buffer.byteLength > MediaUrlFetcher.MAX_BYTES) {
|
|
130
|
-
throw new Error(`El archivo remoto excede el límite de 64 MiB.`);
|
|
262
|
+
throw new Error(`El archivo remoto excede el límite de 64 MiB. / ${MediaUrlFetcher.URL_SIZE_CAP_MARKER} Remote file exceeds the 64 MiB limit.`);
|
|
131
263
|
}
|
|
132
|
-
const
|
|
264
|
+
const servedContentType = (response.headers.get("content-type") ?? "")
|
|
133
265
|
.split(";", 1)[0]
|
|
134
266
|
.trim()
|
|
135
267
|
.toLowerCase();
|
|
268
|
+
// When the server omits content-type, a known media extension in the
|
|
269
|
+
// final URL rescues the download (mirrors EnriCode): only reject when
|
|
270
|
+
// both signals fail. A disallowed served type (e.g. `text/html` behind
|
|
271
|
+
// a `.png` URL) is blanked instead of trusted verbatim so the resolver
|
|
272
|
+
// infers the type from the rescuing extension, exactly like the EnriCode
|
|
273
|
+
// `trustedContentType === ""` contract; uploading `text/html` bytes as
|
|
274
|
+
// media is never useful.
|
|
275
|
+
if (!MediaUrlFetcher.isAllowedMediaContentType(servedContentType)
|
|
276
|
+
&& !MediaUrlFetcher.hasKnownMediaExtension(currentUrl)) {
|
|
277
|
+
try {
|
|
278
|
+
await response.body?.cancel();
|
|
279
|
+
}
|
|
280
|
+
catch {
|
|
281
|
+
// Best-effort only.
|
|
282
|
+
}
|
|
283
|
+
throw new Error(`La URL no sirvió un archivo de media válido (content-type: ${servedContentType.length > 0 ? servedContentType : "desconocido"}). Solo se aceptan imagen, video, audio, PDF y documentos de Office. / URL did not serve a valid media file (content-type: ${servedContentType.length > 0 ? servedContentType : "desconocido"}). Only image, video, audio, PDF, and Office documents are accepted.`);
|
|
284
|
+
}
|
|
285
|
+
const contentType = MediaUrlFetcher.isAllowedMediaContentType(servedContentType)
|
|
286
|
+
? servedContentType
|
|
287
|
+
: "";
|
|
136
288
|
const temporaryDirectory = await mkdtemp(join(tmpdir(), "enrivision-url-"));
|
|
137
|
-
const
|
|
289
|
+
const derived = this.deriveFileName(currentUrl, contentType);
|
|
290
|
+
const localPath = join(temporaryDirectory, derived.fileName);
|
|
138
291
|
try {
|
|
139
|
-
await
|
|
292
|
+
await this.streamBodyToFile(response, localPath, callerSignal);
|
|
140
293
|
}
|
|
141
294
|
catch (error) {
|
|
142
295
|
await rm(temporaryDirectory, { recursive: true, force: true });
|
|
143
|
-
|
|
296
|
+
if (callerSignal?.aborted) {
|
|
297
|
+
throw new Error("La descarga de media fue cancelada por el cliente. / Media download cancelled by the client.");
|
|
298
|
+
}
|
|
299
|
+
if (error instanceof Error && isOwnedDownloadError(error)) {
|
|
300
|
+
throw error;
|
|
301
|
+
}
|
|
302
|
+
// Abort-shaped rejections that reach this boundary with no caller
|
|
303
|
+
// cancellation are the overall 60 s deadline firing during the
|
|
304
|
+
// request, redirect hops, or a null-body buffer read: surface the
|
|
305
|
+
// Spanish-first timeout instead of Node's English abort text.
|
|
306
|
+
if (error instanceof Error
|
|
307
|
+
&& (error.name === "AbortError" || error.name === "TimeoutError" || error.code === "ABORT_ERR")) {
|
|
308
|
+
throw new Error(`La descarga de media expiró: superó el límite de ${String(Math.round(MediaUrlFetcher.TIMEOUT_MS / 1_000))} s. / The media download timed out: it exceeded the ${String(Math.round(MediaUrlFetcher.TIMEOUT_MS / 1_000))} s limit.`);
|
|
309
|
+
}
|
|
310
|
+
throw new Error(`La descarga de media falló: ${error instanceof Error ? error.message : String(error)} / Media download failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
144
311
|
}
|
|
145
|
-
return new MediaUrlFetchResultRecord(localPath, contentType, async () => {
|
|
312
|
+
return new MediaUrlFetchResultRecord(localPath, contentType, derived.extensionSynthesized, async () => {
|
|
146
313
|
await rm(temporaryDirectory, { recursive: true, force: true });
|
|
147
314
|
});
|
|
148
315
|
}
|
|
316
|
+
/**
|
|
317
|
+
* Streams one validated response body directly to a file.
|
|
318
|
+
*
|
|
319
|
+
* @param response - Validated fetch response.
|
|
320
|
+
* @param localPath - Destination file path.
|
|
321
|
+
* @param callerSignal - Optional caller cancellation signal.
|
|
322
|
+
* @throws Error when the body exceeds the size cap, is cancelled, or cannot be written.
|
|
323
|
+
*/
|
|
324
|
+
async streamBodyToFile(response, localPath, callerSignal) {
|
|
325
|
+
if (!response.body) {
|
|
326
|
+
const buffer = await response.arrayBuffer();
|
|
327
|
+
if (buffer.byteLength > MediaUrlFetcher.MAX_BYTES) {
|
|
328
|
+
throw new Error(`El archivo remoto excede el límite de 64 MiB. / ${MediaUrlFetcher.URL_SIZE_CAP_MARKER} Remote file exceeds the 64 MiB limit.`);
|
|
329
|
+
}
|
|
330
|
+
const fileStream = createWriteStream(localPath);
|
|
331
|
+
const completion = finished(fileStream);
|
|
332
|
+
fileStream.end(Buffer.from(buffer));
|
|
333
|
+
try {
|
|
334
|
+
await completion;
|
|
335
|
+
}
|
|
336
|
+
catch (error) {
|
|
337
|
+
throw new Error(`No se pudo guardar la media descargada: ${error instanceof Error ? error.message : String(error)} / Could not save the downloaded media: ${error instanceof Error ? error.message : String(error)}`);
|
|
338
|
+
}
|
|
339
|
+
return;
|
|
340
|
+
}
|
|
341
|
+
const fileStream = createWriteStream(localPath);
|
|
342
|
+
const completion = finished(fileStream);
|
|
343
|
+
// The completion promise is only awaited on the success path; mark it
|
|
344
|
+
// handled so error-path destroy() never surfaces as unhandled rejection.
|
|
345
|
+
completion.catch(() => undefined);
|
|
346
|
+
const reader = response.body.getReader();
|
|
347
|
+
try {
|
|
348
|
+
let writtenBytes = 0;
|
|
349
|
+
for (;;) {
|
|
350
|
+
if (callerSignal?.aborted) {
|
|
351
|
+
throw new Error("La descarga de media fue cancelada por el cliente. / Media download cancelled by the client.");
|
|
352
|
+
}
|
|
353
|
+
const read = await MediaUrlFetcher.readWithStallWatchdog(reader, MediaUrlFetcher.BODY_STALL_MS);
|
|
354
|
+
if (read.done) {
|
|
355
|
+
break;
|
|
356
|
+
}
|
|
357
|
+
writtenBytes += read.value.byteLength;
|
|
358
|
+
if (writtenBytes > MediaUrlFetcher.MAX_BYTES) {
|
|
359
|
+
throw new Error(`El archivo remoto excede el límite de 64 MiB. / ${MediaUrlFetcher.URL_SIZE_CAP_MARKER} Remote file exceeds the 64 MiB limit.`);
|
|
360
|
+
}
|
|
361
|
+
const chunk = Buffer.from(read.value);
|
|
362
|
+
const accepted = fileStream.write(chunk);
|
|
363
|
+
if (!accepted) {
|
|
364
|
+
await new Promise((resolve, reject) => {
|
|
365
|
+
fileStream.once("drain", () => resolve());
|
|
366
|
+
fileStream.once("error", (streamError) => reject(streamError));
|
|
367
|
+
});
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
fileStream.end();
|
|
371
|
+
await completion;
|
|
372
|
+
}
|
|
373
|
+
catch (error) {
|
|
374
|
+
fileStream.destroy();
|
|
375
|
+
try {
|
|
376
|
+
await reader.cancel();
|
|
377
|
+
}
|
|
378
|
+
catch {
|
|
379
|
+
// Ignore reader-cancel failures; the original error wins.
|
|
380
|
+
}
|
|
381
|
+
if (error instanceof Error && isOwnedDownloadError(error)) {
|
|
382
|
+
throw error;
|
|
383
|
+
}
|
|
384
|
+
// The combined signal only aborts early for the overall 60 s deadline
|
|
385
|
+
// or caller cancellation (already handled above): a raw abort-shaped
|
|
386
|
+
// rejection here means the deadline fired mid-body, so surface the
|
|
387
|
+
// Spanish-first timeout instead of Node's English abort text and the
|
|
388
|
+
// generic save-failure envelope.
|
|
389
|
+
if (error instanceof Error
|
|
390
|
+
&& (error.name === "AbortError" || error.name === "TimeoutError" || error.code === "ABORT_ERR")) {
|
|
391
|
+
throw new Error(`La descarga de media expiró: superó el límite de ${String(Math.round(MediaUrlFetcher.TIMEOUT_MS / 1_000))} s. / The media download timed out: it exceeded the ${String(Math.round(MediaUrlFetcher.TIMEOUT_MS / 1_000))} s limit.`);
|
|
392
|
+
}
|
|
393
|
+
throw new Error(`No se pudo guardar la media descargada: ${error instanceof Error ? error.message : String(error)} / Could not save the downloaded media: ${error instanceof Error ? error.message : String(error)}`);
|
|
394
|
+
}
|
|
395
|
+
finally {
|
|
396
|
+
reader.releaseLock();
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
/**
|
|
400
|
+
* Reads one body chunk guarded by the stall watchdog.
|
|
401
|
+
*
|
|
402
|
+
* @remarks
|
|
403
|
+
* The global 60 s timeout alone cannot fire while one `reader.read()`
|
|
404
|
+
* hangs forever; racing each read against `BODY_STALL_MS` aborts stalled
|
|
405
|
+
* connections with a Spanish error.
|
|
406
|
+
*
|
|
407
|
+
* @param reader - Body reader.
|
|
408
|
+
* @param stallMs - Stall budget in milliseconds.
|
|
409
|
+
* @returns Read result.
|
|
410
|
+
* @throws Error with an Spanish-first bilingual message when no bytes arrive within the stall budget.
|
|
411
|
+
*/
|
|
412
|
+
static async readWithStallWatchdog(reader, stallMs) {
|
|
413
|
+
let timer;
|
|
414
|
+
try {
|
|
415
|
+
return await Promise.race([
|
|
416
|
+
reader.read(),
|
|
417
|
+
new Promise((_, reject) => {
|
|
418
|
+
timer = setTimeout(() => {
|
|
419
|
+
try {
|
|
420
|
+
void reader.cancel();
|
|
421
|
+
}
|
|
422
|
+
catch {
|
|
423
|
+
// Best-effort only; the rejection below carries the error.
|
|
424
|
+
}
|
|
425
|
+
reject(new Error("La descarga de media se detuvo por inactividad (sin bytes durante 30 s). / Media download stalled (no bytes for 30 s)."));
|
|
426
|
+
}, stallMs);
|
|
427
|
+
}),
|
|
428
|
+
]);
|
|
429
|
+
}
|
|
430
|
+
finally {
|
|
431
|
+
if (timer !== undefined) {
|
|
432
|
+
clearTimeout(timer);
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
}
|
|
149
436
|
/**
|
|
150
437
|
* Performs one fetch request after validating the destination addresses.
|
|
151
438
|
*
|
|
439
|
+
* @remarks
|
|
440
|
+
* Residual SSRF note (DNS-rebinding TOCTOU): the hostname is resolved and
|
|
441
|
+
* validated here, but the HTTP stack re-resolves it when connecting, so a
|
|
442
|
+
* hostile DNS could answer the second lookup with a private address. IP
|
|
443
|
+
* pinning is intentionally not implemented: the global fetch/undici stack
|
|
444
|
+
* used here exposes no connection-level IP pin without a custom
|
|
445
|
+
* dispatcher, and every redirect hop is re-validated to keep the race
|
|
446
|
+
* window minimal. This residual risk is accepted post-release, mirroring
|
|
447
|
+
* EnriProxy's documented DNS-rebinding stance.
|
|
448
|
+
*
|
|
152
449
|
* @param url - Candidate URL (already normalized).
|
|
153
|
-
* @param
|
|
450
|
+
* @param requestSignal - Timeout/cancellation signal shared by every hop.
|
|
451
|
+
* @param callerSignal - Caller cancellation signal used to report bilingual cancel errors.
|
|
154
452
|
* @returns Fetch response.
|
|
155
453
|
* @throws Error when the hostname resolves to a blocked address or the request fails.
|
|
156
454
|
*/
|
|
157
|
-
async requestValidated(url,
|
|
455
|
+
async requestValidated(url, requestSignal, callerSignal) {
|
|
158
456
|
await this.assertPublicDestination(url);
|
|
159
457
|
try {
|
|
160
|
-
return await this.fetchImpl(url, { signal:
|
|
458
|
+
return await this.fetchImpl(url, { signal: requestSignal, redirect: "manual" });
|
|
161
459
|
}
|
|
162
460
|
catch (error) {
|
|
163
|
-
|
|
461
|
+
if (callerSignal?.aborted) {
|
|
462
|
+
throw new Error("La descarga de media fue cancelada por el cliente. / Media download cancelled by the client.");
|
|
463
|
+
}
|
|
464
|
+
throw new Error(`La descarga de media falló: ${error instanceof Error ? error.message : String(error)} / Media download failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
164
465
|
}
|
|
165
466
|
}
|
|
166
467
|
/**
|
|
167
468
|
* Validates that the URL hostname is not a private/loopback/link-local target.
|
|
168
469
|
*
|
|
470
|
+
* @remarks
|
|
471
|
+
* Non-canonical IPv4 literals (decimal `2130706433`, hex `0x7f.0.0.1`,
|
|
472
|
+
* octal `0177.0.0.1`) are normalized to dotted quads before validation so
|
|
473
|
+
* they cannot bypass the literal-IP check and reach DNS.
|
|
474
|
+
*
|
|
169
475
|
* @param url - Candidate URL.
|
|
170
476
|
* @throws Error when a literal or resolved address is blocked.
|
|
171
477
|
*/
|
|
172
478
|
async assertPublicDestination(url) {
|
|
173
|
-
|
|
479
|
+
let rawHostname;
|
|
480
|
+
try {
|
|
481
|
+
rawHostname = new URL(url).hostname.replace(/^\[|\]$/gu, "");
|
|
482
|
+
}
|
|
483
|
+
catch {
|
|
484
|
+
throw new Error(`URL inválida: '${url}'. Use una URL http(s) completa. / Invalid URL: '${url}'. Use a complete http(s) URL.`);
|
|
485
|
+
}
|
|
486
|
+
const hostname = rawHostname.replace(/\.+$/u, "").toLowerCase();
|
|
174
487
|
if (hostname === "localhost") {
|
|
175
|
-
throw new Error("Destino bloqueado: no se permiten hosts locales ni redes privadas.");
|
|
488
|
+
throw new Error("Destino bloqueado: no se permiten hosts locales ni redes privadas. / Blocked destination: no localhost or private networks allowed.");
|
|
489
|
+
}
|
|
490
|
+
const normalized = MediaUrlFetcher.normalizeHostnameToIpAddress(hostname);
|
|
491
|
+
if (normalized !== null) {
|
|
492
|
+
this.assertPublicAddress(normalized);
|
|
493
|
+
return;
|
|
176
494
|
}
|
|
177
495
|
if (isIP(hostname) !== 0) {
|
|
178
496
|
this.assertPublicAddress(hostname);
|
|
179
497
|
return;
|
|
180
498
|
}
|
|
181
|
-
const addresses = await this.resolveHostAddresses(
|
|
499
|
+
const addresses = await this.resolveHostAddresses(rawHostname);
|
|
182
500
|
if (addresses.length === 0) {
|
|
183
|
-
throw new Error("No se pudo resolver el host antes de descargar la media.");
|
|
501
|
+
throw new Error("No se pudo resolver el host antes de descargar la media. / Could not resolve the host before downloading media.");
|
|
184
502
|
}
|
|
185
503
|
for (const address of addresses) {
|
|
186
504
|
this.assertPublicAddress(address);
|
|
187
505
|
}
|
|
188
506
|
}
|
|
189
507
|
/**
|
|
190
|
-
*
|
|
508
|
+
* Normalizes a hostname to an IP address when it is a non-canonical IP literal.
|
|
509
|
+
*
|
|
510
|
+
* @remarks
|
|
511
|
+
* Handles decimal (`2130706433`), hex (`0x7f.0.0.1`), and octal
|
|
512
|
+
* (`0177.0.0.1`) IPv4 forms per `inet_aton` rules, plus IPv6
|
|
513
|
+
* `::ffff:`-mapped forms in hex (`::ffff:7f00:1`) and full
|
|
514
|
+
* (`0:0:0:0:0:ffff:127.0.0.1`) notation. Returns null for real hostnames
|
|
515
|
+
* so they keep flowing to DNS validation.
|
|
516
|
+
*
|
|
517
|
+
* @param hostname - Lowercased hostname without brackets or trailing dots.
|
|
518
|
+
* @returns Normalized IP address or null when not an IP literal.
|
|
519
|
+
*/
|
|
520
|
+
static normalizeHostnameToIpAddress(hostname) {
|
|
521
|
+
if (hostname.length === 0 || hostname.includes(":")) {
|
|
522
|
+
return MediaUrlFetcher.normalizeMappedIpv6(hostname);
|
|
523
|
+
}
|
|
524
|
+
if (!/^[0-9a-z]+(\.[0-9a-z]+)*$/u.test(hostname)) {
|
|
525
|
+
return null;
|
|
526
|
+
}
|
|
527
|
+
const parts = hostname.split(".");
|
|
528
|
+
if (parts.length < 1 || parts.length > 4) {
|
|
529
|
+
return null;
|
|
530
|
+
}
|
|
531
|
+
const numbers = [];
|
|
532
|
+
for (const part of parts) {
|
|
533
|
+
const parsed = MediaUrlFetcher.parseIpv4Part(part);
|
|
534
|
+
if (parsed === null) {
|
|
535
|
+
return null;
|
|
536
|
+
}
|
|
537
|
+
numbers.push(parsed);
|
|
538
|
+
}
|
|
539
|
+
if (numbers.length === 1) {
|
|
540
|
+
const value = numbers[0];
|
|
541
|
+
if (value > 4294967295) {
|
|
542
|
+
return null;
|
|
543
|
+
}
|
|
544
|
+
return `${String((value >>> 24) & 255)}.${String((value >>> 16) & 255)}.${String((value >>> 8) & 255)}.${String(value & 255)}`;
|
|
545
|
+
}
|
|
546
|
+
if (numbers.length === 2) {
|
|
547
|
+
const first = numbers[0];
|
|
548
|
+
const rest = numbers[1];
|
|
549
|
+
if (first > 255 || rest > 16777215) {
|
|
550
|
+
return null;
|
|
551
|
+
}
|
|
552
|
+
return `${String(first)}.${String((rest >>> 16) & 255)}.${String((rest >>> 8) & 255)}.${String(rest & 255)}`;
|
|
553
|
+
}
|
|
554
|
+
if (numbers.length === 3) {
|
|
555
|
+
const first = numbers[0];
|
|
556
|
+
const second = numbers[1];
|
|
557
|
+
const rest = numbers[2];
|
|
558
|
+
if (first > 255 || second > 255 || rest > 65535) {
|
|
559
|
+
return null;
|
|
560
|
+
}
|
|
561
|
+
return `${String(first)}.${String(second)}.${String((rest >>> 8) & 255)}.${String(rest & 255)}`;
|
|
562
|
+
}
|
|
563
|
+
for (const octet of numbers) {
|
|
564
|
+
if (octet > 255) {
|
|
565
|
+
return null;
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
return numbers.map((octet) => String(octet)).join(".");
|
|
569
|
+
}
|
|
570
|
+
/**
|
|
571
|
+
* Parses one IPv4 address part accepting decimal, hex, and octal forms.
|
|
572
|
+
*
|
|
573
|
+
* @param part - Raw address part.
|
|
574
|
+
* @returns Parsed value or null when not a numeric part.
|
|
575
|
+
*/
|
|
576
|
+
static parseIpv4Part(part) {
|
|
577
|
+
if (/^0x[0-9a-f]+$/u.test(part)) {
|
|
578
|
+
const parsed = Number.parseInt(part, 16);
|
|
579
|
+
return Number.isSafeInteger(parsed) && parsed >= 0 ? parsed : null;
|
|
580
|
+
}
|
|
581
|
+
if (/^0[0-9]+$/u.test(part)) {
|
|
582
|
+
if (!/^0[0-7]*$/u.test(part)) {
|
|
583
|
+
return null;
|
|
584
|
+
}
|
|
585
|
+
const parsed = Number.parseInt(part, 8);
|
|
586
|
+
return Number.isSafeInteger(parsed) ? parsed : null;
|
|
587
|
+
}
|
|
588
|
+
if (/^[0-9]+$/u.test(part)) {
|
|
589
|
+
const parsed = Number.parseInt(part, 10);
|
|
590
|
+
return Number.isSafeInteger(parsed) ? parsed : null;
|
|
591
|
+
}
|
|
592
|
+
return null;
|
|
593
|
+
}
|
|
594
|
+
/**
|
|
595
|
+
* Normalizes IPv6 `::ffff:`-mapped literals to their embedded IPv4 address.
|
|
596
|
+
*
|
|
597
|
+
* @remarks
|
|
598
|
+
* Covers the compact hex form (`::ffff:7f00:1`), the compact dotted form
|
|
599
|
+
* (`::ffff:127.0.0.1`), and the full zero-expanded forms
|
|
600
|
+
* (`0:0:0:0:0:ffff:127.0.0.1`, `0:0:0:0:0:ffff:7f00:1`).
|
|
601
|
+
*
|
|
602
|
+
* @param hostname - Lowercased hostname.
|
|
603
|
+
* @returns Embedded IPv4 dotted quad or null when not a mapped literal.
|
|
604
|
+
*/
|
|
605
|
+
static normalizeMappedIpv6(hostname) {
|
|
606
|
+
const compactHex = /^::ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/u.exec(hostname);
|
|
607
|
+
if (compactHex !== null) {
|
|
608
|
+
const high = Number.parseInt(compactHex[1], 16);
|
|
609
|
+
const low = Number.parseInt(compactHex[2], 16);
|
|
610
|
+
return `${String((high >>> 8) & 255)}.${String(high & 255)}.${String((low >>> 8) & 255)}.${String(low & 255)}`;
|
|
611
|
+
}
|
|
612
|
+
const fullHex = /^0(?::0){4}:ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/u.exec(hostname);
|
|
613
|
+
if (fullHex !== null) {
|
|
614
|
+
const high = Number.parseInt(fullHex[1], 16);
|
|
615
|
+
const low = Number.parseInt(fullHex[2], 16);
|
|
616
|
+
return `${String((high >>> 8) & 255)}.${String(high & 255)}.${String((low >>> 8) & 255)}.${String(low & 255)}`;
|
|
617
|
+
}
|
|
618
|
+
const fullDotted = /^0(?::0){4}:ffff:((?:\d{1,3}\.){3}\d{1,3})$/u.exec(hostname);
|
|
619
|
+
if (fullDotted !== null) {
|
|
620
|
+
return fullDotted[1];
|
|
621
|
+
}
|
|
622
|
+
return null;
|
|
623
|
+
}
|
|
624
|
+
/**
|
|
625
|
+
* Expands one IPv6 literal into eight hex groups.
|
|
626
|
+
*
|
|
627
|
+
* @remarks
|
|
628
|
+
* Returns null for dotted-quad tails (handled by the compatible/mapped
|
|
629
|
+
* helpers), malformed group counts, and non-hex groups, so callers only
|
|
630
|
+
* ever judge fully-validated literals.
|
|
631
|
+
*
|
|
632
|
+
* @param normalized - Lowercased IPv6 literal without a zone id.
|
|
633
|
+
* @returns Eight lowercase hex groups, or null when not parseable.
|
|
634
|
+
*/
|
|
635
|
+
static expandIpv6Groups(normalized) {
|
|
636
|
+
if (normalized.includes(".")) {
|
|
637
|
+
return null;
|
|
638
|
+
}
|
|
639
|
+
const halves = normalized.split("::");
|
|
640
|
+
if (halves.length > 2) {
|
|
641
|
+
return null;
|
|
642
|
+
}
|
|
643
|
+
const left = halves[0].length === 0 ? [] : halves[0].split(":");
|
|
644
|
+
const right = halves.length === 2 ? (halves[1].length === 0 ? [] : halves[1].split(":")) : [];
|
|
645
|
+
if (halves.length === 1) {
|
|
646
|
+
return left.length === 8 ? left : null;
|
|
647
|
+
}
|
|
648
|
+
const missing = 8 - left.length - right.length;
|
|
649
|
+
if (missing < 1) {
|
|
650
|
+
return null;
|
|
651
|
+
}
|
|
652
|
+
const groups = [...left, ...new Array(missing).fill("0"), ...right];
|
|
653
|
+
for (const group of groups) {
|
|
654
|
+
if (!/^[0-9a-f]{1,4}$/u.test(group)) {
|
|
655
|
+
return null;
|
|
656
|
+
}
|
|
657
|
+
}
|
|
658
|
+
return groups;
|
|
659
|
+
}
|
|
660
|
+
/**
|
|
661
|
+
* Reports whether one IPv6 literal is unspecified or loopback in any spelling.
|
|
662
|
+
*
|
|
663
|
+
* @remarks
|
|
664
|
+
* The exact-only `"::"` / `"::1"` comparison misses zero-expanded
|
|
665
|
+
* spellings (`0:0:0:0:0:0:0:0`, `0:0:0:0:0:0:0:1`) and leading-zero
|
|
666
|
+
* variants (`::01`): expanding first closes the SSRF loopback bypass.
|
|
667
|
+
*
|
|
668
|
+
* @param normalized - Lowercased IPv6 literal.
|
|
669
|
+
* @returns True for `::/128` and `::1/128` in any spelling.
|
|
670
|
+
*/
|
|
671
|
+
static isUnspecifiedOrLoopbackV6(normalized) {
|
|
672
|
+
const groups = MediaUrlFetcher.expandIpv6Groups(normalized);
|
|
673
|
+
if (groups === null) {
|
|
674
|
+
return normalized === "::" || normalized === "::1";
|
|
675
|
+
}
|
|
676
|
+
if (groups.every((group) => Number.parseInt(group, 16) === 0)) {
|
|
677
|
+
return true;
|
|
678
|
+
}
|
|
679
|
+
return groups.slice(0, 7).every((group) => Number.parseInt(group, 16) === 0)
|
|
680
|
+
&& Number.parseInt(groups[7], 16) === 1;
|
|
681
|
+
}
|
|
682
|
+
/**
|
|
683
|
+
* Extracts the embedded IPv4 address from a deprecated IPv4-compatible literal.
|
|
684
|
+
*
|
|
685
|
+
* @remarks
|
|
686
|
+
* `::127.0.0.1` (and its zero-expanded `0:0:0:0:0:0:127.0.0.1` form)
|
|
687
|
+
* carries no `ffff` marker, so the mapped-address helper ignores it and
|
|
688
|
+
* the quad would sail through as "public". Recursing into the IPv4
|
|
689
|
+
* validator closes the bypass.
|
|
690
|
+
*
|
|
691
|
+
* @param normalized - Lowercased IPv6 literal.
|
|
692
|
+
* @returns Embedded IPv4 dotted quad or null when not a compatible literal.
|
|
693
|
+
*/
|
|
694
|
+
static extractCompatibleV4(normalized) {
|
|
695
|
+
const compact = /^::((?:\d{1,3}\.){3}\d{1,3})$/u.exec(normalized);
|
|
696
|
+
if (compact !== null) {
|
|
697
|
+
return compact[1];
|
|
698
|
+
}
|
|
699
|
+
const expanded = /^0(?::0){5}:((?:\d{1,3}\.){3}\d{1,3})$/u.exec(normalized);
|
|
700
|
+
if (expanded !== null) {
|
|
701
|
+
return expanded[1];
|
|
702
|
+
}
|
|
703
|
+
return null;
|
|
704
|
+
}
|
|
705
|
+
/**
|
|
706
|
+
* Extracts the embedded IPv4 address from a zero-prefixed hex literal.
|
|
707
|
+
*
|
|
708
|
+
* @remarks
|
|
709
|
+
* `::7f00:1` (and expanded `0:0:0:0:0:0:7f00:1`) embeds `127.0.0.1` in
|
|
710
|
+
* the low 32 bits with neither dots nor an `ffff` marker, so neither the
|
|
711
|
+
* compatible helper nor the mapped helper fires. Treating the low 32
|
|
712
|
+
* bits of any 96-bit-zero literal as IPv4 closes the bypass; literals
|
|
713
|
+
* with a nonzero prefix (including `::ffff:`-mapped) return null.
|
|
714
|
+
*
|
|
715
|
+
* @param normalized - Lowercased IPv6 literal.
|
|
716
|
+
* @returns Embedded IPv4 dotted quad or null when the prefix is nonzero.
|
|
717
|
+
*/
|
|
718
|
+
static extractZeroPrefixedV4(normalized) {
|
|
719
|
+
const groups = MediaUrlFetcher.expandIpv6Groups(normalized);
|
|
720
|
+
if (groups === null) {
|
|
721
|
+
return null;
|
|
722
|
+
}
|
|
723
|
+
for (let index = 0; index < 6; index += 1) {
|
|
724
|
+
if (Number.parseInt(groups[index], 16) !== 0) {
|
|
725
|
+
return null;
|
|
726
|
+
}
|
|
727
|
+
}
|
|
728
|
+
const high = Number.parseInt(groups[6], 16);
|
|
729
|
+
const low = Number.parseInt(groups[7], 16);
|
|
730
|
+
return `${String((high >>> 8) & 255)}.${String(high & 255)}.${String((low >>> 8) & 255)}.${String(low & 255)}`;
|
|
731
|
+
}
|
|
732
|
+
/**
|
|
733
|
+
* Extracts the embedded IPv4 address from a normalized IPv6 literal.
|
|
734
|
+
*
|
|
735
|
+
* @param normalized - Lowercased IPv6 literal.
|
|
736
|
+
* @returns Embedded IPv4 dotted quad or undefined when not mapped.
|
|
737
|
+
*/
|
|
738
|
+
static extractMappedV4(normalized) {
|
|
739
|
+
const compactDotted = /^::ffff:((?:\d{1,3}\.){3}\d{1,3})$/u.exec(normalized);
|
|
740
|
+
if (compactDotted !== null) {
|
|
741
|
+
return compactDotted[1];
|
|
742
|
+
}
|
|
743
|
+
return MediaUrlFetcher.normalizeMappedIpv6(normalized) ?? undefined;
|
|
744
|
+
}
|
|
745
|
+
/**
|
|
746
|
+
* Extracts the embedded IPv4 address from a NAT64 `64:ff9b::/96` literal.
|
|
747
|
+
*
|
|
748
|
+
* @remarks
|
|
749
|
+
* On NAT64 networks `64:ff9b::7f00:1` routes to `127.0.0.1`, so the
|
|
750
|
+
* literal sails through prefix tests as "public". Judging the embedded
|
|
751
|
+
* quad as IPv4 closes the bypass (parity with EnriCode
|
|
752
|
+
* `Nat64AddressGuard.extractNat64EmbeddedIpv4`).
|
|
753
|
+
*
|
|
754
|
+
* @param normalized - Lowercased IPv6 literal.
|
|
755
|
+
* @returns Embedded IPv4 dotted quad or null when not a NAT64 literal.
|
|
756
|
+
*/
|
|
757
|
+
static extractNat64V4(normalized) {
|
|
758
|
+
const dotted = /^64:ff9b::((?:\d{1,3}\.){3}\d{1,3})$/u.exec(normalized);
|
|
759
|
+
if (dotted !== null) {
|
|
760
|
+
return dotted[1];
|
|
761
|
+
}
|
|
762
|
+
const groups = MediaUrlFetcher.expandIpv6Groups(normalized);
|
|
763
|
+
if (groups === null) {
|
|
764
|
+
return null;
|
|
765
|
+
}
|
|
766
|
+
if (Number.parseInt(groups[0], 16) !== 0x64
|
|
767
|
+
|| Number.parseInt(groups[1], 16) !== 0xff9b
|
|
768
|
+
|| Number.parseInt(groups[2], 16) !== 0
|
|
769
|
+
|| Number.parseInt(groups[3], 16) !== 0
|
|
770
|
+
|| Number.parseInt(groups[4], 16) !== 0
|
|
771
|
+
|| Number.parseInt(groups[5], 16) !== 0) {
|
|
772
|
+
return null;
|
|
773
|
+
}
|
|
774
|
+
const high = Number.parseInt(groups[6], 16);
|
|
775
|
+
const low = Number.parseInt(groups[7], 16);
|
|
776
|
+
return `${String((high >>> 8) & 255)}.${String(high & 255)}.${String((low >>> 8) & 255)}.${String(low & 255)}`;
|
|
777
|
+
}
|
|
778
|
+
/**
|
|
779
|
+
* Rejects private, loopback, link-local, multicast, and reserved addresses.
|
|
780
|
+
*
|
|
781
|
+
* @remarks
|
|
782
|
+
* Documentation TEST-NET ranges (`192.0.2.0/24`, `198.51.100.0/24`,
|
|
783
|
+
* `203.0.113.0/24`) intentionally stay allowed: they are publicly routed
|
|
784
|
+
* test fixtures, never host-local media, and blocking them would break
|
|
785
|
+
* legitimate fixture URLs.
|
|
191
786
|
*
|
|
192
787
|
* @param address - Literal IP address.
|
|
193
788
|
* @throws Error when the address belongs to a blocked range.
|
|
@@ -196,36 +791,49 @@ export class MediaUrlFetcher {
|
|
|
196
791
|
const version = isIP(address);
|
|
197
792
|
if (version === 4) {
|
|
198
793
|
const octets = address.split(".").map((part) => Number(part));
|
|
199
|
-
const [first = 0, second = 0] = octets;
|
|
794
|
+
const [first = 0, second = 0, third = 0] = octets;
|
|
200
795
|
const blocked = first === 0 ||
|
|
201
796
|
first === 10 ||
|
|
202
797
|
first === 127 ||
|
|
798
|
+
(first === 100 && second >= 64 && second <= 127) ||
|
|
203
799
|
(first === 169 && second === 254) ||
|
|
204
800
|
(first === 172 && second >= 16 && second <= 31) ||
|
|
801
|
+
(first === 192 && second === 0 && third === 0) ||
|
|
205
802
|
(first === 192 && second === 168) ||
|
|
803
|
+
(first === 198 && second >= 18 && second <= 19) ||
|
|
206
804
|
first >= 224;
|
|
207
805
|
if (blocked) {
|
|
208
|
-
throw new Error("Destino bloqueado: no se permiten hosts locales ni redes privadas.");
|
|
806
|
+
throw new Error("Destino bloqueado: no se permiten hosts locales ni redes privadas. / Blocked destination: no localhost or private networks allowed.");
|
|
209
807
|
}
|
|
210
808
|
return;
|
|
211
809
|
}
|
|
212
810
|
if (version === 6) {
|
|
213
811
|
const normalized = address.toLowerCase();
|
|
214
|
-
|
|
215
|
-
|
|
812
|
+
// Deprecated compatible literals (::127.0.0.1, ::7f00:1, expanded
|
|
813
|
+
// spellings) hide an IPv4 quad with no ffff marker: judge the
|
|
814
|
+
// embedded quad as IPv4 before any prefix test.
|
|
815
|
+
const compatibleV4 = MediaUrlFetcher.extractCompatibleV4(normalized)
|
|
816
|
+
?? MediaUrlFetcher.extractZeroPrefixedV4(normalized)
|
|
817
|
+
?? MediaUrlFetcher.extractNat64V4(normalized);
|
|
818
|
+
if (compatibleV4 !== null) {
|
|
819
|
+
this.assertPublicAddress(compatibleV4);
|
|
820
|
+
return;
|
|
821
|
+
}
|
|
822
|
+
const blocked = MediaUrlFetcher.isUnspecifiedOrLoopbackV6(normalized) ||
|
|
216
823
|
normalized.startsWith("fc") ||
|
|
217
824
|
normalized.startsWith("fd") ||
|
|
218
825
|
normalized.startsWith("fe8") ||
|
|
219
826
|
normalized.startsWith("fe9") ||
|
|
220
827
|
normalized.startsWith("fea") ||
|
|
221
|
-
normalized.startsWith("feb")
|
|
222
|
-
|
|
828
|
+
normalized.startsWith("feb") ||
|
|
829
|
+
normalized.startsWith("ff");
|
|
830
|
+
const mappedV4 = MediaUrlFetcher.extractMappedV4(normalized) ?? null;
|
|
223
831
|
if (mappedV4 !== null) {
|
|
224
832
|
this.assertPublicAddress(mappedV4);
|
|
225
833
|
return;
|
|
226
834
|
}
|
|
227
835
|
if (blocked) {
|
|
228
|
-
throw new Error("Destino bloqueado: no se permiten hosts locales ni redes privadas.");
|
|
836
|
+
throw new Error("Destino bloqueado: no se permiten hosts locales ni redes privadas. / Blocked destination: no localhost or private networks allowed.");
|
|
229
837
|
}
|
|
230
838
|
}
|
|
231
839
|
}
|
|
@@ -234,36 +842,87 @@ export class MediaUrlFetcher {
|
|
|
234
842
|
*
|
|
235
843
|
* @param url - Source URL.
|
|
236
844
|
* @param contentType - Reported content type.
|
|
237
|
-
* @returns Bounded file name with a recognized extension.
|
|
845
|
+
* @returns Bounded file name with a recognized extension plus whether the extension was synthesized.
|
|
238
846
|
*/
|
|
239
847
|
deriveFileName(url, contentType) {
|
|
240
|
-
const withoutQuery = url.split(
|
|
848
|
+
const withoutQuery = url.split(/[?#]/)[0] ?? url;
|
|
241
849
|
const baseName = withoutQuery.split("/").pop() ?? "media";
|
|
242
850
|
const sanitized = baseName.replace(/[^A-Za-z0-9._-]/gu, "").slice(0, 80);
|
|
243
851
|
if (sanitized.length > 0 && /\.[A-Za-z0-9]{2,5}$/u.test(sanitized)) {
|
|
244
|
-
return sanitized;
|
|
852
|
+
return { fileName: sanitized, extensionSynthesized: false };
|
|
245
853
|
}
|
|
246
854
|
const extension = this.extensionForContentType(contentType);
|
|
247
|
-
return
|
|
855
|
+
return {
|
|
856
|
+
fileName: `${sanitized.length > 0 ? sanitized : "media"}${extension}`,
|
|
857
|
+
extensionSynthesized: true,
|
|
858
|
+
};
|
|
248
859
|
}
|
|
249
860
|
/**
|
|
250
|
-
* Resolves one
|
|
861
|
+
* Resolves one precise extension for a content type.
|
|
862
|
+
*
|
|
863
|
+
* @remarks
|
|
864
|
+
* The exact `mime-types` mapping wins (e.g., `image/webp` yields `.webp`,
|
|
865
|
+
* never the generic `.png`) so the synthesized extension can never shadow
|
|
866
|
+
* the authoritative server content type downstream.
|
|
251
867
|
*
|
|
252
868
|
* @param contentType - Reported content type.
|
|
253
869
|
* @returns Extension with leading dot.
|
|
254
870
|
*/
|
|
255
871
|
extensionForContentType(contentType) {
|
|
256
|
-
|
|
872
|
+
const normalized = contentType.trim().toLowerCase();
|
|
873
|
+
if (normalized && normalized !== "application/octet-stream") {
|
|
874
|
+
const mapped = mimeExtension(normalized);
|
|
875
|
+
if (typeof mapped === "string" && /^[a-z0-9]+$/iu.test(mapped)) {
|
|
876
|
+
return `.${mapped.toLowerCase()}`;
|
|
877
|
+
}
|
|
878
|
+
}
|
|
879
|
+
if (normalized.startsWith("image/"))
|
|
257
880
|
return ".png";
|
|
258
|
-
if (
|
|
881
|
+
if (normalized.startsWith("video/"))
|
|
259
882
|
return ".mp4";
|
|
260
|
-
if (
|
|
883
|
+
if (normalized.startsWith("audio/"))
|
|
261
884
|
return ".mp3";
|
|
262
|
-
if (
|
|
885
|
+
if (normalized.includes("pdf"))
|
|
263
886
|
return ".pdf";
|
|
264
|
-
if (contentType.includes("tar"))
|
|
265
|
-
return ".tar";
|
|
266
887
|
return ".bin";
|
|
267
888
|
}
|
|
268
889
|
}
|
|
890
|
+
/**
|
|
891
|
+
* Exact-match Office/document content types accepted for analysis.
|
|
892
|
+
*
|
|
893
|
+
* @remarks
|
|
894
|
+
* Mirrors EnriCode `VisionAnalyzeMediaUrlFetcher`: never extend this set
|
|
895
|
+
* with substring matching, so crafted types cannot bypass the filter.
|
|
896
|
+
*/
|
|
897
|
+
const ALLOWED_EXACT_CONTENT_TYPES = new Set([
|
|
898
|
+
"application/pdf",
|
|
899
|
+
"application/msword",
|
|
900
|
+
"application/rtf",
|
|
901
|
+
"text/csv",
|
|
902
|
+
"text/plain",
|
|
903
|
+
"application/vnd.ms-powerpoint",
|
|
904
|
+
"application/vnd.ms-excel",
|
|
905
|
+
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
|
906
|
+
"application/vnd.openxmlformats-officedocument.presentationml.presentation",
|
|
907
|
+
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
|
908
|
+
"application/vnd.oasis.opendocument.text",
|
|
909
|
+
"application/vnd.oasis.opendocument.presentation",
|
|
910
|
+
"application/vnd.oasis.opendocument.spreadsheet",
|
|
911
|
+
]);
|
|
912
|
+
/**
|
|
913
|
+
* Reports whether an error message is already an owned bilingual download error.
|
|
914
|
+
*
|
|
915
|
+
* @param error - Error to inspect.
|
|
916
|
+
* @returns True when the message must propagate unchanged.
|
|
917
|
+
*/
|
|
918
|
+
function isOwnedDownloadError(error) {
|
|
919
|
+
return (error.message.includes(MediaUrlFetcher.URL_SIZE_CAP_MARKER)
|
|
920
|
+
|| error.message.startsWith("Media download")
|
|
921
|
+
|| error.message.startsWith("Could not save the downloaded media")
|
|
922
|
+
|| error.message.startsWith("El archivo remoto")
|
|
923
|
+
|| error.message.startsWith("La descarga de media fue cancelada")
|
|
924
|
+
|| error.message.startsWith("La descarga de media se detuvo")
|
|
925
|
+
|| error.message.startsWith("La descarga de media expiró")
|
|
926
|
+
|| error.message.startsWith("No se pudo guardar la media descargada"));
|
|
927
|
+
}
|
|
269
928
|
//# sourceMappingURL=mediaUrlFetcher.js.map
|