Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 50 additions & 10 deletions src/input/urlInput.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*/
Comment thread
sebastianMindee marked this conversation as resolved.
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;
Expand All @@ -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: {
Expand Down Expand Up @@ -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<string, string>,
redirects: number,
maxRedirects: number
Expand All @@ -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");
Expand Down
2 changes: 1 addition & 1 deletion src/v2/parsing/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ export type { ErrorDetails } from "./error/index.js";
export {
Job,
JobResponse,
JobWebhook
JobWebhook,
} from "./job/index.js";
export {
BaseInference,
Expand Down
6 changes: 2 additions & 4 deletions src/v2/parsing/job/job.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ export class Job {
/**
* Status of the job.
*/
public status?: string;
public status: string;
/**
* URL to poll for the job status.
*/
Expand All @@ -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"]);
}
Expand Down
68 changes: 68 additions & 0 deletions tests/input/urlInputSource.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string>) => !("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";

Expand Down
Loading