waffensachkunde
Waffensachkunde – Lernsoftware für die Sachkundeprüfung nach § 7 WaffG. Barrierefrei, offline, EUPL-1.2.
/ app tests sicherheit.test.ts
| 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 | }); |