173 lines
6.3 KiB
TypeScript
173 lines
6.3 KiB
TypeScript
import type { DevToolbarRule } from "./types.ts";
|
|
import { createIssue } from "./helpers.ts";
|
|
|
|
const SECRET_KEY = /pass(word)?|token|secret|api[-_]?key|authorization|session/i;
|
|
|
|
export const securityRules: DevToolbarRule[] = [
|
|
{
|
|
id: "security/page",
|
|
category: "security",
|
|
defaultSeverity: "warning",
|
|
description: "Checks development-visible security mistakes.",
|
|
run: ({ document, window, url, root }) => {
|
|
const issues = [];
|
|
for (const [key] of url.searchParams) {
|
|
if (SECRET_KEY.test(key)) {
|
|
issues.push(
|
|
createIssue({
|
|
ruleId: "security/secret-query",
|
|
category: "security",
|
|
severity: "error",
|
|
title: "Potential secret appears in URL",
|
|
message: `The query parameter “${key}” may contain sensitive information.`,
|
|
recommendation:
|
|
"Send secrets in a secure request body or authorization header, not a URL.",
|
|
}),
|
|
);
|
|
}
|
|
}
|
|
|
|
for (const form of root.querySelectorAll<HTMLFormElement>('form[action^="http://"]')) {
|
|
issues.push(
|
|
createIssue({
|
|
ruleId: "security/insecure-form",
|
|
category: "security",
|
|
severity: "error",
|
|
title: "Form submits over HTTP",
|
|
message: "Form values may be transmitted without transport encryption.",
|
|
element: form,
|
|
recommendation: "Submit to an HTTPS endpoint.",
|
|
}),
|
|
);
|
|
}
|
|
|
|
for (const form of root.querySelectorAll<HTMLFormElement>("form")) {
|
|
const method = (form.method || "get").toUpperCase();
|
|
if (
|
|
!["GET", "HEAD"].includes(method) &&
|
|
!form.querySelector('input[name="wire-csrf"], input[name="_csrf"]')
|
|
) {
|
|
issues.push(
|
|
createIssue({
|
|
ruleId: "security/missing-csrf",
|
|
category: "security",
|
|
severity: "error",
|
|
title: "State-changing form has no CSRF token",
|
|
message: `${method} form does not contain a recognized CSRF field.`,
|
|
element: form,
|
|
recommendation: "Enable WRNexus CSRF middleware and use the generated form token.",
|
|
}),
|
|
);
|
|
}
|
|
}
|
|
|
|
for (const script of root.querySelectorAll<HTMLScriptElement>("script:not([src])")) {
|
|
if (script.type === "application/json" || script.hasAttribute("nonce")) continue;
|
|
issues.push(
|
|
createIssue({
|
|
ruleId: "security/inline-script",
|
|
category: "security",
|
|
severity: "warning",
|
|
title: "Inline script has no CSP nonce",
|
|
message: "A strict Content Security Policy will block this inline script.",
|
|
element: script,
|
|
recommendation: "Move code into a client module or attach the request CSP nonce.",
|
|
}),
|
|
);
|
|
}
|
|
|
|
for (const element of root.querySelectorAll<HTMLElement>("[style]")) {
|
|
issues.push(
|
|
createIssue({
|
|
ruleId: "security/inline-style",
|
|
category: "security",
|
|
severity: "suggestion",
|
|
title: "Inline style weakens strict CSP",
|
|
message: "The element uses a style attribute.",
|
|
element,
|
|
recommendation:
|
|
"Prefer extracted CSS classes or a nonce/hash-compatible style strategy.",
|
|
confidence: "medium",
|
|
}),
|
|
);
|
|
}
|
|
|
|
for (const element of root.querySelectorAll<HTMLElement>("[src], [href], [action]")) {
|
|
const raw =
|
|
element.getAttribute("src") ??
|
|
element.getAttribute("href") ??
|
|
element.getAttribute("action");
|
|
if (window.location.protocol === "https:" && raw?.startsWith("http://")) {
|
|
issues.push(
|
|
createIssue({
|
|
ruleId: "security/mixed-content",
|
|
category: "security",
|
|
severity: "error",
|
|
title: "Mixed-content resource",
|
|
message: `${raw} is loaded over insecure HTTP.`,
|
|
element,
|
|
recommendation: "Use HTTPS or serve the resource from the same secure origin.",
|
|
}),
|
|
);
|
|
}
|
|
}
|
|
|
|
for (const frame of root.querySelectorAll<HTMLIFrameElement>("iframe:not([sandbox])")) {
|
|
issues.push(
|
|
createIssue({
|
|
ruleId: "security/iframe-sandbox",
|
|
category: "security",
|
|
severity: "warning",
|
|
title: "Iframe is not sandboxed",
|
|
message: "Third-party or untrusted iframe content has broad browser capabilities.",
|
|
element: frame,
|
|
recommendation: "Add the narrowest possible sandbox and permissions policy.",
|
|
}),
|
|
);
|
|
}
|
|
|
|
try {
|
|
for (let index = 0; index < window.localStorage.length; index += 1) {
|
|
const key = window.localStorage.key(index) ?? "";
|
|
if (SECRET_KEY.test(key)) {
|
|
issues.push(
|
|
createIssue({
|
|
ruleId: "security/sensitive-local-storage",
|
|
category: "security",
|
|
severity: "error",
|
|
title: "Sensitive value may be stored in localStorage",
|
|
message: `Storage key “${key}” looks authentication- or secret-related.`,
|
|
recommendation: "Keep sessions and credentials in Secure, HttpOnly cookies.",
|
|
}),
|
|
);
|
|
}
|
|
}
|
|
} catch {
|
|
// Storage may be unavailable in restricted browser contexts.
|
|
}
|
|
|
|
const hydration = document.querySelectorAll<HTMLScriptElement>(
|
|
'script[type="application/json"][data-wrnexus-state], script[data-wrnexus-store-state]',
|
|
);
|
|
for (const script of hydration) {
|
|
if (SECRET_KEY.test(script.textContent ?? "")) {
|
|
issues.push(
|
|
createIssue({
|
|
ruleId: "security/hydration-secret",
|
|
category: "security",
|
|
severity: "error",
|
|
title: "Hydration payload may contain a secret",
|
|
message: "Sensitive-looking field names were found in serialized client state.",
|
|
element: script,
|
|
recommendation: "Move sensitive state to server-only state and regenerate the page.",
|
|
confidence: "medium",
|
|
}),
|
|
);
|
|
}
|
|
}
|
|
|
|
return issues;
|
|
},
|
|
},
|
|
];
|