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
3 changes: 3 additions & 0 deletions packages/backend/src/checks/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -99,6 +101,7 @@ export const checks = [
hashDisclosureScan,
jsonHtmlResponseScan,
missingContentTypeScan,
pathRelativeStylesheetImportScan,
openRedirectScan,
pathTraversalScan,
phpinfoScan,
Expand Down
Original file line number Diff line number Diff line change
@@ -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<unknown[]> => {
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(
'<html><head><link rel="stylesheet" href="css/main.css"></head></html>',
);

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(
'<style>@import url("styles/theme.css");</style>',
);

expect(findings).toHaveLength(1);
});

it("does not flag absolute href values", async () => {
const findings = await executeCheck(
'<html><head><link rel="stylesheet" href="/static/app.css"></head></html>',
);

expect(findings).toHaveLength(0);
});

it("does not flag absolute import values", async () => {
const findings = await executeCheck(
'<style>@import "https://cdn.example.com/styles.css";</style>',
);

expect(findings).toHaveLength(0);
});
});
129 changes: 129 additions & 0 deletions packages/backend/src/checks/path-relative-stylesheet-import/index.ts
Original file line number Diff line number Diff line change
@@ -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 = /<link\b[^>]*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<Record<never, never>>(({ 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,
};
});
4 changes: 4 additions & 0 deletions packages/backend/src/stores/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -188,6 +188,10 @@ export class ConfigStore {
checkID: Checks.MISSING_CONTENT_TYPE,
enabled: true,
},
{
checkID: Checks.PATH_RELATIVE_STYLESHEET_IMPORT,
enabled: true,
},
],
},
{
Expand Down