From 67d11952589c9a18a7b8b92f21a5937d4b852752 Mon Sep 17 00:00:00 2001 From: iPoower <101516206+iPoower@users.noreply.github.com> Date: Mon, 26 Oct 2026 16:00:50 +0100 Subject: [PATCH 1/4] test(fortress): reproduce recovery HTML cache poisoning in CV service worker [TEST ONLY] --- tests/unit/service-worker.test.ts | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/tests/unit/service-worker.test.ts b/tests/unit/service-worker.test.ts index 8e9734b..4f01f8c 100644 --- a/tests/unit/service-worker.test.ts +++ b/tests/unit/service-worker.test.ts @@ -139,4 +139,18 @@ describe('Service Worker : isolation interprojets', () => { expect(h.written).toEqual([{ cache: 'cv-shell-v3', request: asset }]); expect(h.matched.every(k => k.startsWith('cv-shell-v3:'))).toBe(true); }); + it('ne remplace jamais le shell par une navigation vers /recovery ou un JSON de configuration', () => { + const h = harness(['cv-shell-v3'], { fetch: async () => response(true, 'text/html') }); + const paths = ['recovery', 'recovery.html', 'drive-config.json', 'fixture-manifest.json']; + for (const path of paths) { + let intercepted = false; + h.listeners.get('fetch')?.({ + request: { method: 'GET', mode: 'navigate', url: 'https://ipoower.github.io/Control-Vault/' + path }, + respondWith: () => { intercepted = true; }, + }); + expect(intercepted, 'le navigateur doit aller directement au réseau pour ' + path).toBe(false); + } + expect(h.written).toEqual([]); + }); + }); From da7f0421e00f3a8690256352e203fc7ee5e2808a Mon Sep 17 00:00:00 2001 From: iPoower <101516206+iPoower@users.noreply.github.com> Date: Mon, 26 Oct 2026 16:04:45 +0100 Subject: [PATCH 2/4] fix(fortress): constrain CV offline shell fallback to app entry points [TEST ONLY] --- public/sw.js | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/public/sw.js b/public/sw.js index a3d98d8..fbdc034 100644 --- a/public/sw.js +++ b/public/sw.js @@ -37,6 +37,10 @@ self.addEventListener('fetch', (event) => { // Navigation : réseau d'abord (toujours la dernière version), cache en secours hors ligne. if (req.mode === 'navigate') { + // Une page de reprise, une configuration ou tout autre document HTML ne doit + // jamais écraser ./index.html dans CacheStorage. Seuls les alias du shell + // bénéficient du repli hors ligne. + if (![APP_PATH, APP_PATH + 'index', APP_PATH + 'index.html'].includes(url.pathname)) return; event.respondWith( fetch(req) .then((res) => { From 53fd80f009eafa8a15d0386bce17e248b04b2f2d Mon Sep 17 00:00:00 2001 From: iPoower <101516206+iPoower@users.noreply.github.com> Date: Mon, 26 Oct 2026 16:13:01 +0100 Subject: [PATCH 3/4] test(fortress): require v4 cache migration and exact Cloudflare TEST origin boundaries --- tests/unit/service-worker.test.ts | 47 ++++++++++++++++++++++--------- 1 file changed, 34 insertions(+), 13 deletions(-) diff --git a/tests/unit/service-worker.test.ts b/tests/unit/service-worker.test.ts index 4f01f8c..18fdfdc 100644 --- a/tests/unit/service-worker.test.ts +++ b/tests/unit/service-worker.test.ts @@ -11,7 +11,7 @@ type TestEvent = { request?: { method: string; mode: string; url: string }; }; -function harness(initial: string[], options: { fetch?: () => Promise; hit?: boolean; failPut?: boolean } = {}) { +function harness(initial: string[], options: { fetch?: () => Promise; hit?: boolean; failPut?: boolean; origin?: string; scope?: string } = {}) { const names = new Set(initial); const deleted: string[] = []; const listeners = new Map void>(); @@ -20,8 +20,8 @@ function harness(initial: string[], options: { fetch?: () => Promise; h let claimed = false; const self = { - location: { origin: 'https://ipoower.github.io' }, - registration: { scope: 'https://ipoower.github.io/Control-Vault/' }, + location: { origin: options.origin ?? 'https://ipoower.github.io' }, + registration: { scope: options.scope ?? 'https://ipoower.github.io/Control-Vault/' }, addEventListener: (type: string, fn: (event: TestEvent) => void) => listeners.set(type, fn), skipWaiting: () => {}, clients: { claim: async () => { claimed = true; } }, @@ -57,7 +57,7 @@ function harness(initial: string[], options: { fetch?: () => Promise; h describe('Service Worker : isolation interprojets', () => { it('ne supprime que les ANCIENS caches Control Vault et préserve Race Control et les autres applications', async () => { - const h = harness(['cv-shell-v0', 'cv-shell-v1', 'cv-shell-v2', 'cv-shell-v3', 'twrc-shell-v17', 'twrc-weather-v4', 'unrelated-cache']); + const h = harness(['cv-shell-v0', 'cv-shell-v1', 'cv-shell-v2', 'cv-shell-v3', 'cv-shell-v4', 'twrc-shell-v17', 'twrc-weather-v4', 'unrelated-cache']); const activate = h.listeners.get('activate'); expect(activate).toBeTypeOf('function'); let pending: Promise | undefined; @@ -65,8 +65,8 @@ describe('Service Worker : isolation interprojets', () => { expect(pending).toBeDefined(); await pending; - expect(h.deleted.sort()).toEqual(['cv-shell-v0', 'cv-shell-v1', 'cv-shell-v2']); - expect([...h.names].sort()).toEqual(['cv-shell-v3', 'twrc-shell-v17', 'twrc-weather-v4', 'unrelated-cache'].sort()); + expect(h.deleted.sort()).toEqual(['cv-shell-v0', 'cv-shell-v1', 'cv-shell-v2', 'cv-shell-v3']); + expect([...h.names].sort()).toEqual(['cv-shell-v4', 'twrc-shell-v17', 'twrc-weather-v4', 'unrelated-cache'].sort()); expect(h.isClaimed()).toBe(true); }); @@ -80,7 +80,7 @@ describe('Service Worker : isolation interprojets', () => { }); it('utilise encore la page hors ligne en cas d’échec réseau', async () => { - const h = harness(['cv-shell-v3', 'twrc-shell-v17']); + const h = harness(['cv-shell-v4', 'twrc-shell-v17']); let response: Promise | undefined; h.listeners.get('fetch')?.({ request: { method: 'GET', mode: 'navigate', url: 'https://ipoower.github.io/Control-Vault/' }, @@ -88,12 +88,12 @@ describe('Service Worker : isolation interprojets', () => { }); expect(response).toBeDefined(); await expect(response).resolves.toEqual({ offlineShell: true }); - expect(h.matched).toEqual(['cv-shell-v3:./index.html']); + expect(h.matched).toEqual(['cv-shell-v4:./index.html']); expect(h.deleted).toEqual([]); }); it('ignore les requêtes externes et non GET', () => { - const h = harness(['cv-shell-v3', 'twrc-shell-v17']); + const h = harness(['cv-shell-v4', 'twrc-shell-v17']); let responded = false; for (const request of [ { method: 'POST', mode: 'navigate', url: 'https://ipoower.github.io/Control-Vault/' }, @@ -117,7 +117,7 @@ describe('Service Worker : isolation interprojets', () => { it('attend la mise en cache de l’interface réseau dans son seul cache', async () => { const res = response(), h = harness(['twrc-shell-v17'], { fetch: async () => res }); expect(await request(h)).toBe(res); - expect(h.written).toEqual([{ cache: 'cv-shell-v3', request: './index.html' }]); + expect(h.written).toEqual([{ cache: 'cv-shell-v4', request: './index.html' }]); expect(h.deleted).toEqual([]); }); it('ne remplace pas son interface hors ligne par une erreur HTTP ou une réponse JSON', async () => { @@ -136,11 +136,11 @@ describe('Service Worker : isolation interprojets', () => { const res = response(true, 'text/javascript'), h = harness(['twrc-shell-v17'], { fetch: async () => res, hit: false }); const asset = { ...navigation, mode: 'cors', url: 'https://ipoower.github.io/Control-Vault/assets/fiction.js' }; expect(await request(h, asset)).toBe(res); - expect(h.written).toEqual([{ cache: 'cv-shell-v3', request: asset }]); - expect(h.matched.every(k => k.startsWith('cv-shell-v3:'))).toBe(true); + expect(h.written).toEqual([{ cache: 'cv-shell-v4', request: asset }]); + expect(h.matched.every(k => k.startsWith('cv-shell-v4:'))).toBe(true); }); it('ne remplace jamais le shell par une navigation vers /recovery ou un JSON de configuration', () => { - const h = harness(['cv-shell-v3'], { fetch: async () => response(true, 'text/html') }); + const h = harness(['cv-shell-v4'], { fetch: async () => response(true, 'text/html') }); const paths = ['recovery', 'recovery.html', 'drive-config.json', 'fixture-manifest.json']; for (const path of paths) { let intercepted = false; @@ -153,4 +153,25 @@ describe('Service Worker : isolation interprojets', () => { expect(h.written).toEqual([]); }); + it('applique aussi les frontières TEST Cloudflare à la racine, sans bloquer le shell', () => { + const origin = 'https://cv-drive-test-ipoower.pages.dev'; + const h = harness(['cv-shell-v3'], { origin, scope: origin + '/' }); + for (const pathname of ['/recovery', '/recovery.html', '/drive-config.json', '/fixture-manifest.json', '/autre-page.html']) { + let intercepted = false; + h.listeners.get('fetch')?.({ + request: { method: 'GET', mode: 'navigate', url: origin + pathname }, + respondWith: () => { intercepted = true; }, + }); + expect(intercepted, 'ne pas intercepter la route secondaire ' + pathname).toBe(false); + } + for (const pathname of ['/', '/index', '/index.html']) { + let intercepted = false; + h.listeners.get('fetch')?.({ + request: { method: 'GET', mode: 'navigate', url: origin + pathname }, + respondWith: () => { intercepted = true; }, + }); + expect(intercepted, 'la route shell doit rester interceptée ' + pathname).toBe(true); + } + }); + }); From 13d4d028ba53265c9c4ba86f9a00c00feb193f45 Mon Sep 17 00:00:00 2001 From: iPoower <101516206+iPoower@users.noreply.github.com> Date: Mon, 26 Oct 2026 16:13:12 +0100 Subject: [PATCH 4/4] fix(fortress): rotate CV offline cache after HTML recovery route poisoning [TEST ONLY] --- public/sw.js | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/public/sw.js b/public/sw.js index fbdc034..a290f1b 100644 --- a/public/sw.js +++ b/public/sw.js @@ -4,7 +4,9 @@ // CacheStorage est partagé par toutes les applications d'une même origine GitHub Pages. // Ne jamais supprimer les caches Race Control ou les caches tiers. const CACHE_PREFIX = 'cv-shell-'; -const VERSION = `${CACHE_PREFIX}v3`; +// Renouveler le namespace protège contre un ancien shell déjà remplacé par une page annexe. +// La migration efface uniquement les anciens caches cv-shell-, jamais les autres applications. +const VERSION = `${CACHE_PREFIX}v4`; const APP_PATH = new URL(self.registration.scope).pathname; const cached = (request) => caches.open(VERSION).then((cache) => cache.match(request)); async function remember(request, response) {