Skip to content
Open
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
48 changes: 0 additions & 48 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

86 changes: 76 additions & 10 deletions src/input/urlInput.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*/
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");
}

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;
Expand All @@ -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: {
Expand Down Expand Up @@ -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<string, string>,
redirects: number,
maxRedirects: number
Expand All @@ -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");
Expand Down
6 changes: 3 additions & 3 deletions src/v2/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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."
Expand Down
1 change: 1 addition & 0 deletions src/v2/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
3 changes: 2 additions & 1 deletion src/v2/parsing/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,8 @@ export type { ErrorDetails } from "./error/index.js";
export {
Job,
JobResponse,
JobWebhook
JobWebhook,
JobStatus,
} from "./job/index.js";
export {
BaseInference,
Expand Down
1 change: 1 addition & 0 deletions src/v2/parsing/job/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
export { Job } from "./job.js";
export { JobResponse } from "./jobResponse.js";
export { JobWebhook } from "./jobWebhook.js";
export { JobStatus } from "./jobStatus.js";
5 changes: 3 additions & 2 deletions src/v2/parsing/job/job.ts
Original file line number Diff line number Diff line change
@@ -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.
Expand Down Expand Up @@ -38,7 +39,7 @@ export class Job {
/**
* Status of the job.
*/
public status?: string;
public status?: JobStatus | string;
Comment thread
sebastianMindee marked this conversation as resolved.
/**
* URL to poll for the job status.
*/
Expand All @@ -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;
Comment thread
sebastianMindee marked this conversation as resolved.
}
if (serverResponse["error"]) {
this.error = new ErrorResponse(serverResponse["error"]);
Expand Down
11 changes: 11 additions & 0 deletions src/v2/parsing/job/jobStatus.ts
Original file line number Diff line number Diff line change
@@ -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",
}
Loading
Loading