CVE-2024-37169: Insufficient Information (NVD-CWE-noinfo)
value: @jmondi/url-to-png is a self-hosted URL to PNG utility. Versions prior to 2.0.3 are vulnerable to arbitrary file read if a threat actor uses the Playright's screenshot feature to exploit the file wrapper. Version 2.0.3 mitigates this issue by requiring input URLs to be of protocol `http` or `https`. No known workarounds are available aside from upgrading.
CWE description: There is insufficient information about the issue to classify it; details are unkown or unspecified.
Published: 2024-06-10T22:15Z
Repository: https://github.com/jasonraimondi/url-to-png
Language: TypeScript
Files changed: src/lib/schema.ts, src/middlewares/extract_query_params.ts, tests/app.spec.ts
Commit date: 2024-06-03 22:28:33 -0400
Commit message: fix(security): require screenshot protocol to be http/https

prevent file:// URI scheme in Playwright screenshots

A critical vulnerability was discovered in a web application feature that utilizes
Playwright's screenshot capability. Attackers could exploit this vulnerability by
using the file:// URI scheme to read arbitrary files on the server's filesystem,
potentially exposing sensitive information, such as AWS credentials.

This commit addresses the vulnerability by implementing proper input validation
and sanitization to prevent the use of the file:// URI scheme in Playwright
screenshot requests, mitigating the risk of unauthorized file access.

resolves #47

Diff: diff --git a/src/lib/schema.ts b/src/lib/schema.ts
index 95dc938..c8aae6e 100644
--- a/src/lib/schema.ts
+++ b/src/lib/schema.ts
@@ -5,14 +5,29 @@ const zodStringBool = z
   .string()
   .toLowerCase()
   .transform(x => x === "true")
   .pipe(z.boolean());
 
-const zodStringUrl = z.string().url();
+const urlSchema = z
+  .string()
+  .url()
+  .refine(
+    val => {
+      try {
+        const url = new URL(val);
+        return url.protocol === "http:" || url.protocol === "https:";
+      } catch (err) {
+        return false;
+      }
+    },
+    {
+      message: "must start with http or https",
+    },
+  );
 
 export const PlainConfigSchema = z.object({
-  url: zodStringUrl,
+  url: urlSchema,
   width: z.coerce.number().nullish(),
   height: z.coerce.number().nullish(),
   viewPortWidth: z.coerce.number().nullish(),
   viewPortHeight: z.coerce.number().nullish(),
   forceReload: zodStringBool.nullish(),
diff --git a/src/middlewares/extract_query_params.ts b/src/middlewares/extract_query_params.ts
index a216c9e..e710d7a 100644
--- a/src/middlewares/extract_query_params.ts
+++ b/src/middlewares/extract_query_params.ts
@@ -31,11 +31,19 @@ export function handleExtractQueryParamsMiddleware(encryptionService?: StringEnc
     }
 
     const { validData, errors } = parseForm({ data: input, schema: PlainConfigSchema });
 
     if (errors) {
-      throw new HTTPException(400, { message: "Invalid query parameters", cause: errors });
+      let message: string = "Invalid query parameters: ";
+
+      const specificErrors = Object.entries(errors).map(([key, value]) => `(${key} - ${value})`).join(" ")
+
+      message = `${message} ${specificErrors}`;
+
+      console.log(message);
+
+      throw new HTTPException(400, { message, cause: errors });
     }
 
     if (validData.width && validData.width > 1920) {
       validData.width = 1920;
     }
diff --git a/tests/app.spec.ts b/tests/app.spec.ts
index 98c57d7..c0ff1f0 100644
--- a/tests/app.spec.ts
+++ b/tests/app.spec.ts
@@ -69,10 +69,22 @@ suite("app", () => {
     it("throws when invalid domain", async () => {
       const res = await app.request("/?url=bar");
       expect(res.status).toBe(400);
       expect(await res.text()).toMatch(/Invalid query/gi);
     });
+
+    [
+      "file:///etc/passwd&width=4000",
+      "view-source:file:///home/&width=4000",
+      "view-source:file:///home/ec2-user/url-to-png/.env",
+    ].forEach(invalidDomain => {
+      it(`throws when invalid protocol ${invalidDomain}`, async () => {
+        const res = await app.request(`/?url=${invalidDomain}`);
+        expect(res.status).toBe(400);
+        expect(await res.text()).toMatch(/url - must start with http or https/gi);
+      });
+    });
   });
 
   describe("GET /?hash=", () => {
     describe("without CRYPTO_KEY", () => {
       it("throws when server is not configured for encryption", async () => {

Vulnerable code: const zodStringUrl = z.string().url();
  url: zodStringUrl,
      throw new HTTPException(400, { message: "Invalid query parameters", cause: errors });
Fixed code: const urlSchema = z
  .string()
  .url()
  .refine(
    val => {
      try {
        const url = new URL(val);
        return url.protocol === "http:" || url.protocol === "https:";
      } catch (err) {
        return false;
      }
    },
    {
      message: "must start with http or https",
    },
  );
  url: urlSchema,
      let message: string = "Invalid query parameters: ";

      const specificErrors = Object.entries(errors).map(([key, value]) => `(${key} - ${value})`).join(" ")

      message = `${message} ${specificErrors}`;

      console.log(message);

      throw new HTTPException(400, { message, cause: errors });

    [
      "file:///etc/passwd&width=4000",
      "view-source:file:///home/&width=4000",
      "view-source:file:///home/ec2-user/url-to-png/.env",
    ].forEach(invalidDomain => {
      it(`throws when invalid protocol ${invalidDomain}`, async () => {
        const res = await app.request(`/?url=${invalidDomain}`);
        expect(res.status).toBe(400);
        expect(await res.text()).toMatch(/url - must start with http or https/gi);
      });
    });