release: WRNexusJS 0.7.0
This commit is contained in:
@@ -32,6 +32,14 @@ export interface DiagnoseOptions {
|
||||
accessibility?: boolean;
|
||||
}
|
||||
|
||||
function stripAsciiControlAndSpace(value: string): string {
|
||||
let result = "";
|
||||
for (const character of value) {
|
||||
if (character.charCodeAt(0) > 0x20) result += character;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function maskJavaScriptTrivia(source: string): string {
|
||||
let result = "";
|
||||
let index = 0;
|
||||
@@ -222,19 +230,76 @@ function astDiagnostics(ast: PageAst, options: DiagnoseOptions): WrnDiagnostic[]
|
||||
}
|
||||
|
||||
let interactive = ast.states.length > 0 || ast.effects.length > 0 || ast.watches.length > 0;
|
||||
const urlAttributes = new Set([
|
||||
"href",
|
||||
"src",
|
||||
"action",
|
||||
"formaction",
|
||||
"poster",
|
||||
"cite",
|
||||
"background",
|
||||
"xlink:href",
|
||||
]);
|
||||
walk(ast.view, (node) => {
|
||||
if (node.type === "element" && node.attrs.some((attribute) => attribute.event))
|
||||
interactive = true;
|
||||
if (!options.accessibility || node.type !== "element") return;
|
||||
if (node.type !== "element") return;
|
||||
if (node.attrs.some((attribute) => attribute.event)) interactive = true;
|
||||
const tag = node.tag.toLowerCase();
|
||||
if (tag === "img" && !node.attrs.some((attribute) => attribute.name === "alt")) {
|
||||
diagnostics.push({
|
||||
code: WRN_DIAGNOSTIC_CODES.accessibility,
|
||||
severity: "warning",
|
||||
message: "Image is missing an alt attribute.",
|
||||
hint: 'Add alt text, or alt="" for a decorative image.',
|
||||
file: options.file,
|
||||
});
|
||||
|
||||
for (const attribute of node.attrs) {
|
||||
if (attribute.event || attribute.boolean || !urlAttributes.has(attribute.name.toLowerCase()))
|
||||
continue;
|
||||
if (attribute.value.includes("{")) continue;
|
||||
const value = stripAsciiControlAndSpace(attribute.value.trim()).toLowerCase();
|
||||
if (
|
||||
/^(?:javascript|vbscript|file):/.test(value) ||
|
||||
/^data:(?!image\/(?:png|gif|jpeg|webp|avif);)/.test(value)
|
||||
) {
|
||||
diagnostics.push({
|
||||
code: "WRN-SEC-UNSAFE-URL",
|
||||
severity: "error",
|
||||
message: `Unsafe URL protocol in ${attribute.name} on <${node.tag}>.`,
|
||||
hint: "Use a relative URL, https:, mailto:, tel:, or a framework-validated URL helper.",
|
||||
file: options.file,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (tag === "a") {
|
||||
const target = node.attrs.find((attribute) => attribute.name === "target")?.value;
|
||||
const rel = node.attrs.find((attribute) => attribute.name === "rel")?.value ?? "";
|
||||
if (target === "_blank" && !/\bnoopener\b/i.test(rel)) {
|
||||
diagnostics.push({
|
||||
code: "WRN-SEC-BLANK-REL",
|
||||
severity: "warning",
|
||||
message: "A target=_blank link should include rel=noopener.",
|
||||
hint: 'Add rel="noopener noreferrer".',
|
||||
file: options.file,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (!options.accessibility) return;
|
||||
if (tag === "img") {
|
||||
if (!node.attrs.some((attribute) => attribute.name === "alt")) {
|
||||
diagnostics.push({
|
||||
code: WRN_DIAGNOSTIC_CODES.accessibility,
|
||||
severity: "warning",
|
||||
message: "Image is missing an alt attribute.",
|
||||
hint: 'Add alt text, or alt="" for a decorative image.',
|
||||
file: options.file,
|
||||
});
|
||||
}
|
||||
const hasWidth = node.attrs.some((attribute) => attribute.name === "width");
|
||||
const hasHeight = node.attrs.some((attribute) => attribute.name === "height");
|
||||
if (!hasWidth || !hasHeight) {
|
||||
diagnostics.push({
|
||||
code: "WRN-PERF-IMAGE-DIMENSIONS",
|
||||
severity: "warning",
|
||||
message: "Image width and height are required to prevent layout shifts.",
|
||||
hint: "Declare intrinsic width and height, or use @wrnexus/image.",
|
||||
file: options.file,
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
if (ast.runtime === "server" && interactive) {
|
||||
@@ -291,6 +356,29 @@ function astDiagnostics(ast: PageAst, options: DiagnoseOptions): WrnDiagnostic[]
|
||||
file: options.file,
|
||||
});
|
||||
}
|
||||
if (
|
||||
fn.runtime !== "server" &&
|
||||
/\b(?:eval\s*\(|new\s+Function\s*\(|document\.write\s*\(|\.innerHTML\s*=|\.outerHTML\s*=|insertAdjacentHTML\s*\()/.test(
|
||||
fn.body,
|
||||
)
|
||||
) {
|
||||
diagnostics.push({
|
||||
code: "WRN-SEC-DOM-SINK",
|
||||
severity: "error",
|
||||
message: `Client function '${fn.name}' uses an unsafe dynamic-code or HTML sink.`,
|
||||
hint: "Use compiled templates, textContent, typed outputs, or a reviewed TrustedHTML sanitizer.",
|
||||
file: options.file,
|
||||
});
|
||||
}
|
||||
if (fn.runtime !== "server" && /\b(?:setTimeout|setInterval)\s*\(\s*["'`]/.test(fn.body)) {
|
||||
diagnostics.push({
|
||||
code: "WRN-SEC-STRING-TIMER",
|
||||
severity: "error",
|
||||
message: `Client function '${fn.name}' passes a string to a timer.`,
|
||||
hint: "Pass a function instead of executable text.",
|
||||
file: options.file,
|
||||
});
|
||||
}
|
||||
const parameterNames = new Set(fn.parameters.map((parameter) => parameter.name));
|
||||
for (const prop of ast.props) {
|
||||
if (containsReadonlyPropMutation(fn.body, prop.name, parameterNames)) {
|
||||
@@ -317,6 +405,18 @@ function astDiagnostics(ast: PageAst, options: DiagnoseOptions): WrnDiagnostic[]
|
||||
file: options.file,
|
||||
});
|
||||
}
|
||||
if (
|
||||
state.runtime !== "server" &&
|
||||
/\b(?:process\.env|Bun\.env|Deno\.env|ctx\.env|import\.meta\.env)\b/.test(state.expr)
|
||||
) {
|
||||
diagnostics.push({
|
||||
code: "WRN-SEC-SERVER-SECRET-SOURCE",
|
||||
severity: "error",
|
||||
message: `Browser-visible state '${state.name}' reads from a server environment source.`,
|
||||
hint: "Move environment-backed values into server state and return only an explicitly safe result.",
|
||||
file: options.file,
|
||||
});
|
||||
}
|
||||
}
|
||||
if (ast.persist) {
|
||||
const stateNames = new Set(
|
||||
|
||||
Reference in New Issue
Block a user