diff --git a/package-lock.json b/package-lock.json index b1fc4029..c8c71153 100644 --- a/package-lock.json +++ b/package-lock.json @@ -915,9 +915,6 @@ "cpu": [ "arm" ], - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -934,9 +931,6 @@ "cpu": [ "arm64" ], - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -953,9 +947,6 @@ "cpu": [ "ppc64" ], - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -972,9 +963,6 @@ "cpu": [ "riscv64" ], - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -991,9 +979,6 @@ "cpu": [ "s390x" ], - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1010,9 +995,6 @@ "cpu": [ "x64" ], - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1029,9 +1011,6 @@ "cpu": [ "arm64" ], - "libc": [ - "musl" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1048,9 +1027,6 @@ "cpu": [ "x64" ], - "libc": [ - "musl" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1067,9 +1043,6 @@ "cpu": [ "arm" ], - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -1092,9 +1065,6 @@ "cpu": [ "arm64" ], - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -1117,9 +1087,6 @@ "cpu": [ "ppc64" ], - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -1142,9 +1109,6 @@ "cpu": [ "riscv64" ], - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -1167,9 +1131,6 @@ "cpu": [ "s390x" ], - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -1192,9 +1153,6 @@ "cpu": [ "x64" ], - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -1217,9 +1175,6 @@ "cpu": [ "arm64" ], - "libc": [ - "musl" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -1242,9 +1197,6 @@ "cpu": [ "x64" ], - "libc": [ - "musl" - ], "license": "Apache-2.0", "optional": true, "os": [ diff --git a/src/input/urlInput.ts b/src/input/urlInput.ts index b205d786..d5e37904 100644 --- a/src/input/urlInput.ts +++ b/src/input/urlInput.ts @@ -25,14 +25,54 @@ export class UrlInput extends InputSource { if (this.initialized) { return; } - logger.debug(`source URL: ${this.url}`); - if (!this.url.toLowerCase().startsWith("https")) { - throw new MindeeInputSourceError("URL must be HTTPS"); - } + logger.debug(`source URL: ${UrlInput.maskCredentials(this.url)}`); + UrlInput.validateUrl(this.url); this.fileObject = this.url; this.initialized = true; } + /** + * Validates that a URL is safe to fetch: scheme must be https and the host + * must not be a literal loopback, link-local, or private-network address. + * Note: this does not perform DNS resolution — a hostname that resolves to + * a private IP will not be caught here. + */ + static validateUrl(url: string): void { + let parsed: URL; + try { + parsed = new URL(url); + } catch { + throw new MindeeInputSourceError("Invalid URL"); + } + + if (parsed.protocol !== "https:") { + throw new MindeeInputSourceError("URL must be HTTPS"); + } + + const hostname = parsed.hostname.toLowerCase().replace(/^\[|]$/g, ""); // strip IPv6 brackets + + if ( + hostname === "localhost" || + hostname === "::1" || + /^127\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(hostname) + ) { + throw new MindeeInputSourceError( + "URL host is a loopback or private address" + ); + } + + if ( + /^10\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(hostname) || + /^172\.(1[6-9]|2\d|3[01])\.\d{1,3}\.\d{1,3}$/.test(hostname) || + /^192\.168\.\d{1,3}\.\d{1,3}$/.test(hostname) || + /^169\.254\.\d{1,3}\.\d{1,3}$/.test(hostname) + ) { + throw new MindeeInputSourceError( + "URL host is a loopback or private address" + ); + } + } + private async fetchFileContent(options: { username?: string; password?: string; @@ -44,11 +84,12 @@ export class UrlInput extends InputSource { if (token) { headers["Authorization"] = `Bearer ${token}`; + } else if (username && password) { + const encoded = Buffer.from(`${username}:${password}`).toString("base64"); + headers["Authorization"] = `Basic ${encoded}`; } - const auth = username && password ? `${username}:${password}` : undefined; - - return await this.makeRequest(this.url, auth, headers, 0, maxRedirects); + return await this.makeRequest(this.url, headers, 0, maxRedirects); } async saveToFile(options: { @@ -110,9 +151,27 @@ export class UrlInput extends InputSource { return filename; } + private static maskCredentials(url: string): string { + try { + const parsed = new URL(url); + if (parsed.username || parsed.password) { + parsed.username = "******"; + parsed.password = "******"; + } + return parsed.toString(); + } catch { + return url; + } + } + + private static isSameOrigin(a: string, b: string): boolean { + const urlA = new URL(a); + const urlB = new URL(b); + return urlA.origin === urlB.origin; + } + private async makeRequest( url: string, - auth: string | undefined, headers: Record, redirects: number, maxRedirects: number @@ -130,15 +189,22 @@ export class UrlInput extends InputSource { ); if (response.statusCode && response.statusCode >= 300 && response.statusCode < 400) { - logger.debug(`Redirecting to: ${response.headers.location}`); + logger.debug(`Redirecting to: ${UrlInput.maskCredentials(response.headers.location?.toString() ?? "")}`); if (redirects === maxRedirects) { throw new MindeeInputSourceError( `Can't reach URL after ${redirects} out of ${maxRedirects} redirects, aborting operation.` ); } if (response.headers.location) { + const redirectUrl = new URL(response.headers.location.toString(), url).toString(); + UrlInput.validateUrl(redirectUrl); + const redirectHeaders = UrlInput.isSameOrigin(url, redirectUrl) + ? headers + : Object.fromEntries( + Object.entries(headers).filter(([k]) => k.toLowerCase() !== "authorization") + ); return await this.makeRequest( - response.headers.location.toString(), auth, headers, redirects + 1, maxRedirects + redirectUrl, redirectHeaders, redirects + 1, maxRedirects ); } throw new MindeeInputSourceError("Redirect location not found"); diff --git a/src/v2/client.ts b/src/v2/client.ts index e066f093..7b0ae62f 100644 --- a/src/v2/client.ts +++ b/src/v2/client.ts @@ -4,7 +4,7 @@ import { InputSource } from "@/input/index.js"; import { MindeeError } from "@/errors/index.js"; import { errorHandler } from "@/errors/handler.js"; import { LOG_LEVELS, logger } from "@/logger.js"; -import { ErrorResponse, JobResponse } from "./parsing/index.js"; +import { ErrorResponse, JobResponse, JobStatus } from "./parsing/index.js"; import { SearchResponse } from "./parsing/search/index.js"; import { MindeeApiV2 } from "./http/mindeeApiV2.js"; import { MindeeHttpErrorV2 } from "./http/errors.js"; @@ -203,10 +203,10 @@ export class Client { throw new MindeeHttpErrorV2(error); } logger.debug(`Job status: ${pollResults.job.status}.`); - if (pollResults.job.status === "Failed") { + if (pollResults.job.status === JobStatus.Failed) { break; } - if (pollResults.job.status === "Processed") { + if (pollResults.job.status === JobStatus.Processed) { if (!pollResults.job.resultUrl) { throw new MindeeError( "The result URL is undefined. This is a server error, try again later or contact support." diff --git a/src/v2/index.ts b/src/v2/index.ts index d58f6d47..037dc0cc 100644 --- a/src/v2/index.ts +++ b/src/v2/index.ts @@ -4,6 +4,7 @@ export * as product from "./product/index.js"; export { Client } from "./client.js"; export { JobResponse, + JobStatus, ErrorResponse, LocalResponse, } from "./parsing/index.js"; diff --git a/src/v2/parsing/index.ts b/src/v2/parsing/index.ts index b449079d..fab1d3f5 100644 --- a/src/v2/parsing/index.ts +++ b/src/v2/parsing/index.ts @@ -6,7 +6,8 @@ export type { ErrorDetails } from "./error/index.js"; export { Job, JobResponse, - JobWebhook + JobWebhook, + JobStatus, } from "./job/index.js"; export { BaseInference, diff --git a/src/v2/parsing/job/index.ts b/src/v2/parsing/job/index.ts index e67e54a2..f3e9c4d4 100644 --- a/src/v2/parsing/job/index.ts +++ b/src/v2/parsing/job/index.ts @@ -1,3 +1,4 @@ export { Job } from "./job.js"; export { JobResponse } from "./jobResponse.js"; export { JobWebhook } from "./jobWebhook.js"; +export { JobStatus } from "./jobStatus.js"; diff --git a/src/v2/parsing/job/job.ts b/src/v2/parsing/job/job.ts index dce9d172..67a3d81c 100644 --- a/src/v2/parsing/job/job.ts +++ b/src/v2/parsing/job/job.ts @@ -1,6 +1,7 @@ import { StringDict, parseDate } from "@/parsing/index.js"; import { ErrorResponse } from "@/v2/index.js"; import { JobWebhook } from "./jobWebhook.js"; +import { JobStatus } from "./jobStatus.js"; /** * Job information for a V2 polling attempt. @@ -38,7 +39,7 @@ export class Job { /** * Status of the job. */ - public status?: string; + public status?: JobStatus | string; /** * URL to poll for the job status. */ @@ -55,7 +56,7 @@ export class Job { constructor(serverResponse: StringDict) { this.id = serverResponse["id"]; if (serverResponse["status"] !== undefined) { - this.status = serverResponse["status"]; + this.status = serverResponse["status"] as JobStatus; } if (serverResponse["error"]) { this.error = new ErrorResponse(serverResponse["error"]); diff --git a/src/v2/parsing/job/jobStatus.ts b/src/v2/parsing/job/jobStatus.ts new file mode 100644 index 00000000..451b9efb --- /dev/null +++ b/src/v2/parsing/job/jobStatus.ts @@ -0,0 +1,11 @@ +/* eslint-disable @typescript-eslint/naming-convention */ + +/** + * Possible statuses for a V2 Job. + */ +export enum JobStatus { + Pending = "Pending", + Processing = "Processing", + Processed = "Processed", + Failed = "Failed", +} diff --git a/tests/input/urlInputSource.spec.ts b/tests/input/urlInputSource.spec.ts index 24cbd603..13fad976 100644 --- a/tests/input/urlInputSource.spec.ts +++ b/tests/input/urlInputSource.spec.ts @@ -32,6 +32,58 @@ describe("Input Sources - URL input source", () => { } }); + it("should throw an error for localhost URL", async () => { + for (const host of ["localhost", "127.0.0.1", "127.1.2.3"]) { + const urlSource = new UrlInput({ url: `https://${host}/file.pdf`, dispatcher: mockAgent }); + try { + await urlSource.init(); + assert.fail(`Expected an error for host: ${host}`); + } catch (error) { + assert.ok(error instanceof Error); + assert.strictEqual( + (error as Error).message, + "URL host is a loopback or private address" + ); + } + } + }); + + it("should throw an error for IPv6 loopback URL", async () => { + const urlSource = new UrlInput({ url: "https://[::1]/file.pdf", dispatcher: mockAgent }); + try { + await urlSource.init(); + assert.fail("Expected an error to be thrown"); + } catch (error) { + assert.ok(error instanceof Error); + assert.strictEqual( + (error as Error).message, + "URL host is a loopback or private address" + ); + } + }); + + it("should throw an error for private IP ranges", async () => { + for (const host of [ + "10.0.0.1", + "172.16.0.1", + "172.31.255.255", + "192.168.1.1", + "169.254.0.1", + ]) { + const urlSource = new UrlInput({ url: `https://${host}/file.pdf`, dispatcher: mockAgent }); + try { + await urlSource.init(); + assert.fail(`Expected an error for host: ${host}`); + } catch (error) { + assert.ok(error instanceof Error); + assert.strictEqual( + (error as Error).message, + "URL host is a loopback or private address" + ); + } + } + }); + describe("asLocalInputSource", () => { @@ -81,6 +133,74 @@ describe("Input Sources - URL input source", () => { assert.deepStrictEqual(localInput.fileObject, fileContent); }); + it("should handle relative redirect URLs", async () => { + const originalUrl = "https://dummy-host/dir/original.pdf"; + const fileContent = Buffer.from("relative redirect content"); + + mockPool + .intercept({ path: "/dir/original.pdf", method: "GET" }) + .reply(302, "", { headers: { location: "/other/redirected.pdf" } }); + + mockPool + .intercept({ path: "/other/redirected.pdf", method: "GET" }) + .reply(200, fileContent); + + const urlInput = new UrlInput({ url: originalUrl, dispatcher: mockAgent }); + const localInput = await urlInput.asLocalInputSource(); + await localInput.init(); + + assert.ok(localInput instanceof BytesInput); + assert.strictEqual(localInput.filename, "redirected.pdf"); + assert.deepStrictEqual(localInput.fileObject, fileContent); + }); + + it("should strip Authorization header on cross-origin redirect", async () => { + const originalUrl = "https://dummy-host/file.pdf"; + const crossOriginUrl = "https://other-host/file.pdf"; + const fileContent = Buffer.from("cross-origin content"); + const otherPool = mockAgent.get("https://other-host"); + + mockPool + .intercept({ path: "/file.pdf", method: "GET" }) + .reply(302, "", { headers: { location: crossOriginUrl } }); + + otherPool + .intercept({ + path: "/file.pdf", + method: "GET", + headers: (h: Record) => !("Authorization" in h), + }) + .reply(200, fileContent); + + const urlInput = new UrlInput({ url: originalUrl, dispatcher: mockAgent }); + const localInput = await urlInput.asLocalInputSource({ token: "secret" }); + await localInput.init(); + + assert.ok(localInput instanceof BytesInput); + assert.deepStrictEqual(localInput.fileObject, fileContent); + }); + + it("should block redirect to loopback address", async () => { + const originalUrl = "https://dummy-host/file.pdf"; + + mockPool + .intercept({ path: "/file.pdf", method: "GET" }) + .reply(302, "", { headers: { location: "https://127.0.0.1/evil" } }); + + const urlInput = new UrlInput({ url: originalUrl, dispatcher: mockAgent }); + + try { + await urlInput.asLocalInputSource(); + assert.fail("Expected an error to be thrown"); + } catch (error) { + assert.ok(error instanceof Error); + assert.strictEqual( + (error as Error).message, + "URL host is a loopback or private address" + ); + } + }); + it("should throw an error for HTTP error responses", async () => { const url = "https://dummy-host/not-found.pdf";