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
+39
View File
@@ -0,0 +1,39 @@
name: Verify
on:
push:
branches: [main]
pull_request:
workflow_dispatch:
concurrency:
group: verify-${{ gitea.repository }}-${{ gitea.ref }}
cancel-in-progress: true
permissions:
contents: read
jobs:
verify:
runs-on: ubuntu-latest
timeout-minutes: 45
env:
CI: "true"
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: "22"
cache: npm
- name: Select declared npm version
run: npm install --global npm@11.17.0
- name: Install dependencies
run: npm ci
- name: Audit runtime dependencies
run: npm audit --omit=dev --audit-level=moderate
- name: Check, test, and build
run: npm run check
- name: Install browser engines
run: npx playwright install --with-deps chromium firefox webkit
- name: Browser tests
run: npm run test:browser
+6
View File
@@ -1,5 +1,11 @@
# Changelog
## 0.2.0 - 2026-09-02
- Added largest-first IPv4 VLSM planning with bounded, atomic allocation.
- Added inert DNS zone parsing/validation with common record semantics and explicit syntax-only coverage.
- Added multi-field CSP and HTTP security-header analysis.
## 0.1.0 - 2026-09-01
- Added the initial local-first Network Tools workbench.
+6 -3
View File
@@ -4,12 +4,15 @@ Calculate and construct network values locally in the browser.
Network Tools is a standalone local-first application in the [add·ideas Toolbox](https://git.add-ideas.de/lotobo/toolbox-portal). Inputs are processed in the browser and are not uploaded.
## Version 0.1 scope
## Current scope
- IPv4 and IPv6 parsing, canonicalisation, prefix ranges and IPv4 broadcast calculation
- Largest-first IPv4 VLSM planning with conventional network/broadcast reservations, alignment and atomic fit failures
- URL resolution, component/query inspection and password-redacted display without requesting the URL
- Bounded A, AAAA, CNAME, MX, SRV, CAA and TXT zone-record construction
- Bounded response-header inspection and explicit CSP construction
- Inert, bounded zone-file parsing with `$ORIGIN`, `$TTL`, inherited owners, multiline records, common RDATA validation and explicit syntax-only coverage for unknown types; `$INCLUDE` is never followed
- Bounded response-header security analysis covering CSP, HSTS, cookies, CORS, framing, content sniffing, referrer and cross-origin policies
- CSP parsing/diagnostics and explicit conservative CSP construction
- A compact offline extension/MIME reference
The application treats pasted values as untrusted, applies explicit length and numeric limits, and makes no DNS, URL or HTTP request. See [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) and [docs/PRIVACY-SECURITY.md](docs/PRIVACY-SECURITY.md).
@@ -26,7 +29,7 @@ npm run test:browser
## Release
`npm run release:artifact` creates a deterministic `release/network-tools-0.1.0.zip` and checksum sidecar.
`npm run release:artifact` creates a deterministic `release/network-tools-0.2.0.zip` and checksum sidecar.
## Licence
+2 -2
View File
@@ -1,7 +1,7 @@
# Corresponding source
The corresponding source for Network Tools 0.1.0 is available at:
The corresponding source for Network Tools 0.2.0 is available at:
https://git.add-ideas.de/lotobo/network-tools/src/tag/v0.1.0
https://git.add-ideas.de/lotobo/network-tools/src/tag/v0.2.0
Build with Node.js 22, npm 11, `npm ci`, and `npm run release:artifact`.
+3 -3
View File
@@ -4,9 +4,9 @@ Network Tools 0.1.0 directly depends on these runtime packages:
| Package | Pinned version | Declared licence |
| -------------------------------- | -------------: | ---------------- |
| `@add-ideas/toolbox-contract` | 0.2.3 | Apache-2.0 |
| `@add-ideas/toolbox-helpers` | 0.1.0 | GPL-3.0-or-later |
| `@add-ideas/toolbox-shell-react` | 0.2.3 | Apache-2.0 |
| `@add-ideas/toolbox-contract` | 0.3.0 | Apache-2.0 |
| `@add-ideas/toolbox-helpers` | 0.2.0 | GPL-3.0-or-later |
| `@add-ideas/toolbox-shell-react` | 0.3.0 | Apache-2.0 |
| `react` | 19.2.8 | MIT |
| `react-dom` | 19.2.8 | MIT |
+4 -3
View File
@@ -3,8 +3,9 @@
Network Tools is a static React/Vite application wrapped in the shared Toolbox shell. `Workbench.tsx` owns only transient view state and delegates to small pure modules:
- `network/url.ts` resolves and decomposes URLs with the platform `URL` parser and returns a password-redacted display value.
- `network/dns.ts` constructs one bounded zone-file record; IPv4 and IPv6 parsing comes from `@add-ideas/toolbox-helpers`.
- `network/http.ts` parses a pasted header block and constructs an explicit CSP from source tokens.
- `network/vlsm.ts` parses bounded `name,hosts` requirements, sorts largest-first and allocates aligned IPv4 blocks atomically inside one parsed base CIDR.
- `network/dns.ts` constructs one bounded zone-file record and inertly parses complete pasted zone text. The zone parser joins bounded parenthesized records, honours local `$ORIGIN`/`$TTL`, validates common RDATA and retains unsupported record types as clearly marked syntax-only data. It rejects rather than follows external/generative directives such as `$INCLUDE`.
- `network/http.ts` parses a pasted header block, analyses a deployed CSP plus baseline response-header protections, and constructs an explicit CSP from source tokens.
- `network/mime.ts` is a project-authored offline reference table.
All version 0.1 operations are short, bounded and synchronous, so the app does not create a worker. There is no server API, service worker, persistence layer or runtime lookup. Relative entry and asset URLs keep the production build relocatable below a nested portal path.
All operations are short, bounded and synchronous, so the app does not create a worker. VLSM is capped at 1,024 requirements; zone text at 2 MiB/50,000 physical lines/20,000 records; headers at 256 KiB/2,048 fields and CSP at 128 KiB. There is no server API, persistence layer or runtime lookup. Relative entry and asset URLs keep the production build relocatable below a nested portal path.
+3 -3
View File
@@ -1,7 +1,7 @@
# Privacy and security
Network Tools has no runtime network operation. Entering a URL does not visit it; constructing a DNS record does not perform a DNS query; and the MIME reference is bundled in the app. There is no account, telemetry, analytics, persistence or imported active content.
Network Tools has no runtime network operation. Entering a URL does not visit it; constructing or parsing DNS data does not perform a DNS query; `$INCLUDE` and other external zone directives are rejected; pasted headers are never requested; and the MIME reference is bundled in the app. There is no account, telemetry, analytics, persistence or imported active content.
Inputs and results stay in React memory until they are replaced or the page is closed. URL passwords are removed from the displayed resolved URL, but query strings, fragments and the original input remain visible and may still contain secrets. Header findings and generated CSP are advisory, not a complete security audit.
Inputs and results stay in React memory until they are replaced or the page is closed. URL passwords are removed from the displayed resolved URL, but query strings, fragments and the original input remain visible and may still contain secrets. VLSM output assumes conventional IPv4 network/broadcast reservations. Zone validation covers common record semantics and labels all other records as syntax-only. Header/CSP findings are defensive review prompts, not proof that an application is secure or correctly deployed.
The URL parser rejects inputs above 16 KiB, the header parser rejects blocks above 256 KiB and obsolete folding/control characters, TXT character strings are limited to 255 UTF-8 bytes, and DNS names/numbers are syntax- and range-checked. These limits reduce accidental resource use; they do not make copied output trustworthy for an unrelated system.
The URL parser rejects inputs above 16 KiB, the header parser rejects blocks above 256 KiB and obsolete folding/control characters, TXT character strings are limited to 255 UTF-8 bytes, and DNS names/numbers are syntax- and range-checked. Zone and VLSM bounds are documented in the architecture. These limits reduce accidental resource use; they do not make copied output trustworthy for an unrelated system.
+22 -23
View File
@@ -1,22 +1,22 @@
{
"name": "network-tools",
"version": "0.1.0",
"version": "0.2.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "network-tools",
"version": "0.1.0",
"version": "0.2.0",
"license": "GPL-3.0-or-later",
"dependencies": {
"@add-ideas/toolbox-contract": "0.2.3",
"@add-ideas/toolbox-helpers": "0.1.0",
"@add-ideas/toolbox-shell-react": "0.2.3",
"@add-ideas/toolbox-contract": "0.3.0",
"@add-ideas/toolbox-helpers": "0.2.0",
"@add-ideas/toolbox-shell-react": "0.3.0",
"react": "19.2.8",
"react-dom": "19.2.8"
},
"devDependencies": {
"@add-ideas/toolbox-testkit": "0.2.3",
"@add-ideas/toolbox-testkit": "0.3.0",
"@eslint/js": "10.0.1",
"@playwright/test": "1.62.1",
"@testing-library/jest-dom": "6.9.1",
@@ -42,24 +42,24 @@
}
},
"node_modules/@add-ideas/toolbox-contract": {
"version": "0.2.3",
"license": "Apache-2.0",
"engines": {
"node": ">=20"
}
"version": "0.3.0",
"resolved": "https://git.add-ideas.de/api/packages/lotobo/npm/%40add-ideas%2Ftoolbox-contract/-/0.3.0/toolbox-contract-0.3.0.tgz",
"integrity": "sha512-dKrK7BjOFwqJaBfJuhKxZKIld4sH0AKjEn6a0yLnbdMUFY+fFv4VSLGV2tNSBD016gumc2iNqOjUj/ld7x4rtA==",
"license": "Apache-2.0"
},
"node_modules/@add-ideas/toolbox-helpers": {
"version": "0.1.0",
"license": "GPL-3.0-or-later",
"engines": {
"node": ">=22"
}
"version": "0.2.0",
"resolved": "https://git.add-ideas.de/api/packages/lotobo/npm/%40add-ideas%2Ftoolbox-helpers/-/0.2.0/toolbox-helpers-0.2.0.tgz",
"integrity": "sha512-SdOqkw+P+3J3fa5iVkzb5P15rVepB001GNV21Oh8w0CZcVL+YRltgD/s+MVcTyrNijWQf3E5vtQON/3N2LLyKg==",
"license": "GPL-3.0-or-later"
},
"node_modules/@add-ideas/toolbox-shell-react": {
"version": "0.2.3",
"version": "0.3.0",
"resolved": "https://git.add-ideas.de/api/packages/lotobo/npm/%40add-ideas%2Ftoolbox-shell-react/-/0.3.0/toolbox-shell-react-0.3.0.tgz",
"integrity": "sha512-74p6JzAOG0YCAKdlc1hLofV4ZIko7vb448S75cIiM88PKm93EHl5VD7g8YVyfM56Ui97UY9dmy+Whiq4sGzpsg==",
"license": "Apache-2.0",
"dependencies": {
"@add-ideas/toolbox-contract": "0.2.3"
"@add-ideas/toolbox-contract": "0.3.0"
},
"peerDependencies": {
"react": ">=18 <20",
@@ -67,17 +67,16 @@
}
},
"node_modules/@add-ideas/toolbox-testkit": {
"version": "0.2.3",
"version": "0.3.0",
"resolved": "https://git.add-ideas.de/api/packages/lotobo/npm/%40add-ideas%2Ftoolbox-testkit/-/0.3.0/toolbox-testkit-0.3.0.tgz",
"integrity": "sha512-4Fk+oSvZFspOMIXr8Xy040nhAaBsIQAzsGyXWSpjn3+k3yBKq7nB1r5zCHhsXzfdLzvPDAx2KcmSNOhM330D9w==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"@add-ideas/toolbox-contract": "0.2.3"
"@add-ideas/toolbox-contract": "0.3.0"
},
"bin": {
"toolbox-check": "dist/cli.js"
},
"engines": {
"node": ">=20"
}
},
"node_modules/@adobe/css-tools": {
+5 -5
View File
@@ -1,6 +1,6 @@
{
"name": "network-tools",
"version": "0.1.0",
"version": "0.2.0",
"description": "Calculate and construct network values locally in the browser.",
"license": "GPL-3.0-or-later",
"author": "Albrecht Degering",
@@ -39,14 +39,14 @@
"release:artifact": "npm run check && npm run test:browser && npm run package:release -- --force"
},
"dependencies": {
"@add-ideas/toolbox-contract": "0.2.3",
"@add-ideas/toolbox-helpers": "0.1.0",
"@add-ideas/toolbox-shell-react": "0.2.3",
"@add-ideas/toolbox-contract": "0.3.0",
"@add-ideas/toolbox-helpers": "0.2.0",
"@add-ideas/toolbox-shell-react": "0.3.0",
"react": "19.2.8",
"react-dom": "19.2.8"
},
"devDependencies": {
"@add-ideas/toolbox-testkit": "0.2.3",
"@add-ideas/toolbox-testkit": "0.3.0",
"@eslint/js": "10.0.1",
"@playwright/test": "1.62.1",
"@testing-library/jest-dom": "6.9.1",
+20 -2
View File
@@ -15,7 +15,25 @@ export default defineConfig({
timeout: 180_000,
},
projects: [
{ name: "chromium", use: { ...devices["Desktop Chrome"] } },
{ name: "firefox", use: { ...devices["Desktop Firefox"] } },
{
name: "chromium",
testIgnore: /responsive\.spec\.ts/,
use: { ...devices["Desktop Chrome"] },
},
{
name: "firefox",
testIgnore: /responsive\.spec\.ts/,
use: { ...devices["Desktop Firefox"] },
},
{
name: "webkit",
testIgnore: /responsive\.spec\.ts/,
use: { ...devices["Desktop Safari"] },
},
{
name: "mobile-chromium",
testMatch: /responsive\.spec\.ts/,
use: { ...devices["Pixel 5"] },
},
],
});
+6
View File
@@ -1,5 +1,11 @@
# Changelog
## 0.2.0 - 2026-09-02
- Added largest-first IPv4 VLSM planning with bounded, atomic allocation.
- Added inert DNS zone parsing/validation with common record semantics and explicit syntax-only coverage.
- Added multi-field CSP and HTTP security-header analysis.
## 0.1.0 - 2026-09-01
- Added the initial local-first Network Tools workbench.
+3 -3
View File
@@ -1,5 +1,5 @@
==============================================================================
@add-ideas/toolbox-contract@0.2.3
@add-ideas/toolbox-contract@0.3.0
Declared licence: Apache-2.0
==============================================================================
--- LICENSE ---
@@ -198,7 +198,7 @@ Declared licence: Apache-2.0
==============================================================================
@add-ideas/toolbox-helpers@0.1.0
@add-ideas/toolbox-helpers@0.2.0
Declared licence: GPL-3.0-or-later
==============================================================================
--- LICENSE ---
@@ -879,7 +879,7 @@ Public License instead of this License. But first, please read
==============================================================================
@add-ideas/toolbox-shell-react@0.2.3
@add-ideas/toolbox-shell-react@0.3.0
Declared licence: Apache-2.0
==============================================================================
--- LICENSE ---
+6 -3
View File
@@ -4,12 +4,15 @@ Calculate and construct network values locally in the browser.
Network Tools is a standalone local-first application in the [add·ideas Toolbox](https://git.add-ideas.de/lotobo/toolbox-portal). Inputs are processed in the browser and are not uploaded.
## Version 0.1 scope
## Current scope
- IPv4 and IPv6 parsing, canonicalisation, prefix ranges and IPv4 broadcast calculation
- Largest-first IPv4 VLSM planning with conventional network/broadcast reservations, alignment and atomic fit failures
- URL resolution, component/query inspection and password-redacted display without requesting the URL
- Bounded A, AAAA, CNAME, MX, SRV, CAA and TXT zone-record construction
- Bounded response-header inspection and explicit CSP construction
- Inert, bounded zone-file parsing with `$ORIGIN`, `$TTL`, inherited owners, multiline records, common RDATA validation and explicit syntax-only coverage for unknown types; `$INCLUDE` is never followed
- Bounded response-header security analysis covering CSP, HSTS, cookies, CORS, framing, content sniffing, referrer and cross-origin policies
- CSP parsing/diagnostics and explicit conservative CSP construction
- A compact offline extension/MIME reference
The application treats pasted values as untrusted, applies explicit length and numeric limits, and makes no DNS, URL or HTTP request. See [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) and [docs/PRIVACY-SECURITY.md](docs/PRIVACY-SECURITY.md).
@@ -26,7 +29,7 @@ npm run test:browser
## Release
`npm run release:artifact` creates a deterministic `release/network-tools-0.1.0.zip` and checksum sidecar.
`npm run release:artifact` creates a deterministic `release/network-tools-0.2.0.zip` and checksum sidecar.
## Licence
+2 -2
View File
@@ -1,7 +1,7 @@
# Corresponding source
The corresponding source for Network Tools 0.1.0 is available at:
The corresponding source for Network Tools 0.2.0 is available at:
https://git.add-ideas.de/lotobo/network-tools/src/tag/v0.1.0
https://git.add-ideas.de/lotobo/network-tools/src/tag/v0.2.0
Build with Node.js 22, npm 11, `npm ci`, and `npm run release:artifact`.
+3 -3
View File
@@ -4,9 +4,9 @@ Network Tools 0.1.0 directly depends on these runtime packages:
| Package | Pinned version | Declared licence |
| -------------------------------- | -------------: | ---------------- |
| `@add-ideas/toolbox-contract` | 0.2.3 | Apache-2.0 |
| `@add-ideas/toolbox-helpers` | 0.1.0 | GPL-3.0-or-later |
| `@add-ideas/toolbox-shell-react` | 0.2.3 | Apache-2.0 |
| `@add-ideas/toolbox-contract` | 0.3.0 | Apache-2.0 |
| `@add-ideas/toolbox-helpers` | 0.2.0 | GPL-3.0-or-later |
| `@add-ideas/toolbox-shell-react` | 0.3.0 | Apache-2.0 |
| `react` | 19.2.8 | MIT |
| `react-dom` | 19.2.8 | MIT |
+4 -3
View File
@@ -3,8 +3,9 @@
Network Tools is a static React/Vite application wrapped in the shared Toolbox shell. `Workbench.tsx` owns only transient view state and delegates to small pure modules:
- `network/url.ts` resolves and decomposes URLs with the platform `URL` parser and returns a password-redacted display value.
- `network/dns.ts` constructs one bounded zone-file record; IPv4 and IPv6 parsing comes from `@add-ideas/toolbox-helpers`.
- `network/http.ts` parses a pasted header block and constructs an explicit CSP from source tokens.
- `network/vlsm.ts` parses bounded `name,hosts` requirements, sorts largest-first and allocates aligned IPv4 blocks atomically inside one parsed base CIDR.
- `network/dns.ts` constructs one bounded zone-file record and inertly parses complete pasted zone text. The zone parser joins bounded parenthesized records, honours local `$ORIGIN`/`$TTL`, validates common RDATA and retains unsupported record types as clearly marked syntax-only data. It rejects rather than follows external/generative directives such as `$INCLUDE`.
- `network/http.ts` parses a pasted header block, analyses a deployed CSP plus baseline response-header protections, and constructs an explicit CSP from source tokens.
- `network/mime.ts` is a project-authored offline reference table.
All version 0.1 operations are short, bounded and synchronous, so the app does not create a worker. There is no server API, service worker, persistence layer or runtime lookup. Relative entry and asset URLs keep the production build relocatable below a nested portal path.
All operations are short, bounded and synchronous, so the app does not create a worker. VLSM is capped at 1,024 requirements; zone text at 2 MiB/50,000 physical lines/20,000 records; headers at 256 KiB/2,048 fields and CSP at 128 KiB. There is no server API, persistence layer or runtime lookup. Relative entry and asset URLs keep the production build relocatable below a nested portal path.
+3 -3
View File
@@ -1,7 +1,7 @@
# Privacy and security
Network Tools has no runtime network operation. Entering a URL does not visit it; constructing a DNS record does not perform a DNS query; and the MIME reference is bundled in the app. There is no account, telemetry, analytics, persistence or imported active content.
Network Tools has no runtime network operation. Entering a URL does not visit it; constructing or parsing DNS data does not perform a DNS query; `$INCLUDE` and other external zone directives are rejected; pasted headers are never requested; and the MIME reference is bundled in the app. There is no account, telemetry, analytics, persistence or imported active content.
Inputs and results stay in React memory until they are replaced or the page is closed. URL passwords are removed from the displayed resolved URL, but query strings, fragments and the original input remain visible and may still contain secrets. Header findings and generated CSP are advisory, not a complete security audit.
Inputs and results stay in React memory until they are replaced or the page is closed. URL passwords are removed from the displayed resolved URL, but query strings, fragments and the original input remain visible and may still contain secrets. VLSM output assumes conventional IPv4 network/broadcast reservations. Zone validation covers common record semantics and labels all other records as syntax-only. Header/CSP findings are defensive review prompts, not proof that an application is secure or correctly deployed.
The URL parser rejects inputs above 16 KiB, the header parser rejects blocks above 256 KiB and obsolete folding/control characters, TXT character strings are limited to 255 UTF-8 bytes, and DNS names/numbers are syntax- and range-checked. These limits reduce accidental resource use; they do not make copied output trustworthy for an unrelated system.
The URL parser rejects inputs above 16 KiB, the header parser rejects blocks above 256 KiB and obsolete folding/control characters, TXT character strings are limited to 255 UTF-8 bytes, and DNS names/numbers are syntax- and range-checked. Zone and VLSM bounds are documented in the architecture. These limits reduce accidental resource use; they do not make copied output trustworthy for an unrelated system.
+1 -1
View File
@@ -1,5 +1,5 @@
const CACHE_PREFIX = "network-tools-shell-";
const CACHE_NAME = CACHE_PREFIX + "0.1.0";
const CACHE_NAME = CACHE_PREFIX + "0.2.0";
const CORE = ["./", "./manifest.webmanifest", "./favicon.svg"];
self.addEventListener("install", (event) => {
event.waitUntil(
+12 -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,17 @@
"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,
+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";
+31 -2
View File
@@ -39,7 +39,7 @@ test("calculates a subnet and sanitises a credential-bearing URL", async ({
await expect(
page.getByText("Last address", { exact: true }).locator("..").locator("dd"),
).toHaveText("192.168.10.255");
await page.getByRole("tab", { name: "URL" }).click();
await page.getByRole("button", { name: "URL" }).click();
await page
.getByLabel("URL or reference")
.fill("https://alice:secret@example.test/a?q=one&q=two");
@@ -51,6 +51,35 @@ test("calculates a subnet and sanitises a credential-bearing URL", async ({
expect(external).toEqual([]);
});
test("plans VLSM and inspects a zone and deployed CSP without lookups", async ({
page,
}) => {
const external = await localOnly(page);
await page.goto("/deep/nested/network/");
await page.getByRole("button", { name: "VLSM planner" }).click();
await expect(page.getByText("10.20.0.0/23", { exact: true })).toBeVisible();
await expect(page.getByText("10.20.2.0/25", { exact: true })).toBeVisible();
await page.getByRole("button", { name: "Zone file" }).click();
await expect(
page.getByText("Validated records").locator("..").locator("dd"),
).toHaveText("6");
await expect(
page.locator("pre").filter({ hasText: "192.0.2.80" }),
).toBeVisible();
await page.getByRole("button", { name: "HTTP & CSP" }).click();
await page
.getByLabel("Response headers")
.fill(
"Content-Security-Policy: default-src 'self'; script-src 'unsafe-eval'\nSet-Cookie: session=x; SameSite=None",
);
await expect(page.getByText(/permits 'unsafe-eval'/u)).toBeVisible();
await expect(page.getByText(/SameSite=None without Secure/u)).toBeVisible();
expect(external).toEqual([]);
});
test("serves the release identity and hardened headers", async ({
request,
}) => {
@@ -63,7 +92,7 @@ test("serves the release identity and hardened headers", async ({
const manifest = await request.get("/deep/nested/network/toolbox-app.json");
await expect(manifest.json()).resolves.toMatchObject({
id: "de.add-ideas.network-tools",
version: "0.1.0",
version: "0.2.0",
entry: "./",
});
});
+18
View File
@@ -0,0 +1,18 @@
import { expect, test } from "@playwright/test";
test("keeps the primary workspace inside a narrow viewport", async ({
page,
}) => {
await page.goto("/deep/nested/network/");
await expect(page.locator("main").first()).toBeVisible();
await expect(
page.locator("main .loading, main .workbench-loading"),
).toHaveCount(0);
const widths = await page.evaluate(() => ({
content: document.documentElement.scrollWidth,
viewport: document.documentElement.clientWidth,
}));
expect(widths.viewport).toBeLessThanOrEqual(430);
expect(widths.content).toBeLessThanOrEqual(widths.viewport + 1);
});
+78 -1
View File
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import { buildCsp, inspectHeaders } from "../../src/network/http";
import { buildCsp, inspectCsp, inspectHeaders } from "../../src/network/http";
import { lookupMime } from "../../src/network/mime";
describe("HTTP helpers", () => {
@@ -29,6 +29,83 @@ describe("HTTP helpers", () => {
expect(result.policy).toContain("object-src 'none'");
expect(result.warnings.join(" ")).toMatch(/unsafe-eval/u);
});
it("analyses deployed CSP, cookies, HSTS and framing defenses", () => {
const result = inspectHeaders(
"Content-Security-Policy: default-src 'self'; script-src 'self' 'unsafe-eval'; object-src 'none'; base-uri 'self'; frame-ancestors 'none'\n" +
"Strict-Transport-Security: max-age=60\n" +
"Set-Cookie: session=abc; SameSite=None\n" +
"X-Content-Type-Options: nosniff\n" +
"Referrer-Policy: strict-origin",
);
expect(result.securityFindings.map((finding) => finding.code)).toEqual(
expect.arrayContaining([
"csp-unsafe-eval",
"hsts-short",
"cookie-secure",
"cookie-http-only",
"cookie-none-insecure",
]),
);
expect(result.csp?.directives).toContainEqual({
name: "object-src",
values: ["'none'"],
});
});
it("identifies duplicate and dangerous CSP directives", () => {
const result = inspectCsp(
"default-src *; script-src data:; script-src 'self'",
);
expect(result.findings.map((finding) => finding.code)).toEqual(
expect.arrayContaining([
"csp-wildcard",
"csp-script-data",
"csp-duplicate-directive",
]),
);
});
it("analyses every enforcing CSP field rather than silently dropping later fields", () => {
const result = inspectHeaders(
"Content-Security-Policy: default-src 'self'; object-src 'none'\n" +
"Content-Security-Policy: script-src 'unsafe-eval'; object-src 'none'",
);
expect(result.cspFields).toHaveLength(2);
expect(result.securityFindings).toContainEqual(
expect.objectContaining({
code: "csp-unsafe-eval",
message: expect.stringContaining("CSP field 2"),
}),
);
});
it("uses the first duplicate directive and validates X-Frame-Options", () => {
const csp = inspectCsp(
"default-src 'self'; object-src *; object-src 'none'",
);
expect(csp.findings.map((finding) => finding.code)).toContain(
"csp-object-src-open",
);
const headers = inspectHeaders(
"X-Frame-Options: ALLOW-FROM https://example.test",
);
expect(headers.securityFindings.map((finding) => finding.code)).toContain(
"x-frame-options-invalid",
);
});
it("does not apply sources from ignored duplicate CSP directives", () => {
const csp = inspectCsp(
"default-src 'self'; script-src 'self'; script-src 'unsafe-eval' data:",
);
expect(csp.findings.map((finding) => finding.code)).not.toContain(
"csp-unsafe-eval",
);
expect(csp.findings.map((finding) => finding.code)).toContain(
"csp-duplicate-directive",
);
});
});
describe("MIME lookup", () => {
+89 -1
View File
@@ -1,5 +1,6 @@
import { describe, expect, it } from "vitest";
import { buildDnsRecord } from "../../src/network/dns";
import { buildDnsRecord, inspectDnsZone } from "../../src/network/dns";
import { parseVlsmRequirements, planVlsm } from "../../src/network/vlsm";
import { buildUrl, inspectUrl } from "../../src/network/url";
describe("URL inspection", () => {
@@ -73,4 +74,91 @@ describe("DNS construction", () => {
buildDnsRecord({ owner: "@", ttl: 60, type: "AAAA", value: "::::" }),
).toThrow(/IPv6/u);
});
it("parses a bounded zone inertly and validates common record data", () => {
const result = inspectDnsZone(
"$ORIGIN example.test.\n$TTL 1h\n@ IN SOA ns hostmaster 1 1h 15m 1w 5m\n@ IN NS ns\nns IN A 192.0.2.1\n IN AAAA 2001:db8::1\n_unknown IN SVCB 1 target.example.",
);
expect(result.origin).toBe("example.test.");
expect(result.records).toHaveLength(5);
expect(result.records[2]?.normalized).toContain(
"ns.example.test. 3600 IN A 192.0.2.1",
);
expect(result.records[3]?.owner).toBe("ns.example.test.");
expect(result.coverage).toMatchObject({
validatedRecords: 4,
syntaxOnlyRecords: 1,
});
expect(result.diagnostics[0]?.message).toMatch(/syntax-only/u);
});
it("rejects include directives without reading external files", () => {
const result = inspectDnsZone("$INCLUDE secrets.zone");
expect(result.records).toEqual([]);
expect(result.diagnostics[0]).toMatchObject({ severity: "error" });
});
it("accepts wildcard/root owners and keeps non-IN records syntax-only", () => {
const result = inspectDnsZone(
"$ORIGIN example.test.\n*.web IN A 192.0.2.9\n. IN NS a.root-servers.net.\nlegacy CH A not-an-ip",
);
expect(result.records.map((record) => record.owner)).toEqual([
"*.web.example.test.",
".",
"legacy.example.test.",
]);
expect(result.records[2]).toMatchObject({
dnsClass: "CH",
validation: "syntax-only",
data: ["not-an-ip"],
});
expect(result.diagnostics).toContainEqual(
expect.objectContaining({ message: expect.stringContaining("only IN") }),
);
});
it("joins names correctly beneath the DNS root origin", () => {
const result = inspectDnsZone("$ORIGIN .\nwww IN A 192.0.2.10");
expect(result.records[0]).toMatchObject({
owner: "www.",
normalized: "www. IN A 192.0.2.10",
});
expect(result.diagnostics).toEqual([]);
});
});
describe("VLSM planning", () => {
it("allocates largest requirements first on subnet boundaries", () => {
const plan = planVlsm(
"10.0.0.0/24",
parseVlsmRequirements("Printers,12\nOffice,100\nLab,50"),
);
expect(plan.allocations.map((item) => item.network)).toEqual([
"10.0.0.0/25",
"10.0.0.128/26",
"10.0.0.192/28",
]);
expect(plan.allocations[0]?.name).toBe("Office");
expect(plan.freeAddresses).toBe(48);
});
it("fails atomically when requirements do not fit", () => {
expect(() =>
planVlsm("192.0.2.0/29", [
{ name: "one", hosts: 4 },
{ name: "two", hosts: 4 },
]),
).toThrow(/does not fit/u);
});
it("supports a conventional full IPv4 /0 capacity without unsafe numbers", () => {
const plan = planVlsm("0.0.0.0/0", [
{ name: "whole range", hosts: 4_294_967_294 },
]);
expect(plan.allocations[0]).toMatchObject({
network: "0.0.0.0/0",
broadcast: "255.255.255.255",
usableHosts: 4_294_967_294,
});
});
});