waffensachkunde

Waffensachkunde – Lernsoftware für die Sachkundeprüfung nach § 7 WaffG. Barrierefrei, offline, EUPL-1.2.

/ app tests sicherheit.test.ts

8,3 KB Rohdatei
app/tests/sicherheit.test.ts — 213 Zeilen
1 // @vitest-environment node
2 // Reiner Dateisystem-Test – jsdom wird hier nicht gebraucht und würde
3 // `import.meta.url` auf eine http-URL setzen.
4
5 import { readFileSync } from 'node:fs';
6 import { join } from 'node:path';
7 import { fileURLToPath } from 'node:url';
8
9 import { describe, expect, it } from 'vitest';
10
11 import { istGleicheDatei } from '../src/main/oberflaeche';
12 import { cspRichtlinie } from '../src/shared/csp';
13
14 /**
15 * Schutzgitter für die nicht verhandelbaren Sicherheitsschalter.
16 *
17 * Electron 43 bietet keine öffentliche API mehr, um die tatsächlich
18 * angewandten `webPreferences` zur Laufzeit auszulesen. Dieser Test liest
19 * deshalb die Quelle und schlägt fehl, sobald jemand einen der drei harten
20 * Schalter umstellt oder entfernt. Das beobachtbare Verhalten prüfen
21 * zusätzlich die E2E-Tests in `e2e/anwendung.spec.ts`.
22 */
23
24 const wurzel = fileURLToPath(new URL('..', import.meta.url));
25
26 function quelle(relativerPfad: string): string {
27 return readFileSync(join(wurzel, relativerPfad), 'utf8');
28 }
29
30 describe('webPreferences des Hauptfensters', () => {
31 const fensterQuelle = quelle('src/main/fenster.ts');
32
33 it.each([
34 ['contextIsolation', 'true'],
35 ['nodeIntegration', 'false'],
36 ['sandbox', 'true'],
37 ['nodeIntegrationInWorker', 'false'],
38 ['nodeIntegrationInSubFrames', 'false'],
39 ['webSecurity', 'true'],
40 ['allowRunningInsecureContent', 'false'],
41 ['webviewTag', 'false'],
42 ])('setzt %s auf %s', (schalter, wert) => {
43 expect(fensterQuelle).toMatch(new RegExp(`\\b${schalter}\\s*:\\s*${wert}\\s*,`, 'u'));
44 });
45
46 it('lädt das Preload-Skript aus dem gebauten Ausgabeverzeichnis', () => {
47 expect(fensterQuelle).toContain("join(__dirname, '../preload/index.js')");
48 });
49 });
50
51 describe('Content-Security-Policy', () => {
52 /*
53 * Was hier gemessen wird – und was ausdrücklich nicht.
54 *
55 * In dieser Datei läuft kein Browser. Geprüft wird der **Wortlaut** der
56 * Richtlinie, nicht ihre Wirkung. Genau daran hat dieser Block schon einmal
57 * grün gemeldet, während nichts galt: `toContain("frame-ancestors 'none'")`
58 * war erfüllt, obwohl Chromium die Direktive im `<meta>`-Element verwirft
59 * und bei jedem Start einen Konsolenfehler darüber schrieb. Eine Zusicherung,
60 * die mehr verspricht als sie hält, ist schlimmer als keine – deshalb steht
61 * der Satz hier und nicht im Nachhinein.
62 *
63 * Die Wirkung misst `e2e/anwendung.spec.ts` im echten Fenster: dort werden
64 * die tatsächlich angewandten Richtlinien über `securitypolicyviolation`
65 * ausgelesen und die Konsole beim Laden mitgehört.
66 *
67 * Hier bleibt, was ohne Browser beantwortbar ist: ob der Wortlaut stimmt und
68 * ob die beiden Fassungen (`<meta>` und HTTP-Kopf) zueinander passen.
69 */
70 const produktionKopf = cspRichtlinie('production', 'kopf');
71 const produktionMeta = cspRichtlinie('production', 'meta');
72 const entwicklungKopf = cspRichtlinie('development', 'kopf');
73 const entwicklungMeta = cspRichtlinie('development', 'meta');
74
75 /** Beide Fassungen des Produktionsbuilds, für die gemeinsamen Zusicherungen. */
76 const produktion: readonly [string, string][] = [
77 ['HTTP-Kopf', produktionKopf],
78 ['<meta>', produktionMeta],
79 ];
80
81 it.each(produktion)('sperrt im Produktionsbuild alles per Vorgabe (%s)', (_weg, richtlinie) => {
82 expect(richtlinie).toContain("default-src 'none'");
83 });
84
85 it.each(produktion)(
86 'erlaubt im Produktionsbuild weder unsafe-inline noch unsafe-eval (%s)',
87 (_weg, richtlinie) => {
88 expect(richtlinie).not.toContain('unsafe-inline');
89 expect(richtlinie).not.toContain('unsafe-eval');
90 },
91 );
92
93 it.each(produktion)(
94 'verbietet Formularversand, fremde Basis-URLs, Objekte und eigene Rahmen (%s)',
95 (_weg, richtlinie) => {
96 expect(richtlinie).toContain("form-action 'none'");
97 expect(richtlinie).toContain("base-uri 'none'");
98 expect(richtlinie).toContain("object-src 'none'");
99 // `frame-src` wirkt auch im `<meta>`: Die eigene Seite darf nichts einbetten.
100 expect(richtlinie).toContain("frame-src 'none'");
101 },
102 );
103
104 it.each(produktion)(
105 'beschränkt Netzwerkverbindungen auf die eigene Herkunft (%s)',
106 (_weg, richtlinie) => {
107 expect(richtlinie).toContain("connect-src 'self'");
108 expect(richtlinie).not.toMatch(/connect-src[^;]*https?:/u);
109 },
110 );
111
112 /**
113 * Direktiven, die jeder Browser im `<meta>`-Element ignoriert (CSP Level 3).
114 * Sie gehören ausschließlich in den HTTP-Kopf.
115 */
116 const NUR_IM_KOPF = ['frame-ancestors', 'report-uri', 'sandbox'] as const;
117
118 /** Namen der gesetzten Direktiven, in der Reihenfolge der Richtlinie. */
119 function direktivnamen(richtlinie: string): string[] {
120 return richtlinie.split('; ').map((teil) => teil.split(' ')[0] ?? '');
121 }
122
123 it('setzt frame-ancestors dort, wo die Direktive gilt: im HTTP-Kopf', () => {
124 expect(produktionKopf).toContain("frame-ancestors 'none'");
125 expect(entwicklungKopf).toContain("frame-ancestors 'none'");
126 });
127
128 it.each([
129 ['Produktion', produktionMeta],
130 ['Entwicklung', entwicklungMeta],
131 ])(
132 'lässt aus der <meta>-Fassung jede Direktive weg, die dort ignoriert würde (%s)',
133 (_modus, richtlinie) => {
134 /* Nicht nur `frame-ancestors`: Wer später `sandbox` oder `report-uri`
135 ergänzt, soll hier auffallen und nicht erst am Konsolenfehler beim
136 Start. Ein `<meta>`, das eine ignorierte Direktive nennt, kostet
137 nichts – behauptet aber Schutz, den es nicht gibt. */
138 for (const direktive of NUR_IM_KOPF) {
139 expect(direktivnamen(richtlinie)).not.toContain(direktive);
140 }
141 },
142 );
143
144 it.each([
145 ['Produktion', produktionKopf, produktionMeta],
146 ['Entwicklung', entwicklungKopf, entwicklungMeta],
147 ])(
148 'hält beide Fassungen deckungsgleich – bis auf die Nur-Kopf-Direktiven (%s)',
149 (_modus, kopf, meta) => {
150 /* Wäre die Kopf-Fassung an anderer Stelle strenger als die
151 `<meta>`-Fassung, bräche der Dev-Modus unbemerkt an der Schnittmenge
152 beider Richtlinien. Verglichen wird deshalb Zeichen für Zeichen. */
153 const erwartet = kopf
154 .split('; ')
155 .filter((teil) => !NUR_IM_KOPF.some((d) => teil.startsWith(`${d} `)))
156 .join('; ');
157 expect(meta).toBe(erwartet);
158 },
159 );
160
161 it('lockert im Dev-Modus nur genau das, was Vite braucht', () => {
162 expect(entwicklungKopf).toContain("script-src 'self' 'unsafe-inline'");
163 expect(entwicklungKopf).toContain('ws:');
164 // Auch im Dev-Modus bleibt eval verboten.
165 expect(entwicklungKopf).not.toContain('unsafe-eval');
166 });
167 });
168
169 describe('Navigationsziel', () => {
170 /*
171 * Das Protokoll `file:` allein darf nicht genügen. Sonst käme jede Datei auf
172 * der Platte in Frage – und das Preload-Skript hängt am `webContents`, wird
173 * also auch für die neue Seite wieder eingespielt. Eine heruntergeladene
174 * HTML-Datei hätte damit die volle Bridge in der Hand.
175 */
176 const eigene = 'file:///C:/Programme/Waffensachkunde/resources/app/out/renderer/index.html';
177
178 it('lässt genau die eigene Oberfläche zu', () => {
179 expect(istGleicheDatei(eigene, eigene)).toBe(true);
180 });
181
182 it('lässt Sprungziele und Parameter innerhalb der eigenen Seite zu', () => {
183 expect(istGleicheDatei(`${eigene}#inhalt`, eigene)).toBe(true);
184 expect(istGleicheDatei(`${eigene}?x=1`, eigene)).toBe(true);
185 });
186
187 it('weist jede andere Datei ab', () => {
188 for (const fremd of [
189 'file:///C:/Users/Test/Downloads/beliebig.html',
190 'file:///C:/Windows/win.ini',
191 'file:///C:/Programme/Waffensachkunde/resources/app/out/renderer/andere.html',
192 'file:///C:/Programme/Waffensachkunde/resources/app/out/index.html',
193 ]) {
194 expect(istGleicheDatei(fremd, eigene)).toBe(false);
195 }
196 });
197
198 it('nimmt Groß- und Kleinschreibung des Pfades hin', () => {
199 // Windows liefert den Laufwerksbuchstaben nicht immer gleich geschrieben.
200 expect(istGleicheDatei(eigene.replace('C:', 'c:'), eigene)).toBe(true);
201 });
202
203 it('behandelt prozentkodierte Pfade wie ihre Klartextform', () => {
204 const mitLeerzeichen = 'file:///C:/Mein%20Ordner/index.html';
205 expect(istGleicheDatei('file:///C:/Mein Ordner/index.html', mitLeerzeichen)).toBe(true);
206 });
207
208 it('weist andere Protokolle und Unsinn ab', () => {
209 for (const fremd of ['http://example.com/', 'javascript:alert(1)', 'nichts', '']) {
210 expect(istGleicheDatei(fremd, eigene)).toBe(false);
211 }
212 });
213 });