feat(core): the firewall settings page of routers after Java, measured against Java

The page konfig changes the firewall at run time (into the snapshot, back
into the project through projectWithSimulationChanges). KonfigCases feeds
Java's plug-in directly (25 cases, de and en); RouterWebRun plays two
stories over the network. Ten Java bugs named, not adopted. The web server
answers a method not written GET or POST with 501, as Java does.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-01 18:13:31 +00:00
co-authored by Claude Opus 5.5
parent 7dac57cbbe
commit 8438b42018
23 changed files with 13229 additions and 24 deletions
@@ -0,0 +1,90 @@
# Cases for tools/java-oracle's KonfigCases: FILIUS' firewall settings page
# (konfig) fed with what a browser posts. Read by firewallSettings.test.ts.
case getEmpty empty
GET
case getThree three
GET
POST
case deactivate three
POST btnSave=speichern
POST firewallActivate=1&dropICMP=1&onlySYN=1&btnSave=speichern
case defaultPolicy three
POST defaultPolicy=accept&defPolSet=speichern
POST defaultPolicy=xyz&defPolSet=speichern
POST defaultPolicy=accept
POST defaultPolicy=accept&defPolSet=speichern
POST defPolSet=speichern
case moveUp three
POST radioRule=2&moveup=rauf
POST radioRule=1&moveup=rauf
POST radioRule=4&moveup=rauf
POST moveup=rauf
POST radioRule=3&radioRule=2&moveup=rauf
POST radioRule=2&radioRule=abc&moveup=rauf
POST radioRule=%2B3&moveup=rauf
case moveDown three
POST radioRule=1&movedown=runter
POST radioRule=3&movedown=runter
POST movedown=runter
case moveDownZero three
POST radioRule=0&movedown=runter
case delete three
POST radioRule=2&delrule=l%C3%B6schen
POST radioRule=0&delrule=l%C3%B6schen
POST radioRule=abc&delrule=l%C3%B6schen
POST radioRule=9&delrule=l%C3%B6schen
case addEmpty empty
POST srcIP=&srcMask=&destIP=&destMask=&protocol=*&port=&action=drop&addrule=hinzuf%C3%BCgen
POST srcIP=&srcMask=&destIP=&destMask=&protocol=TCP&port=80&action=accept&addrule=hinzuf%C3%BCgen
POST srcIP=&srcMask=&destIP=&destMask=&protocol=UDP&port=-1&action=drop&addrule=hinzuf%C3%BCgen
POST srcIP=&srcMask=&destIP=&destMask=&protocol=tcp&port=+53+&action=ACCEPT&addrule=hinzuf%C3%BCgen
POST addrule=x
case addSource empty
POST srcIP=10.0.0.0&srcMask=255.255.255.0&destIP=&destMask=&protocol=TCP&port=80&action=drop&addrule=hinzuf%C3%BCgen
case addSourceMaskFirst empty
POST srcMask=255.255.255.0&srcIP=10.0.0.0&destIP=&destMask=&protocol=TCP&port=80&action=drop&addrule=hinzuf%C3%BCgen
case sourceRule sourceRule
GET
case addDestination empty
POST srcIP=&srcMask=&destIP=20.0.0.0&destMask=255.0.0.0&protocol=UDP&port=&action=accept&addrule=hinzuf%C3%BCgen
case addDestinationMaskFirst empty
POST srcIP=&srcMask=&destMask=255.0.0.0&destIP=20.0.0.0&protocol=UDP&port=&action=accept&addrule=hinzuf%C3%BCgen
case destinationRule destinationRule
GET
case errors three
POST srcIP=10.0.0.300&srcMask=&destIP=&destMask=255.0.255.0&protocol=UDP&port=abc&action=accept&addrule=hinzuf%C3%BCgen
GET
POST radioRule=1&moveup=rauf
POST srcIP=10.0.0.1&srcMask=1.2.3.4&destIP=&destMask=255.0.0.0&protocol=TCP&port=0&action=drop&addrule=hinzuf%C3%BCgen
POST srcIP=&srcMask=0.0.0.0&destIP=1.2.3.4&destMask=0.0.0.0&protocol=*&port=65536&action=drop&addrule=hinzuf%C3%BCgen
POST srcIP=&srcMask=&destIP=&destMask=&protocol=*&port=080&action=drop&addrule=hinzuf%C3%BCgen
POST srcIP=&srcMask=&destIP=&destMask=&protocol=*&port=%2B80&action=drop&addrule=hinzuf%C3%BCgen
POST srcIP=&srcMask=255.255.255.0.&destIP=&destMask=255.255.255&protocol=*&port=65535&action=drop&addrule=hinzuf%C3%BCgen
case errorAndSave three
POST srcIP=1.2.3&srcMask=&destIP=&destMask=&protocol=*&port=&action=drop&btnSave=speichern&addrule=hinzuf%C3%BCgen
case markup empty
POST srcIP=%3Cb%3E%22&srcMask=&destIP=&destMask=&protocol=*&port=&action=drop&addrule=hinzuf%C3%BCgen
case dollar empty
POST srcIP=%241&srcMask=&destIP=&destMask=&protocol=*&port=&action=drop&addrule=hinzuf%C3%BCgen
case encoding empty
POST srcIP=+&srcMask=&destIP=&destMask=&protocol=%2A&port=%38&action=drop&addrule=hinzuf%C3%BCgen
POST \u000abtnSave=speichern&firewallActivate=1
POST BTNSAVE=x&firewallActivate=1
POST =x&btnSave
case masks empty
POST srcIP=&srcMask=%2B255.255.255.0&destIP=1.2.3&destMask=255.255.255.255.0&protocol=TCP&protocol=UDP&port=&action=drop&addrule=hinzuf%C3%BCgen
POST srcIP=&srcMask=255.255.255.256&destIP=&destMask=255.255.255.255&protocol=*&port=&action=accept&action=drop&addrule=hinzuf%C3%BCgen
case portZero portZero
GET
case ampersand empty
POST srcIP=%26&srcMask=&destIP=&destMask=&protocol=*&port=&action=drop&addrule=hinzuf%C3%BCgen
# What Java's browser window posts (Swing's form: every field, the button pressed).
case browserForm three
POST firewallActivate=1&onlySYN=1&btnSave=speichern&defaultPolicy=drop&srcIP=&srcMask=&destIP=&destMask=&protocol=*&port=&action=drop
POST onlySYN=1&defaultPolicy=accept&defPolSet=speichern&srcIP=&srcMask=&destIP=&destMask=&protocol=*&port=&action=drop
POST firewallActivate=1&onlySYN=1&defaultPolicy=drop&radioRule=3&moveup=rauf&srcIP=10.0.0.0&srcMask=&destIP=&destMask=&protocol=*&port=&action=drop
POST firewallActivate=1&onlySYN=1&defaultPolicy=drop&srcIP=&srcMask=&destIP=&destMask=&protocol=UDP&port=53&action=accept&addrule=hinzuf%C3%BCgen
POST firewallActivate=1&onlySYN=1&defaultPolicy=drop&radioRule=1&delrule=l%C3%B6schen&srcIP=&srcMask=&destIP=&destMask=&protocol=*&port=&action=drop
case malformed three
POST radioRule=2&moveup=rauf&x=%zz
POST radioRule=2&moveup=rauf&x=%
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+163
View File
@@ -0,0 +1,163 @@
// The firewall settings page of routers and home routers (konfig,
// apps/firewallSettings.ts) against Java FILIUS' own: tools/java-oracle's
// KonfigCases fed Java's page with the steps of firewall-settings.cases.txt
// (firewall-settings.<de|en>.java.json). Each step's page and the firewall
// after it must be Java's, in both languages, except where Java errs.
import { readFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { describe, expect, it } from 'vitest';
import {
applyFirewallSettings,
firewallConfiguration,
firewallRule,
firewallSettingsPage,
javaUrlDecode,
type FirewallConfiguration,
type FirewallRule,
type PageLanguage,
} from '../src/index.js';
const here = dirname(fileURLToPath(import.meta.url));
type JavaRule = [string, string, string, string, number, string, string];
interface JavaFirewall {
isActive: boolean;
dropIcmp: boolean;
filterSynSegmentsOnly: boolean;
defaultPolicy: string;
rules: JavaRule[];
}
interface JavaStep {
post: string | null;
page: string | null;
firewall: JavaFirewall;
}
interface JavaCase {
name: string;
start: string;
steps: JavaStep[];
}
function javaRule(rule: FirewallRule): JavaRule {
return [rule.sourceIPAddress, rule.sourceSubnetMask, rule.destinationIPAddress, rule.destinationSubnetMask, rule.port, rule.protocol, rule.action];
}
function asJava(configuration: FirewallConfiguration): JavaFirewall {
return {
isActive: configuration.isActive,
dropIcmp: configuration.dropIcmp,
filterSynSegmentsOnly: configuration.filterSynSegmentsOnly,
defaultPolicy: configuration.defaultPolicy,
rules: configuration.rules.map(javaRule),
};
}
/** KonfigCases' start states. */
const STARTS: Record<string, FirewallConfiguration> = {
empty: firewallConfiguration(),
three: firewallConfiguration({
isActive: true,
rules: [
firewallRule({ port: 80, protocol: 'tcp', action: 'accept' }),
firewallRule({ sourceIPAddress: '999.999.999.999', destinationIPAddress: '20.0.0.0', destinationSubnetMask: '255.0.0.0', protocol: 'udp', action: 'drop' }),
firewallRule({ sourceIPAddress: '10.0.0.0', sourceSubnetMask: '255.0.0.0', protocol: 'all', action: 'drop' }),
],
}),
sourceRule: firewallConfiguration({ rules: [firewallRule({ sourceIPAddress: '10.0.0.0', sourceSubnetMask: '255.255.255.0', port: 80, protocol: 'tcp', action: 'drop' })] }),
portZero: firewallConfiguration({ rules: [firewallRule({ port: 0, protocol: 'icmp', action: 'accept' })] }),
destinationRule: firewallConfiguration({ rules: [firewallRule({ destinationIPAddress: '20.0.0.0', destinationSubnetMask: '255.0.0.0', protocol: 'udp', action: 'accept' })] }),
};
type Cases = Record<string, JavaCase>;
const step = (cases: Cases, name: string, index: number) => cases[name]!.steps[index]!;
/** A step as Java means it: another step's page and firewall, which Java answers for the same state. */
const asStep =
(name: string, index: number) =>
(cases: Cases, measured: JavaStep): JavaStep => ({ ...step(cases, name, index), post: measured.post });
/**
* Where Java errs, each step as Java means it (the core's departures, see
* firewallSettings.ts).
*/
const JAVA_BUGS: Record<string, Record<number, (cases: Cases, measured: JavaStep) => JavaStep>> = {
// Java checks the source IP against the mask not read yet, and the mask
// against the IP not read yet: it refuses every rule with an address,
// whichever field comes first. The core takes the rule.
addSource: { 0: asStep('sourceRule', 0) },
addSourceMaskFirst: { 0: asStep('sourceRule', 0) },
addDestination: { 0: asStep('destinationRule', 0) },
addDestinationMaskFirst: { 0: asStep('destinationRule', 0) },
// Java keeps the error of a failed add for every later page.
errors: { 1: asStep('getThree', 0) },
// Save and add at once: Java saves, but writes the add's errors as a bare
// list and fills the form in. The core answers the saved firewall.
errorAndSave: { 0: asStep('deactivate', 0) },
// Java writes the form's text into the page as it came.
markup: { 0: (_, measured) => ({ ...measured, page: measured.page!.replace('value="<b>""', 'value="&lt;b&gt;&quot;"') }) },
// A "$" in the form's text: Java's replaceAll reads it as a group, throws,
// and the page fails (500). The core answers as for any other bad address.
dollar: { 0: (cases, measured) => ({ ...measured, page: step(cases, 'markup', 0).page!.replace('value="<b>""', 'value="$1"') }) },
// An "&" typed into a field: Java decodes the data before it splits it,
// reads an empty source and adds a rule. The core keeps the "&" in its
// field and answers as for any other bad address.
ampersand: { 0: (cases) => ({ ...step(cases, 'markup', 0), page: step(cases, 'markup', 0).page!.replace('value="<b>""', 'value="&amp;"') }) },
// Moving rule 0 down: Java throws (its worker thread ends, no page comes).
moveDownZero: { 0: asStep('getThree', 0) },
// An escape that is not one: Java's decoder throws. The core reads the
// data as it came, as Java's code means to.
malformed: { 0: asStep('moveUp', 0), 1: asStep('getThree', 0) },
};
function javaCases(language: PageLanguage): Cases {
const list = JSON.parse(readFileSync(join(here, `firewall-settings.${language}.java.json`), 'utf8')) as JavaCase[];
return Object.fromEntries(list.map((each) => [each.name, each]));
}
describe('the firewall settings page as Java FILIUS answers it', () => {
for (const language of ['de', 'en'] as const) {
const cases = javaCases(language);
it(`corrects Java only where it errs (${language})`, () => {
for (const [name, steps] of Object.entries(JAVA_BUGS)) {
for (const [index, correct] of Object.entries(steps)) {
const measured = step(cases, name, Number(index));
expect(correct(cases, measured), `${name} ${index}`).not.toEqual(measured);
}
}
});
for (const [name, javaCase] of Object.entries(cases)) {
it(`${name} (${language})`, () => {
let configuration = STARTS[javaCase.start]!;
for (const [index, measured] of javaCase.steps.entries()) {
const expected = JAVA_BUGS[name]?.[index]?.(cases, measured) ?? measured;
const result = applyFirewallSettings(configuration, measured.post ?? undefined);
configuration = result.configuration;
expect(asJava(configuration), `step ${index}: the firewall`).toEqual(expected.firewall);
expect(firewallSettingsPage(configuration, language, result.rejected), `step ${index}: the page`).toBe(expected.page);
}
});
}
}
});
describe("a form's data as Java's URLDecoder reads it", () => {
it('turns + into spaces and runs of escapes into UTF-8 characters', () => {
expect(javaUrlDecode('a+b%C3%BC%E2%82%AC%41')).toBe('a bü€A');
expect(javaUrlDecode('%+f%-0x')).toBe('\u000f\u0000x');
});
it('fails where Java throws: an escape cut off, not hexadecimal, or negative', () => {
expect(javaUrlDecode('%zz')).toBeUndefined();
expect(javaUrlDecode('%4')).toBeUndefined();
expect(javaUrlDecode('%41%')).toBeUndefined();
expect(javaUrlDecode('%-1')).toBeUndefined();
});
});
+37
View File
@@ -9,6 +9,7 @@ import {
firewallLogPage,
firewallRule,
firewallRuleText,
firewallSettingsPage,
forwardingTablePage,
interfaceKey,
javaTextLength,
@@ -140,6 +141,42 @@ describe("a router's firewall log", () => {
});
});
describe("a router's settings page (konfig)", () => {
const pageOf = (engine: NetworkRuntimeEngine) => {
const pages = engine.webBrowser(LEFT)!.log.all.flatMap((line) => (line.kind === 'page' ? [line] : []));
return pages[pages.length - 1];
};
const ask = (engine: NetworkRuntimeEngine, data?: string) => {
engine.webBrowser(LEFT)!.open('10.0.0.1/konfig', data);
engine.handle({ type: 'advance', toMilliseconds: engine.state.currentTimeMilliseconds + 5_000 });
return pageOf(engine);
};
it('changes the firewall at run time, into the snapshot; a page without data changes nothing', () => {
const engine = startedEngine({ ...network(), webBrowserNodeIds: new Set([LEFT]) });
const before = engine.state.topologySnapshot;
expect(ask(engine)).toMatchObject({ statusCode: 200, contentType: 'text/html' });
expect(engine.state.topologySnapshot).toBe(before);
const page = ask(engine, 'firewallActivate=1&btnSave=speichern&defaultPolicy=accept&srcIP=&srcMask=&destIP=&destMask=&protocol=*&port=&action=drop');
expect(engine.state.topologySnapshot.firewallConfigurationsByNodeId.get(ROUTER)).toEqual(
firewallConfiguration({ isActive: true, defaultPolicy: 'accept', filterSynSegmentsOnly: false, rules: [firewallRule({ protocol: 'udp', port: 7, action: 'drop' })] }),
);
// The browser reads the answer as HTTPNachricht does: without its last empty lines.
expect(page?.body).toBe(firewallSettingsPage(engine.firewallConfiguration(ROUTER), 'de').replace(/\n+$/, ''));
});
it("answers in the project's language, and on a home router as on a router", () => {
const gateway = network({ language: 'en', webBrowserNodeIds: new Set([LEFT]) });
const engine = startedEngine({ ...gateway, nodes: gateway.nodes.map((node) => (node.id === ROUTER ? { ...node, kind: 'gateway' as const } : node)) });
const page = ask(engine, 'srcIP=&srcMask=&destIP=&destMask=&protocol=TCP&port=22&action=accept&addrule=add');
expect(page?.body).toContain('<title>Router Firewall Configuration</title>');
expect(engine.firewallConfiguration(ROUTER).rules).toEqual([firewallRule({ protocol: 'udp', port: 7, action: 'drop' }), firewallRule({ protocol: 'tcp', port: 22, action: 'accept' })]);
const rejected = ask(engine, 'srcIP=&srcMask=&destIP=&destMask=&protocol=TCP&port=0&action=accept&addrule=add');
expect(rejected?.body).toContain('<font color="red">Erroneous input(s):<ul><li>Port invalid</li></ul></font><br>');
expect(engine.firewallConfiguration(ROUTER).rules).toHaveLength(2);
});
});
describe("a RIP route's public address (hopPublicIp)", () => {
it('is the telling router\'s, also when a shorter way replaces the route', () => {
const engine = startedEngine(network({ ripEnabledByNodeId: new Map([[ROUTER, true]]), firewallConfigurationsByNodeId: new Map() }));
+58 -3
View File
@@ -11,7 +11,9 @@
// not compared. Java's router server sometimes never listens (setAktiv wakes
// its thread only when it already waits), and takes no initial sequence
// number then; the engine's always listens: sequence numbers are compared
// below the millions. The settings page (konfig) comes with F3.2c.
// below the millions. The settings page (konfig) changes the firewall at run
// time: a ping passes once "drop ICMP" is saved off; a rule moved above the
// one that accepts the browser shuts it out.
import { readFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
@@ -46,15 +48,53 @@ const open =
engine.webBrowser(browser)!.open(typed);
};
const post =
(typed: string, data: string) =>
(engine: NetworkRuntimeEngine, browser: string): void => {
engine.webBrowser(browser)!.open(typed, data);
};
const ping = (engine: NetworkRuntimeEngine, browser: string): void => {
engine.sendIcmpEcho(browser, '20.0.0.2', { sequenceNumber: 1 });
};
/** The form as Java's browser window posts it: every field, then the button pressed. */
const FORM = {
saveWithoutIcmp: 'firewallActivate=1&onlySYN=1&btnSave=speichern&defaultPolicy=accept&srcIP=&srcMask=&destIP=&destMask=&protocol=*&port=&action=drop',
addTcpDrop: 'firewallActivate=1&dropICMP=1&onlySYN=1&defaultPolicy=accept&srcIP=&srcMask=&destIP=&destMask=&protocol=TCP&port=80&action=drop&addrule=hinzuf%C3%BCgen',
moveSecondUp: 'firewallActivate=1&dropICMP=1&onlySYN=1&defaultPolicy=accept&radioRule=2&moveup=rauf&srcIP=&srcMask=&destIP=&destMask=&protocol=*&port=&action=drop',
};
const STORIES: Record<string, Story> = {
routerIndex: { scenario: 'router-web', browser: id(301), router: id(302), acts: [[500, open('10.0.0.1')]] },
routerRoutes: { scenario: 'router-web', browser: id(301), router: id(302), acts: [[500, open('10.0.0.1/routes')]] },
routerKonfig: { scenario: 'router-web', browser: id(301), router: id(302), acts: [[500, open('10.0.0.1/konfig')]] },
konfigIcmp: {
scenario: 'router-web',
browser: id(301),
router: id(302),
acts: [
[500, ping],
[3500, post('10.0.0.1/konfig', FORM.saveWithoutIcmp)],
[7000, ping],
],
},
konfigBlock: {
scenario: 'router-web',
browser: id(301),
router: id(302),
acts: [
[500, post('10.0.0.1/konfig', FORM.addTcpDrop)],
[4000, post('10.0.0.1/konfig', FORM.moveSecondUp)],
[8000, open('10.0.0.1')],
],
},
routerLog: {
scenario: 'router-web',
browser: id(301),
router: id(302),
acts: [
[500, (engine, browser) => engine.sendIcmpEcho(browser, '20.0.0.2', { sequenceNumber: 1 })],
[500, ping],
[3500, open('10.0.0.1')],
[7000, open('10.0.0.1/log')],
],
@@ -78,12 +118,21 @@ const withoutInitialNumbers = (text: string) =>
.replace(/(seq|ack)=(\d+)/g, (_, name: string, value: string) => `${name}=${Number(value) % 1_000_000}`)
.replace(/(SEQ|ACK): \d+\.(\d{3}\.\d{3})/g, '$1: n.$2');
/** Java's wait for a SYN's answer (TCPSocket: three times 2.75 s, its timeout here). */
const JAVA_TIMEOUT_MILLISECONDS = 3 * 2_750;
/**
* Where Java errs: its run corrected, as Java means it. routerLog: the
* German log page closes its title with </titel> (sw_firewallweblog_msg1);
* the engine writes </title>.
* the engine writes </title>. konfigBlock: the browser's SYN, dropped by the
* firewall, goes once in Java, not three times (webJava.test.ts, noServer);
* the page "server not reachable" comes two attempts later in the engine.
*/
const JAVA_BUGS: Record<string, (java: JavaRun) => JavaRun> = {
konfigBlock: (java) => ({
...java,
events: java.events.map(([time, who, text]) => (who === 'browser' && text.startsWith('page 0') ? [time + 2 * JAVA_TIMEOUT_MILLISECONDS, who, text] : [time, who, text])),
}),
routerLog: (java) => {
const fixed = (text: string | null) => text?.replace('</titel>', '</title>') ?? null;
const rows = (byCard: Record<string, (string | null)[][]> | undefined) =>
@@ -103,6 +152,11 @@ const PAGE_PLACEHOLDERS: Record<string, (text: string) => string> = {
ripRoutes: (text) => text.replace(/<td>-?\d+<\/td>(<td>[\d.]+<\/td><td><a )/g, '<td>validity</td>$1'),
};
/** Where the frames are compared only up to a time (Java's). konfigBlock: the engine's second SYN, which Java does not send. */
const UNTIL: Record<string, (java: JavaRun) => number> = {
konfigBlock: (java) => java.events.find(([, who, text]) => who === 'browser' && text.startsWith('page 0'))![0] - 250,
};
type Lines = Record<Program, [number, string][]>;
function javaLines(java: JavaRun): Lines {
@@ -162,6 +216,7 @@ describe('the pages of routers and home routers as Java FILIUS serves them', ()
cell: (text) => placeholders(withoutInitialNumbers(text)),
tables: java,
from: firstCall,
until: UNTIL[name]?.(measured),
starts: { java: 0, engine: 0 },
});
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+5
View File
@@ -137,6 +137,11 @@ describe('the web server', () => {
expect(ask(engine, 'HALLO', 'GET', 'GET /')).toEqual(['HTTP/1.1 501 Not Implemented\n', 'HTTP/1.1 400 Bad Request\n', 'HTTP/1.1 200 OK\nContent-type: text/html\n\nStart']);
});
it('answers only GET and POST as Java writes them: a method in small letters is read, then answered 501 (WebServerMitarbeiter)', () => {
const engine = startedEngine(network(files({ '/webserver/index.html': 'Start' })));
expect(ask(engine, 'get /', 'Post /\n\nx=1')).toEqual(['HTTP/1.1 501 Not Implemented\n', 'HTTP/1.1 501 Not Implemented\n']);
});
it('serves nothing outside its document root (Java serves any file of the host), and no folder', () => {
const engine = startedEngine(network(files({ '/geheim.txt': 'geheim', '/webserver/ordner/a.txt': 'a' })));
expect(ask(engine, 'GET /../geheim.txt HTTP/1.1', 'GET /ordner/../../geheim.txt HTTP/1.1', 'GET /ordner HTTP/1.1', 'GET /ordner/./a.txt?x HTTP/1.1')).toEqual([