From f9468810c63ba70a4790f2483057c12c62ce8679 Mon Sep 17 00:00:00 2001 From: sebastianMindee <130448732+sebastianMindee@users.noreply.github.com> Date: Wed, 15 Jul 2026 17:15:49 +0200 Subject: [PATCH 1/3] :sparkles: add support for queue polling enums --- src/v2/client.ts | 6 +++--- src/v2/index.ts | 1 + src/v2/parsing/index.ts | 3 ++- src/v2/parsing/job/index.ts | 1 + src/v2/parsing/job/job.ts | 5 +++-- src/v2/parsing/job/jobStatus.ts | 11 +++++++++++ 6 files changed, 21 insertions(+), 6 deletions(-) create mode 100644 src/v2/parsing/job/jobStatus.ts diff --git a/src/v2/client.ts b/src/v2/client.ts index e066f093f..7b0ae62f9 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 d58f6d47c..037dc0cc0 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 b449079d2..fab1d3f53 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 e67e54a22..f3e9c4d4f 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 dce9d172c..67a3d81cd 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 000000000..451b9efbf --- /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", +} From 88dfcaab30e17892f5fdeb9574692cd1e6f44b5b Mon Sep 17 00:00:00 2001 From: sebastianMindee <130448732+sebastianMindee@users.noreply.github.com> Date: Wed, 15 Jul 2026 17:22:48 +0200 Subject: [PATCH 2/3] :recycle: add credential masking to URL input source remote fetching --- src/input/urlInput.ts | 27 ++++++++++++++++++++------- 1 file changed, 20 insertions(+), 7 deletions(-) diff --git a/src/input/urlInput.ts b/src/input/urlInput.ts index b205d7860..e1b917e48 100644 --- a/src/input/urlInput.ts +++ b/src/input/urlInput.ts @@ -25,7 +25,7 @@ export class UrlInput extends InputSource { if (this.initialized) { return; } - logger.debug(`source URL: ${this.url}`); + logger.debug(`source URL: ${UrlInput.maskCredentials(this.url)}`); if (!this.url.toLowerCase().startsWith("https")) { throw new MindeeInputSourceError("URL must be HTTPS"); } @@ -44,11 +44,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 +111,21 @@ 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 async makeRequest( url: string, - auth: string | undefined, headers: Record, redirects: number, maxRedirects: number @@ -130,7 +143,7 @@ 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.` @@ -138,7 +151,7 @@ export class UrlInput extends InputSource { } if (response.headers.location) { return await this.makeRequest( - response.headers.location.toString(), auth, headers, redirects + 1, maxRedirects + response.headers.location.toString(), headers, redirects + 1, maxRedirects ); } throw new MindeeInputSourceError("Redirect location not found"); From 8a79f6c39bb15887fb4c9ee8c14ad5ff3dfb6082 Mon Sep 17 00:00:00 2001 From: sebastianMindee <130448732+sebastianMindee@users.noreply.github.com> Date: Wed, 15 Jul 2026 21:35:50 +0200 Subject: [PATCH 3/3] :arrow_up: bump dependencies --- src/input/urlInput.ts | 35 +++++++++++++-- src/v2/client.ts | 6 +-- src/v2/index.ts | 1 - src/v2/parsing/index.ts | 1 - src/v2/parsing/job/index.ts | 1 - src/v2/parsing/job/job.ts | 7 +-- src/v2/parsing/job/jobStatus.ts | 11 ----- tests/input/urlInputSource.spec.ts | 68 ++++++++++++++++++++++++++++++ 8 files changed, 104 insertions(+), 26 deletions(-) delete mode 100644 src/v2/parsing/job/jobStatus.ts diff --git a/src/input/urlInput.ts b/src/input/urlInput.ts index e1b917e48..18eca804f 100644 --- a/src/input/urlInput.ts +++ b/src/input/urlInput.ts @@ -26,13 +26,27 @@ export class UrlInput extends InputSource { return; } logger.debug(`source URL: ${UrlInput.maskCredentials(this.url)}`); - if (!this.url.toLowerCase().startsWith("https")) { - throw new MindeeInputSourceError("URL must be HTTPS"); - } + UrlInput.validateUrl(this.url); this.fileObject = this.url; this.initialized = true; } + /** + * Validates that a URL is safe to fetch: scheme must be https. + */ + 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"); + } + } + private async fetchFileContent(options: { username?: string; password?: string; @@ -124,6 +138,12 @@ export class UrlInput extends InputSource { } } + 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, headers: Record, @@ -150,8 +170,15 @@ export class UrlInput extends InputSource { ); } 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(), 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 7b0ae62f9..e066f093f 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, JobStatus } from "./parsing/index.js"; +import { ErrorResponse, JobResponse } 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 === JobStatus.Failed) { + if (pollResults.job.status === "Failed") { break; } - if (pollResults.job.status === JobStatus.Processed) { + if (pollResults.job.status === "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 037dc0cc0..d58f6d47c 100644 --- a/src/v2/index.ts +++ b/src/v2/index.ts @@ -4,7 +4,6 @@ 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 fab1d3f53..d6f04b0d4 100644 --- a/src/v2/parsing/index.ts +++ b/src/v2/parsing/index.ts @@ -7,7 +7,6 @@ export { Job, JobResponse, 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 f3e9c4d4f..e67e54a22 100644 --- a/src/v2/parsing/job/index.ts +++ b/src/v2/parsing/job/index.ts @@ -1,4 +1,3 @@ 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 67a3d81cd..e5c2f35ef 100644 --- a/src/v2/parsing/job/job.ts +++ b/src/v2/parsing/job/job.ts @@ -1,7 +1,6 @@ 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. @@ -39,7 +38,7 @@ export class Job { /** * Status of the job. */ - public status?: JobStatus | string; + public status: string; /** * URL to poll for the job status. */ @@ -55,9 +54,7 @@ export class Job { constructor(serverResponse: StringDict) { this.id = serverResponse["id"]; - if (serverResponse["status"] !== undefined) { - this.status = serverResponse["status"] as JobStatus; - } + this.status = serverResponse["status"]; 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 deleted file mode 100644 index 451b9efbf..000000000 --- a/src/v2/parsing/job/jobStatus.ts +++ /dev/null @@ -1,11 +0,0 @@ -/* 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 24cbd6032..d57e8e23c 100644 --- a/tests/input/urlInputSource.spec.ts +++ b/tests/input/urlInputSource.spec.ts @@ -81,6 +81,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";