From a08472af40354d01d1cf79ae07faa1e1f1507825 Mon Sep 17 00:00:00 2001 From: Joseph Thacker Date: Thu, 23 Oct 2025 13:15:54 -0400 Subject: [PATCH] feat: flag path-relative stylesheet imports (#144) --- packages/backend/src/checks/index.ts | 3 + .../index.spec.ts | 65 +++++++++ .../path-relative-stylesheet-import/index.ts | 129 ++++++++++++++++++ packages/backend/src/stores/config.ts | 4 + 4 files changed, 201 insertions(+) create mode 100644 packages/backend/src/checks/path-relative-stylesheet-import/index.spec.ts create mode 100644 packages/backend/src/checks/path-relative-stylesheet-import/index.ts diff --git a/packages/backend/src/checks/index.ts b/packages/backend/src/checks/index.ts index 1c28c38..a5756b0 100644 --- a/packages/backend/src/checks/index.ts +++ b/packages/backend/src/checks/index.ts @@ -21,6 +21,7 @@ import hashDisclosureScan from "./hash-disclosure"; import jsonHtmlResponseScan from "./json-html-response"; import missingContentTypeScan from "./missing-content-type"; import openRedirectScan from "./open-redirect"; +import pathRelativeStylesheetImportScan from "./path-relative-stylesheet-import"; import pathTraversalScan from "./path-traversal"; import phpinfoScan from "./phpinfo"; import privateIpDisclosureScan from "./private-ip-disclosure"; @@ -59,6 +60,7 @@ export const Checks = { HASH_DISCLOSURE: "hash-disclosure", JSON_HTML_RESPONSE: "json-html-response", MISSING_CONTENT_TYPE: "missing-content-type", + PATH_RELATIVE_STYLESHEET_IMPORT: "path-relative-stylesheet-import", OPEN_REDIRECT: "open-redirect", PATH_TRAVERSAL: "path-traversal", PHPINFO: "phpinfo", @@ -99,6 +101,7 @@ export const checks = [ hashDisclosureScan, jsonHtmlResponseScan, missingContentTypeScan, + pathRelativeStylesheetImportScan, openRedirectScan, pathTraversalScan, phpinfoScan, diff --git a/packages/backend/src/checks/path-relative-stylesheet-import/index.spec.ts b/packages/backend/src/checks/path-relative-stylesheet-import/index.spec.ts new file mode 100644 index 0000000..10b50bb --- /dev/null +++ b/packages/backend/src/checks/path-relative-stylesheet-import/index.spec.ts @@ -0,0 +1,65 @@ +import { createMockRequest, createMockResponse, runCheck } from "engine"; +import { describe, expect, it } from "vitest"; + +import pathRelativeStylesheetCheck from "./index"; + +const executeCheck = async (body: string): Promise => { + const request = createMockRequest({ + id: "req-path-css", + host: "example.com", + method: "GET", + path: "/app/page", + headers: { Host: ["example.com"] }, + }); + + const response = createMockResponse({ + id: "res-path-css", + code: 200, + headers: { "content-type": ["text/html"] }, + body, + }); + + const execution = await runCheck(pathRelativeStylesheetCheck, [ + { request, response }, + ]); + + return execution[0]?.steps[execution[0].steps.length - 1]?.findings ?? []; +}; + +describe("Path-relative stylesheet import check", () => { + it("flags link elements with path-relative href", async () => { + const findings = await executeCheck( + '', + ); + + expect(findings).toHaveLength(1); + expect(findings[0]).toMatchObject({ + name: "Path-relative stylesheet import", + severity: "low", + }); + }); + + it("flags @import rules with relative paths", async () => { + const findings = await executeCheck( + '', + ); + + expect(findings).toHaveLength(1); + }); + + it("does not flag absolute href values", async () => { + const findings = await executeCheck( + '', + ); + + expect(findings).toHaveLength(0); + }); + + it("does not flag absolute import values", async () => { + const findings = await executeCheck( + '', + ); + + expect(findings).toHaveLength(0); + }); +}); diff --git a/packages/backend/src/checks/path-relative-stylesheet-import/index.ts b/packages/backend/src/checks/path-relative-stylesheet-import/index.ts new file mode 100644 index 0000000..289a426 --- /dev/null +++ b/packages/backend/src/checks/path-relative-stylesheet-import/index.ts @@ -0,0 +1,129 @@ +import { defineCheck, done, Severity } from "engine"; + +import { Tags } from "../../types"; +import { keyStrategy } from "../../utils/key"; + +type FindingEntry = { + type: "link" | "import"; + value: string; +}; + +const LINK_STYLESHEET_REGEX = /]*rel=["']?stylesheet["']?[^>]*>/gi; +const HREF_REGEX = /href=(["'])([^"']+)\1/i; +const IMPORT_REGEX = /@import\s+(?:url\()?["']?([^"')\s]+)["']?(?:\))?/gi; + +const isRelativePath = (value: string): boolean => { + const trimmed = value.trim().toLowerCase(); + + if ( + trimmed.startsWith("http://") || + trimmed.startsWith("https://") || + trimmed.startsWith("//") || + trimmed.startsWith("/") || + trimmed.startsWith("data:") || + trimmed.startsWith("javascript:") || + trimmed.startsWith("#") + ) { + return false; + } + + return true; +}; + +const collectFindings = (body: string): FindingEntry[] => { + const findings: FindingEntry[] = []; + + for (const tag of body.matchAll(LINK_STYLESHEET_REGEX)) { + const element = tag[0]; + if (element === undefined) { + continue; + } + + const hrefMatch = element.match(HREF_REGEX); + const hrefValue = hrefMatch?.[2]; + if (hrefValue !== undefined && isRelativePath(hrefValue)) { + findings.push({ type: "link", value: hrefValue }); + } + } + + for (const match of body.matchAll(IMPORT_REGEX)) { + const importValue = match[1]; + if (importValue !== undefined && isRelativePath(importValue)) { + findings.push({ type: "import", value: importValue }); + } + } + + return findings; +}; + +const buildDescription = (entries: FindingEntry[]): string => { + const details = entries + .map((entry) => { + const description = + entry.type === "link" ? "link href attribute" : "@import rule"; + return `- Relative path \`${entry.value}\` used in ${description}.`; + }) + .join("\n"); + + return [ + "The response references a stylesheet using a path-relative URL.", + "", + details, + "", + "Path-relative imports are prone to being resolved against attacker-controlled paths (for example when serving content from nested routes or via user-supplied directories). Use absolute paths or fully qualified URLs for stylesheet references.", + ].join("\n"); +}; + +export default defineCheck>(({ step }) => { + step("detectRelativeStylesheets", (state, context) => { + const { response } = context.target; + + if (response === undefined) { + return done({ state }); + } + + const body = response.getBody()?.toText(); + if (body === undefined || body.length === 0) { + return done({ state }); + } + + const findings = collectFindings(body); + if (findings.length === 0) { + return done({ state }); + } + + return done({ + state, + findings: [ + { + name: "Path-relative stylesheet import", + description: buildDescription(findings), + severity: Severity.LOW, + correlation: { + requestID: context.target.request.getId(), + locations: [], + }, + }, + ], + }); + }); + + return { + metadata: { + id: "path-relative-stylesheet-import", + name: "Path-relative stylesheet import", + description: + "Detects stylesheet references that rely on path-relative URLs, which can break isolation boundaries when directories are attacker-controlled.", + type: "passive", + tags: [Tags.INFORMATION_DISCLOSURE], + severities: [Severity.LOW], + aggressivity: { + minRequests: 0, + maxRequests: 0, + }, + }, + initState: () => ({}), + dedupeKey: keyStrategy().withHost().withPath().build(), + when: (target) => target.response !== undefined, + }; +}); diff --git a/packages/backend/src/stores/config.ts b/packages/backend/src/stores/config.ts index 6b716b7..ee0af1e 100644 --- a/packages/backend/src/stores/config.ts +++ b/packages/backend/src/stores/config.ts @@ -188,6 +188,10 @@ export class ConfigStore { checkID: Checks.MISSING_CONTENT_TYPE, enabled: true, }, + { + checkID: Checks.PATH_RELATIVE_STYLESHEET_IMPORT, + enabled: true, + }, ], }, {