Release Network Tools 0.2.0
Verify / verify (push) Canceled after 0s

This commit is contained in:
2026-09-02 11:38:57 +02:00
parent 729430295b
commit 429b55d587
30 changed files with 1474 additions and 96 deletions
+190 -12
View File
@@ -1,14 +1,21 @@
import { useMemo, useState } from "react";
import { parseCidr } from "@add-ideas/toolbox-helpers";
import { buildDnsRecord, type DnsRecordType } from "../network/dns";
import {
buildDnsRecord,
inspectDnsZone,
type DnsRecordType,
} from "../network/dns";
import { buildCsp, inspectHeaders } from "../network/http";
import { lookupMime } from "../network/mime";
import { inspectUrl } from "../network/url";
import { parseVlsmRequirements, planVlsm } from "../network/vlsm";
const tabs = [
["cidr", "IP & CIDR"],
["vlsm", "VLSM planner"],
["url", "URL"],
["dns", "DNS"],
["zone", "Zone file"],
["http", "HTTP & CSP"],
["mime", "MIME types"],
] as const;
@@ -191,6 +198,101 @@ function UrlWorkspace() {
);
}
function VlsmWorkspace() {
const [base, setBase] = useState("10.20.0.0/22");
const [requirements, setRequirements] = useState(
"Office,300\nLab,120\nGuest Wi-Fi,50\nInfrastructure,20",
);
const plan = useMemo(
() => attempt(() => planVlsm(base, parseVlsmRequirements(requirements))),
[base, requirements],
);
return (
<section className="workspace" aria-labelledby="vlsm-heading">
<div>
<h2 id="vlsm-heading">IPv4 VLSM planner</h2>
<p className="muted">
Allocate largest requirements first inside one base network. No router
or IP address is contacted.
</p>
</div>
<label className="field">
<span>Base IPv4 network</span>
<input
value={base}
onChange={(event) => setBase(event.target.value)}
spellCheck={false}
/>
</label>
<label className="field">
<span>Requirements one name,hosts pair per line</span>
<textarea
value={requirements}
onChange={(event) => setRequirements(event.target.value)}
spellCheck={false}
/>
</label>
{plan.error ? (
<p className="error" role="alert">
{plan.error}
</p>
) : (
plan.value && (
<>
<dl className="facts">
<div>
<dt>Base</dt>
<dd>{plan.value.base}</dd>
</div>
<div>
<dt>Allocated addresses</dt>
<dd>{plan.value.usedAddresses.toLocaleString()}</dd>
</div>
<div>
<dt>Unallocated addresses</dt>
<dd>{plan.value.freeAddresses.toLocaleString()}</dd>
</div>
</dl>
<div className="table-scroll" tabIndex={0}>
<table>
<thead>
<tr>
<th>Requirement</th>
<th>Network</th>
<th>Usable range</th>
<th>Broadcast</th>
<th>Capacity</th>
</tr>
</thead>
<tbody>
{plan.value.allocations.map((allocation) => (
<tr key={`${allocation.requestedIndex}-${allocation.name}`}>
<td>
{allocation.name} ({allocation.hosts})
</td>
<td>{allocation.network}</td>
<td>
{allocation.firstUsable} {allocation.lastUsable}
</td>
<td>{allocation.broadcast}</td>
<td>{allocation.usableHosts}</td>
</tr>
))}
</tbody>
</table>
</div>
<ul className="compact-list muted">
{plan.value.warnings.map((warning) => (
<li key={warning}>{warning}</li>
))}
</ul>
</>
)
)}
</section>
);
}
function DnsWorkspace() {
const [owner, setOwner] = useState("www.example.com.");
const [ttl, setTtl] = useState(3600);
@@ -348,10 +450,15 @@ function HttpWorkspace() {
</p>
) : (
parsed.value && (
<ul className="compact-list">
{parsed.value.findings.length ? (
parsed.value.findings.map((finding) => (
<li key={finding}>{finding}</li>
<ul className="compact-list findings-list">
{parsed.value.securityFindings.length ? (
parsed.value.securityFindings.map((finding, index) => (
<li
className={`finding-${finding.severity}`}
key={`${finding.code}-${index}`}
>
<strong>{finding.severity}</strong> {finding.message}
</li>
))
) : (
<li>No baseline header gaps detected.</li>
@@ -399,6 +506,80 @@ function HttpWorkspace() {
);
}
function ZoneWorkspace() {
const [source, setSource] = useState(
"$ORIGIN example.test.\n$TTL 1h\n@ IN SOA ns1 hostmaster 2026090101 1h 15m 1w 5m\n@ IN NS ns1\nns1 IN A 192.0.2.53\nwww 300 IN A 192.0.2.80\n IN AAAA 2001:db8::80\n@ IN MX 10 mail",
);
const inspection = useMemo(
() => attempt(() => inspectDnsZone(source)),
[source],
);
return (
<section className="workspace" aria-labelledby="zone-heading">
<div>
<h2 id="zone-heading">Inert DNS zone inspector</h2>
<p className="muted">
Parse bounded zone text, directives and common record data without
resolving names, following $INCLUDE, or changing DNS.
</p>
</div>
<label className="field">
<span>Zone text</span>
<textarea
value={source}
onChange={(event) => setSource(event.target.value)}
spellCheck={false}
/>
</label>
{inspection.error ? (
<p className="error" role="alert">
{inspection.error}
</p>
) : (
inspection.value && (
<>
<dl className="facts">
<div>
<dt>Origin</dt>
<dd>{inspection.value.origin ?? "Not declared"}</dd>
</div>
<div>
<dt>Default TTL</dt>
<dd>{inspection.value.defaultTtl ?? "Not declared"}</dd>
</div>
<div>
<dt>Validated records</dt>
<dd>{inspection.value.coverage.validatedRecords}</dd>
</div>
<div>
<dt>Syntax-only records</dt>
<dd>{inspection.value.coverage.syntaxOnlyRecords}</dd>
</div>
</dl>
{inspection.value.diagnostics.length > 0 && (
<ul className="compact-list findings-list">
{inspection.value.diagnostics.map((diagnostic, index) => (
<li
className={`finding-${diagnostic.severity}`}
key={`${diagnostic.line}-${index}`}
>
Line {diagnostic.line}: {diagnostic.message}
</li>
))}
</ul>
)}
<pre>
{inspection.value.records
.map((record) => record.normalized)
.join("\n") || "No valid records."}
</pre>
</>
)
)}
</section>
);
}
function MimeWorkspace() {
const [query, setQuery] = useState("");
const matches = useMemo(() => lookupMime(query), [query]);
@@ -460,17 +641,12 @@ export function Workbench() {
</div>
<span className="privacy-pill">No queries sent</span>
</header>
<nav
className="workspace-tabs panel"
aria-label="Network workspaces"
role="tablist"
>
<nav className="workspace-tabs panel" aria-label="Network workspaces">
{tabs.map(([key, label]) => (
<button
key={key}
type="button"
role="tab"
aria-selected={tab === key}
aria-pressed={tab === key}
onClick={() => choose(key)}
>
{label}
@@ -478,8 +654,10 @@ export function Workbench() {
))}
</nav>
{tab === "cidr" && <CidrWorkspace />}
{tab === "vlsm" && <VlsmWorkspace />}
{tab === "url" && <UrlWorkspace />}
{tab === "dns" && <DnsWorkspace />}
{tab === "zone" && <ZoneWorkspace />}
{tab === "http" && <HttpWorkspace />}
{tab === "mime" && <MimeWorkspace />}
</main>
+446 -4
View File
@@ -27,10 +27,13 @@ function integer(
return value!;
}
function domain(value: string, name: string): string {
function domain(value: string, name: string, allowWildcard = false): string {
const candidate = value.trim();
if (candidate === "@") return candidate;
if (!candidate || candidate.length > 253 || !LABEL.test(candidate))
if (candidate === "@" || candidate === ".") return candidate;
const wildcard = allowWildcard && candidate.startsWith("*.");
const checked = wildcard ? candidate.slice(2) : candidate;
const maximumLength = candidate.endsWith(".") ? 254 : 253;
if (!checked || candidate.length > maximumLength || !LABEL.test(checked))
throw new Error(`${name} must be a valid ASCII DNS name.`);
return candidate;
}
@@ -43,7 +46,7 @@ function quoteTxt(value: string): string {
}
export function buildDnsRecord(input: DnsRecordInput): string {
const owner = domain(input.owner, "Owner");
const owner = domain(input.owner, "Owner", true);
const ttl = integer(input.ttl, "TTL", 2_147_483_647);
const value = input.value.trim();
if (!value) throw new Error("Record value is required.");
@@ -84,3 +87,442 @@ export function buildDnsRecord(input: DnsRecordInput): string {
}
return `${owner} ${ttl} IN ${input.type} ${data}`;
}
const MAX_ZONE_CHARS = 2 * 1024 * 1024;
const MAX_ZONE_LINES = 50_000;
const MAX_ZONE_RECORDS = 20_000;
const MAX_LOGICAL_LINE = 64 * 1024;
const KNOWN_ZONE_TYPES = new Set([
"A",
"AAAA",
"CAA",
"CNAME",
"MX",
"NS",
"PTR",
"SOA",
"SRV",
"TXT",
]);
export interface ZoneDiagnostic {
line: number;
severity: "warning" | "error";
message: string;
}
export interface ZoneRecord {
line: number;
owner: string;
ttl?: number;
dnsClass: string;
type: string;
data: string[];
normalized: string;
validation: "validated" | "syntax-only";
}
export interface ZoneInspection {
origin?: string;
defaultTtl?: number;
records: ZoneRecord[];
diagnostics: ZoneDiagnostic[];
coverage: {
logicalLines: number;
validatedRecords: number;
syntaxOnlyRecords: number;
};
}
interface LogicalZoneLine {
line: number;
text: string;
ownerOmitted: boolean;
}
function stripZoneComment(line: string): string {
let quoted = false;
let escaped = false;
for (let index = 0; index < line.length; index += 1) {
const character = line[index]!;
if (escaped) {
escaped = false;
continue;
}
if (character === "\\") {
escaped = true;
continue;
}
if (character === '"') quoted = !quoted;
if (character === ";" && !quoted) return line.slice(0, index);
}
return line;
}
function logicalZoneLines(source: string): LogicalZoneLine[] {
if (source.length > MAX_ZONE_CHARS)
throw new Error("Zone text exceeds the 2 MiB limit.");
const physical = source
.replaceAll("\r\n", "\n")
.replaceAll("\r", "\n")
.split("\n");
if (physical.length > MAX_ZONE_LINES)
throw new Error(
`Zone text exceeds ${MAX_ZONE_LINES.toLocaleString()} physical lines.`,
);
const result: LogicalZoneLine[] = [];
let buffer = "";
let start = 1;
let ownerOmitted = false;
let depth = 0;
let quoted = false;
let escaped = false;
for (const [index, raw] of physical.entries()) {
const withoutComment = stripZoneComment(raw);
if (!buffer) {
start = index + 1;
ownerOmitted = /^[ \t]/u.test(raw);
}
let segment = "";
for (const character of withoutComment) {
if (escaped) {
segment += character;
escaped = false;
continue;
}
if (character === "\\") {
segment += character;
escaped = true;
continue;
}
if (character === '"') quoted = !quoted;
if (!quoted && character === "(") {
depth += 1;
if (depth > 1)
throw new Error(
`Line ${index + 1}: nested parentheses are not supported.`,
);
segment += " ";
} else if (!quoted && character === ")") {
depth -= 1;
if (depth < 0)
throw new Error(`Line ${index + 1}: unmatched closing parenthesis.`);
segment += " ";
} else segment += character;
}
buffer += `${buffer ? " " : ""}${segment.trim()}`;
if (buffer.length > MAX_LOGICAL_LINE)
throw new Error(`Line ${start}: logical record exceeds 64 KiB.`);
if (depth === 0 && !quoted) {
if (buffer.trim())
result.push({ line: start, text: buffer.trim(), ownerOmitted });
buffer = "";
escaped = false;
}
}
if (quoted) throw new Error(`Line ${start}: unterminated quoted string.`);
if (depth !== 0)
throw new Error(`Line ${start}: unterminated parenthesized record.`);
return result;
}
function zoneTokens(text: string, line: number): string[] {
const tokens: string[] = [];
let token = "";
let quoted = false;
let escaped = false;
let active = false;
for (const character of text) {
if (escaped) {
token += character;
escaped = false;
active = true;
} else if (character === "\\") {
escaped = true;
active = true;
} else if (character === '"') {
quoted = !quoted;
active = true;
} else if (/\s/u.test(character) && !quoted) {
if (active) tokens.push(token);
token = "";
active = false;
} else {
token += character;
active = true;
}
}
if (escaped || quoted)
throw new Error(`Line ${line}: malformed quoted or escaped value.`);
if (active) tokens.push(token);
if (tokens.length > 512) throw new Error(`Line ${line}: too many fields.`);
return tokens;
}
function ttlValue(text: string, label: string): number {
if (!/^(?:\d+[WDHMSwdhms]?)+$/u.test(text))
throw new Error(`${label} must be seconds or a sequence such as 1h30m.`);
let total = 0;
for (const match of text.matchAll(/(\d+)([WDHMSwdhms]?)/gu)) {
const multiplier =
match[2]?.toLowerCase() === "w"
? 604_800
: match[2]?.toLowerCase() === "d"
? 86_400
: match[2]?.toLowerCase() === "h"
? 3_600
: match[2]?.toLowerCase() === "m"
? 60
: 1;
total += Number(match[1]) * multiplier;
}
if (!Number.isSafeInteger(total) || total > 2_147_483_647)
throw new Error(`${label} exceeds 2,147,483,647 seconds.`);
return total;
}
function absoluteZoneName(value: string, origin?: string): string {
if (value === "@") {
if (!origin) throw new Error("@ requires a preceding $ORIGIN directive.");
return origin;
}
domain(value, "DNS name");
if (value.endsWith(".") || !origin) return value;
const absolute = origin === "." ? `${value}.` : `${value}.${origin}`;
domain(absolute, "Absolute DNS name");
return absolute;
}
function absoluteZoneOwner(value: string, origin?: string): string {
if (value === "@") return absoluteZoneName(value, origin);
domain(value, "DNS owner", true);
if (value.endsWith(".") || !origin) return value;
const absolute = origin === "." ? `${value}.` : `${value}.${origin}`;
domain(absolute, "Absolute DNS owner", true);
return absolute;
}
function normalizedSyntaxToken(value: string): string {
return /[\s;()"\\]/u.test(value)
? `"${value.replaceAll("\\", "\\\\").replaceAll('"', '\\"')}"`
: value;
}
function zoneUnsigned(
value: string,
name: string,
maximum = 4_294_967_295,
): number {
if (!/^\d+$/u.test(value))
throw new Error(`${name} must be an unsigned integer.`);
const parsed = Number(value);
if (!Number.isSafeInteger(parsed) || parsed > maximum)
throw new Error(`${name} must be no greater than ${maximum}.`);
return parsed;
}
function validateZoneData(
type: string,
values: string[],
origin?: string,
): string[] {
switch (type) {
case "A":
if (values.length !== 1)
throw new Error("A requires exactly one address.");
return [parseIpv4(values[0]!).canonical];
case "AAAA":
if (values.length !== 1)
throw new Error("AAAA requires exactly one address.");
return [parseIpv6(values[0]!).canonical];
case "CNAME":
case "NS":
case "PTR":
if (values.length !== 1)
throw new Error(`${type} requires exactly one target.`);
return [absoluteZoneName(values[0]!, origin)];
case "MX":
if (values.length !== 2)
throw new Error("MX requires preference and target.");
return [
String(zoneUnsigned(values[0]!, "MX preference", 65_535)),
absoluteZoneName(values[1]!, origin),
];
case "SRV":
if (values.length !== 4)
throw new Error("SRV requires priority, weight, port and target.");
return [
String(zoneUnsigned(values[0]!, "SRV priority", 65_535)),
String(zoneUnsigned(values[1]!, "SRV weight", 65_535)),
String(zoneUnsigned(values[2]!, "SRV port", 65_535)),
values[3] === "." ? "." : absoluteZoneName(values[3]!, origin),
];
case "CAA":
if (values.length !== 3)
throw new Error("CAA requires flags, tag and value.");
if (!["issue", "issuewild", "iodef"].includes(values[1]!))
throw new Error("CAA tag must be issue, issuewild or iodef.");
return [
String(zoneUnsigned(values[0]!, "CAA flags", 255)),
values[1]!,
quoteTxt(values[2]!),
];
case "TXT":
if (values.length < 1)
throw new Error("TXT requires at least one character-string.");
return values.map(quoteTxt);
case "SOA":
if (values.length !== 7)
throw new Error(
"SOA requires MNAME, RNAME, serial, refresh, retry, expire and minimum.",
);
return [
absoluteZoneName(values[0]!, origin),
absoluteZoneName(values[1]!, origin),
String(zoneUnsigned(values[2]!, "SOA serial")),
...values
.slice(3)
.map((value, index) =>
String(
ttlValue(
value,
["SOA refresh", "SOA retry", "SOA expire", "SOA minimum"][
index
]!,
),
),
),
];
default:
return values;
}
}
export function inspectDnsZone(source: string): ZoneInspection {
const lines = logicalZoneLines(source);
const records: ZoneRecord[] = [];
const diagnostics: ZoneDiagnostic[] = [];
let origin: string | undefined;
let defaultTtl: number | undefined;
let previousOwner: string | undefined;
for (const logical of lines) {
try {
const tokens = zoneTokens(logical.text, logical.line);
const directive = tokens[0]?.toUpperCase();
if (directive?.startsWith("$")) {
if (directive === "$ORIGIN") {
if (tokens.length !== 2)
throw new Error("$ORIGIN requires one DNS name.");
const next = domain(tokens[1]!, "Origin");
origin = next.endsWith(".") ? next : `${next}.`;
} else if (directive === "$TTL") {
if (tokens.length !== 2)
throw new Error("$TTL requires one duration.");
defaultTtl = ttlValue(tokens[1]!, "$TTL");
} else {
throw new Error(
`${directive} is intentionally unsupported; external includes and generated records are never followed.`,
);
}
continue;
}
if (records.length >= MAX_ZONE_RECORDS)
throw new Error(
`Zone exceeds ${MAX_ZONE_RECORDS.toLocaleString()} records.`,
);
let cursor = 0;
let owner: string;
if (logical.ownerOmitted) {
if (!previousOwner)
throw new Error(
"An omitted owner has no previous record to inherit.",
);
owner = previousOwner;
} else {
owner = absoluteZoneOwner(tokens[cursor++] ?? "", origin);
}
let ttl: number | undefined;
let dnsClass = "IN";
let type = "";
while (cursor < tokens.length) {
const token = tokens[cursor]!;
const upper = token.toUpperCase();
if (/^(?:\d+[WDHMSwdhms]?)+$/u.test(token) && ttl === undefined)
ttl = ttlValue(token, "TTL");
else if (["IN", "CH", "HS"].includes(upper)) dnsClass = upper;
else {
type = upper;
cursor += 1;
break;
}
cursor += 1;
}
if (!type || !/^(?:[A-Z][A-Z0-9-]{0,31}|TYPE\d{1,5})$/u.test(type))
throw new Error("Record type is missing or invalid.");
if (type.startsWith("TYPE") && Number(type.slice(4)) > 65_535)
throw new Error("Generic TYPE code must be from 0 to 65535.");
const rawData = tokens.slice(cursor);
if (rawData.length === 0)
throw new Error(`${type} record data is missing.`);
const known = KNOWN_ZONE_TYPES.has(type);
if (!known)
diagnostics.push({
line: logical.line,
severity: "warning",
message: `${type} was retained as syntax-only data; its RDATA was not semantically validated.`,
});
if (dnsClass !== "IN")
diagnostics.push({
line: logical.line,
severity: "warning",
message: `${dnsClass} class was parsed but only IN record semantics are validated.`,
});
const semanticallyValidated = known && dnsClass === "IN";
const data = semanticallyValidated
? validateZoneData(type, rawData, origin)
: rawData;
const normalizedData = semanticallyValidated
? data
: data.map((value) => normalizedSyntaxToken(value));
const effectiveTtl = ttl ?? defaultTtl;
records.push({
line: logical.line,
owner,
...(effectiveTtl === undefined ? {} : { ttl: effectiveTtl }),
dnsClass,
type,
data,
normalized: [owner, effectiveTtl, dnsClass, type, ...normalizedData]
.filter((value) => value !== undefined)
.join(" "),
validation: semanticallyValidated ? "validated" : "syntax-only",
});
previousOwner = owner;
} catch (error) {
diagnostics.push({
line: logical.line,
severity: "error",
message:
error instanceof Error
? error.message
: "Record could not be parsed.",
});
}
}
return {
...(origin ? { origin } : {}),
...(defaultTtl === undefined ? {} : { defaultTtl }),
records,
diagnostics,
coverage: {
logicalLines: lines.length,
validatedRecords: records.filter(
(record) => record.validation === "validated",
).length,
syntaxOnlyRecords: records.filter(
(record) => record.validation === "syntax-only",
).length,
},
};
}
+275 -10
View File
@@ -7,6 +7,25 @@ export interface HeaderInspection {
headers: HeaderEntry[];
duplicates: string[];
findings: string[];
securityFindings: SecurityHeaderFinding[];
csp?: CspInspection;
cspFields: CspInspection[];
}
export interface SecurityHeaderFinding {
severity: "info" | "warning" | "error";
code: string;
message: string;
}
export interface CspDirective {
name: string;
values: string[];
}
export interface CspInspection {
directives: CspDirective[];
findings: SecurityHeaderFinding[];
}
const TOKEN = /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/u;
@@ -42,7 +61,11 @@ export function inspectHeaders(source: string): HeaderInspection {
throw new Error(`Line ${index + 1}: invalid header name.`);
if (hasInvalidHeaderControl(value))
throw new Error(`Line ${index + 1}: invalid control character.`);
if (value.length > 64 * 1024)
throw new Error(`Line ${index + 1}: header value exceeds 64 KiB.`);
headers.push({ name, value });
if (headers.length > 2_048)
throw new Error("Header block contains more than 2,048 fields.");
}
const counts = new Map<string, number>();
@@ -54,18 +77,260 @@ export function inspectHeaders(source: string): HeaderInspection {
.filter(([, count]) => count > 1)
.map(([name]) => name);
const names = new Set(counts.keys());
const findings: string[] = [];
if (!names.has("content-security-policy"))
findings.push("No Content-Security-Policy header is present.");
if (!names.has("x-content-type-options"))
findings.push("Add X-Content-Type-Options: nosniff.");
if (!names.has("referrer-policy"))
findings.push("No Referrer-Policy header is present.");
const securityFindings: SecurityHeaderFinding[] = [];
const add = (
severity: SecurityHeaderFinding["severity"],
code: string,
message: string,
) => securityFindings.push({ severity, code, message });
const values = (name: string) =>
headers
.filter((header) => header.name.toLowerCase() === name)
.map((header) => header.value);
const first = (name: string) => values(name)[0];
const cspValues = values("content-security-policy");
if (cspValues.length === 0)
add(
"error",
"csp-missing",
"No Content-Security-Policy header is present.",
);
else if (cspValues.length > 1)
add(
"warning",
"csp-multiple",
"Multiple enforcing Content-Security-Policy fields intersect; inspect each deployed field explicitly.",
);
const cspFields = cspValues.map((policy, fieldIndex) => {
const inspected = inspectCsp(policy);
securityFindings.push(
...inspected.findings.map((finding) => ({
...finding,
message:
cspValues.length > 1
? `CSP field ${fieldIndex + 1}: ${finding.message}`
: finding.message,
})),
);
return inspected;
});
const csp = cspFields[0];
if (first("x-content-type-options")?.toLowerCase() !== "nosniff")
add("warning", "nosniff", "Add X-Content-Type-Options: nosniff.");
const referrer = first("referrer-policy")?.toLowerCase();
if (!referrer)
add("warning", "referrer-policy", "No Referrer-Policy header is present.");
else if (/unsafe-url|no-referrer-when-downgrade/u.test(referrer))
add(
"warning",
"referrer-policy-weak",
`Referrer-Policy “${referrer}” can disclose full paths cross-origin.`,
);
if (!names.has("permissions-policy"))
findings.push("No Permissions-Policy header is present.");
add(
"info",
"permissions-policy",
"No Permissions-Policy header is present.",
);
if (!names.has("strict-transport-security"))
add(
"warning",
"hsts-missing",
"No Strict-Transport-Security header is present (relevant on HTTPS responses).",
);
else {
const hsts = first("strict-transport-security")!;
const age = hsts.match(/(?:^|;)\s*max-age\s*=\s*(\d+)/iu);
if (!age)
add(
"error",
"hsts-invalid",
"Strict-Transport-Security has no valid max-age directive.",
);
else if (Number(age[1]) < 15_552_000)
add(
"warning",
"hsts-short",
"Strict-Transport-Security max-age is shorter than 180 days.",
);
}
if (headers.some((header) => header.name.toLowerCase() === "server"))
findings.push("The Server header may disclose implementation details.");
return { headers, duplicates, findings };
add(
"info",
"server-disclosure",
"The Server header may disclose implementation details.",
);
for (const cookie of values("set-cookie")) {
const cookieName = cookie.split("=", 1)[0]?.trim() || "(unnamed)";
if (!/;\s*Secure(?:;|$)/iu.test(cookie))
add(
"warning",
"cookie-secure",
`Cookie ${cookieName} does not declare Secure.`,
);
if (!/;\s*HttpOnly(?:;|$)/iu.test(cookie))
add(
"warning",
"cookie-http-only",
`Cookie ${cookieName} does not declare HttpOnly.`,
);
if (!/;\s*SameSite=(?:Strict|Lax|None)(?:;|$)/iu.test(cookie))
add(
"warning",
"cookie-samesite",
`Cookie ${cookieName} has no explicit valid SameSite attribute.`,
);
if (
/;\s*SameSite=None(?:;|$)/iu.test(cookie) &&
!/;\s*Secure(?:;|$)/iu.test(cookie)
)
add(
"error",
"cookie-none-insecure",
`Cookie ${cookieName} uses SameSite=None without Secure.`,
);
}
const allowOrigin = first("access-control-allow-origin");
const allowCredentials = first("access-control-allow-credentials");
if (allowOrigin === "*" && allowCredentials?.toLowerCase() === "true")
add(
"error",
"cors-wildcard-credentials",
"Wildcard Access-Control-Allow-Origin cannot be combined safely with credentials.",
);
else if (allowOrigin === "*")
add(
"info",
"cors-wildcard",
"Access-Control-Allow-Origin permits every origin; verify that the response is intentionally public.",
);
const frameAncestors = cspFields.some((field) =>
field.directives.some((directive) => directive.name === "frame-ancestors"),
);
if (!frameAncestors) {
const xFrameOptions = first("x-frame-options")?.trim().toUpperCase();
if (!xFrameOptions)
add(
"warning",
"framing",
"No frame-ancestors directive or X-Frame-Options fallback was found.",
);
else if (!["DENY", "SAMEORIGIN"].includes(xFrameOptions))
add(
"warning",
"x-frame-options-invalid",
"X-Frame-Options is present but is not the supported DENY or SAMEORIGIN value.",
);
}
if (!names.has("cross-origin-opener-policy"))
add(
"info",
"coop",
"Cross-Origin-Opener-Policy is not set; isolation-sensitive applications should review it.",
);
if (!names.has("cross-origin-resource-policy"))
add(
"info",
"corp",
"Cross-Origin-Resource-Policy is not set; review cross-origin embedding needs.",
);
return {
headers,
duplicates,
findings: securityFindings.map((finding) => finding.message),
securityFindings,
cspFields,
...(csp ? { csp } : {}),
};
}
export function inspectCsp(policy: string): CspInspection {
if (policy.length > 128 * 1024)
throw new Error("CSP exceeds the 128 KiB inspection limit.");
const directives: CspDirective[] = [];
const findings: SecurityHeaderFinding[] = [];
const seen = new Set<string>();
const add = (
severity: SecurityHeaderFinding["severity"],
code: string,
message: string,
) => findings.push({ severity, code, message });
for (const raw of policy.split(";")) {
const tokens = raw.trim().split(/\s+/u).filter(Boolean);
if (tokens.length === 0) continue;
const name = tokens[0]!.toLowerCase();
if (!/^[a-z][a-z0-9-]{0,63}$/u.test(name)) {
add(
"error",
"csp-directive-name",
`Invalid CSP directive name “${name}”.`,
);
continue;
}
const duplicate = seen.has(name);
if (duplicate)
add(
"warning",
"csp-duplicate-directive",
`Duplicate ${name} directive is ignored by browsers after its first occurrence.`,
);
seen.add(name);
const values = tokens.slice(1);
const normalizedValues = values.map((value) => value.toLowerCase());
directives.push({ name, values });
// CSP processors ignore duplicate directives after the first occurrence.
// Keep them visible above, but do not attribute their ignored sources to
// the effective policy.
if (duplicate) continue;
if (normalizedValues.includes("*"))
add("warning", "csp-wildcard", `${name} contains a wildcard source.`);
if (normalizedValues.includes("'unsafe-eval'"))
add("error", "csp-unsafe-eval", `${name} permits 'unsafe-eval'.`);
if (normalizedValues.includes("'unsafe-inline'"))
add(
"warning",
"csp-unsafe-inline",
`${name} permits 'unsafe-inline'; hashes/nonces are usually safer.`,
);
if (normalizedValues.includes("http:"))
add("warning", "csp-http", `${name} permits the insecure http: scheme.`);
if (
(name === "script-src" || name === "script-src-elem") &&
normalizedValues.includes("data:")
)
add("error", "csp-script-data", `${name} permits data: script sources.`);
}
if (!seen.has("default-src"))
add("warning", "csp-default-src", "CSP has no default-src fallback.");
const objectSource = directives.find(
(directive) => directive.name === "object-src",
);
if (!objectSource)
add("warning", "csp-object-src", "CSP has no explicit object-src 'none'.");
else if (!(
objectSource.values.length === 1 &&
objectSource.values[0]?.toLowerCase() === "'none'"
))
add(
"warning",
"csp-object-src-open",
"object-src is not restricted to 'none'.",
);
if (!seen.has("base-uri"))
add("warning", "csp-base-uri", "CSP has no base-uri restriction.");
if (!seen.has("frame-ancestors"))
add(
"warning",
"csp-frame-ancestors",
"CSP has no frame-ancestors restriction.",
);
return { directives, findings };
}
export interface CspInput {
+147
View File
@@ -0,0 +1,147 @@
import { formatIpv4, parseCidr } from "@add-ideas/toolbox-helpers";
const MAX_REQUIREMENTS = 1_024;
const MAX_NAME_LENGTH = 120;
export interface VlsmRequirement {
name: string;
hosts: number;
}
export interface VlsmAllocation extends VlsmRequirement {
requestedIndex: number;
prefixLength: number;
network: string;
firstUsable: string;
lastUsable: string;
broadcast: string;
usableHosts: number;
addresses: number;
}
export interface VlsmPlan {
base: string;
allocations: VlsmAllocation[];
usedAddresses: number;
freeAddresses: number;
warnings: string[];
}
function prefixForHosts(hosts: number): number {
if (!Number.isSafeInteger(hosts) || hosts < 1 || hosts > 4_294_967_294)
throw new Error(
"Host requirements must be integers from 1 to 4,294,967,294.",
);
const addressBits = Math.ceil(Math.log2(hosts + 2));
return 32 - Math.max(2, addressBits);
}
function align(value: bigint, block: bigint): bigint {
const remainder = value % block;
return remainder === 0n ? value : value + block - remainder;
}
export function parseVlsmRequirements(source: string): VlsmRequirement[] {
if (source.length > 256 * 1024)
throw new Error("Requirements exceed the 256 KiB limit.");
const requirements: VlsmRequirement[] = [];
for (const [index, raw] of source
.replaceAll("\r\n", "\n")
.split("\n")
.entries()) {
const line = raw.trim();
if (!line || line.startsWith("#")) continue;
if (requirements.length >= MAX_REQUIREMENTS)
throw new Error(
`More than ${MAX_REQUIREMENTS} subnet requirements were supplied.`,
);
const separator = line.lastIndexOf(",");
if (separator < 1)
throw new Error(`Line ${index + 1}: expected name,hosts.`);
const name = line.slice(0, separator).trim();
const hostsText = line.slice(separator + 1).trim();
if (!name || name.length > MAX_NAME_LENGTH)
throw new Error(
`Line ${index + 1}: name must contain 1${MAX_NAME_LENGTH} characters.`,
);
if (!/^\d+$/u.test(hostsText))
throw new Error(`Line ${index + 1}: hosts must be a positive integer.`);
const hosts = Number(hostsText);
prefixForHosts(hosts);
requirements.push({ name, hosts });
}
if (requirements.length === 0)
throw new Error("Enter at least one name,hosts requirement.");
return requirements;
}
export function planVlsm(
baseCidr: string,
requirements: readonly VlsmRequirement[],
): VlsmPlan {
if (requirements.length === 0 || requirements.length > MAX_REQUIREMENTS)
throw new Error(
`Supply between 1 and ${MAX_REQUIREMENTS} subnet requirements.`,
);
const base = parseCidr(baseCidr);
if (base.version !== 4)
throw new Error(
"The VLSM planner currently supports IPv4 base networks only.",
);
const sorted = requirements
.map((requirement, requestedIndex) => ({
...requirement,
requestedIndex,
prefixLength: prefixForHosts(requirement.hosts),
}))
.sort(
(left, right) =>
left.prefixLength - right.prefixLength ||
left.requestedIndex - right.requestedIndex,
);
let cursor = base.network.value;
const endExclusive = base.network.value + base.size;
const allocations: VlsmAllocation[] = [];
for (const requirement of sorted) {
if (requirement.prefixLength < base.prefixLength)
throw new Error(
`${requirement.name} needs /${requirement.prefixLength}, which is larger than ${base.canonical}.`,
);
const addresses = 2 ** (32 - requirement.prefixLength);
const block = BigInt(addresses);
const network = align(cursor, block);
const after = network + block;
if (after > endExclusive)
throw new Error(
`${requirement.name} does not fit. Allocation stopped before producing a partial plan.`,
);
allocations.push({
name: requirement.name,
hosts: requirement.hosts,
requestedIndex: requirement.requestedIndex,
prefixLength: requirement.prefixLength,
network: `${formatIpv4(network)}/${requirement.prefixLength}`,
firstUsable: formatIpv4(network + 1n),
lastUsable: formatIpv4(after - 2n),
broadcast: formatIpv4(after - 1n),
usableHosts: addresses - 2,
addresses,
});
cursor = after;
}
const used = allocations.reduce((total, item) => total + item.addresses, 0);
const baseSize = Number(base.size);
return {
base: base.canonical,
allocations,
usedAddresses: used,
freeAddresses: baseSize - used,
warnings: [
"Allocations are largest-first and use conventional network/broadcast reservations; point-to-point /31 semantics are not assumed.",
"Free-address count includes alignment gaps and is not itself a contiguous remainder guarantee.",
],
};
}
+22 -1
View File
@@ -140,7 +140,7 @@ textarea {
overflow-x: auto;
padding-bottom: 0.8rem;
}
.workspace-tabs button[aria-selected="true"] {
.workspace-tabs button[aria-pressed="true"] {
border-color: var(--toolbox-accent);
background: var(--toolbox-accent);
color: var(--toolbox-accent-contrast);
@@ -230,6 +230,27 @@ table {
border-collapse: collapse;
font-size: 0.9rem;
}
.table-scroll {
max-width: 100%;
overflow: auto;
border: 1px solid var(--toolbox-border);
border-radius: 0.68rem;
}
.table-scroll table {
min-width: 52rem;
}
.findings-list li + li {
margin-top: 0.35rem;
}
.finding-error {
color: var(--toolbox-danger);
}
.finding-warning {
color: #8a5a00;
}
.finding-info {
color: var(--toolbox-muted);
}
th,
td {
padding: 0.55rem 0.65rem;
+27 -1
View File
@@ -3,7 +3,7 @@
"schemaVersion": 1,
"id": "de.add-ideas.network-tools",
"name": "Network Tools",
"version": "0.1.0",
"version": "0.2.0",
"description": "Calculate and construct network values locally in the browser.",
"entry": "./",
"icon": "./favicon.svg",
@@ -21,6 +21,32 @@
"crossOriginIsolated": false,
"topLevelContext": false
},
"io": {
"accepts": [
{
"mediaType": "text/plain",
"extensions": [".txt", ".zone"]
},
{
"mediaType": "application/json",
"extensions": [".json"]
}
],
"produces": [
{
"mediaType": "text/plain",
"extensions": [".txt", ".zone"]
},
{
"mediaType": "application/json",
"extensions": [".json"]
}
]
},
"capabilities": {
"required": [],
"optional": []
},
"privacy": {
"processing": "local",
"fileUploads": false,
+1 -1
View File
@@ -1 +1 @@
export const APP_VERSION = "0.1.0";
export const APP_VERSION = "0.2.0";