diff --git a/src/input/urlInput.ts b/src/input/urlInput.ts index b205d786..18eca804 100644 --- a/src/input/urlInput.ts +++ b/src/input/urlInput.ts @@ -25,14 +25,28 @@ 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. + */ + 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; @@ -44,11 +58,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 +125,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 +163,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/parsing/index.ts b/src/v2/parsing/index.ts index b449079d..d6f04b0d 100644 --- a/src/v2/parsing/index.ts +++ b/src/v2/parsing/index.ts @@ -6,7 +6,7 @@ export type { ErrorDetails } from "./error/index.js"; export { Job, JobResponse, - JobWebhook + JobWebhook, } from "./job/index.js"; export { BaseInference, diff --git a/src/v2/parsing/job/job.ts b/src/v2/parsing/job/job.ts index dce9d172..e5c2f35e 100644 --- a/src/v2/parsing/job/job.ts +++ b/src/v2/parsing/job/job.ts @@ -38,7 +38,7 @@ export class Job { /** * Status of the job. */ - public status?: string; + public status: string; /** * URL to poll for the job status. */ @@ -54,9 +54,7 @@ export class Job { constructor(serverResponse: StringDict) { this.id = serverResponse["id"]; - if (serverResponse["status"] !== undefined) { - this.status = serverResponse["status"]; - } + this.status = serverResponse["status"]; if (serverResponse["error"]) { this.error = new ErrorResponse(serverResponse["error"]); } diff --git a/tests/input/urlInputSource.spec.ts b/tests/input/urlInputSource.spec.ts index 24cbd603..d57e8e23 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";