From 9a5d0a79120aa63bcb7c8235cafef20ccabecc7c Mon Sep 17 00:00:00 2001 From: dev_agent Date: Sun, 4 Oct 2026 16:30:27 -0400 Subject: [PATCH] Match service worker routes on the decoded path Offline navigations to /%61uth/login or /%61pi/v1/settings/household missed the reserved prefixes and were answered with the precached index.html. routeFor now percent-decodes the pathname and checks reserved prefixes before and after dot segments are resolved, as the server does; a malformed encoding is network-only. Co-Authored-By: Claude Opus 5.5 --- web/src/sw/routes.test.ts | 28 ++++++++++++++++++++++++++++ web/src/sw/routes.ts | 37 +++++++++++++++++++++++++++++++++---- 2 files changed, 61 insertions(+), 4 deletions(-) diff --git a/web/src/sw/routes.test.ts b/web/src/sw/routes.test.ts index f9298ec..784fa68 100644 --- a/web/src/sw/routes.test.ts +++ b/web/src/sw/routes.test.ts @@ -56,6 +56,34 @@ describe('routeFor', () => { }, ) + it.each([ + '/%61uth/login', + '/auth%2Fcallback?code=x&state=y', + '/%61pi/v1/settings/household', + '/api/v1/%73ettings/ai', + '/api/v1/settings%2Fai', + '/%61pi/v1/jobs/01J9ZK', + '/api/v1/j%6Fbs', + '/api/v1/plans%2F..%2Fsettings', + '/plan%2F..%2Fauth%2Flogin', + ])('an encoded reserved path %s is network-only, navigated or not', (path) => { + expect(route(path)).toEqual(networkOnly) + expect(route(path, 'GET', 'navigate')).toEqual(networkOnly) + }) + + it.each(['/%E0%A4%A', '/auth%', '/plan/%zz', '/api/v1/plans/%C3'])( + 'a malformed encoding in %s is network-only, navigated or not', + (path) => { + expect(route(path)).toEqual(networkOnly) + expect(route(path, 'GET', 'navigate')).toEqual(networkOnly) + }, + ) + + it('classifies other API paths by their decoded form', () => { + expect(route('/%61pi/v1/plans/2026-W40')).toEqual(apiCache) + expect(route('/%61pi/v1/recipes', 'GET', 'navigate')).toEqual(networkOnly) + }) + it.each(['POST', 'PUT', 'PATCH', 'DELETE', 'HEAD'])( '%s is network-only on every URL class', (method) => { diff --git a/web/src/sw/routes.ts b/web/src/sw/routes.ts index 2d097bb..e9ff348 100644 --- a/web/src/sw/routes.ts +++ b/web/src/sw/routes.ts @@ -3,7 +3,10 @@ // class without a worker; src/sw.ts maps each route to a Workbox strategy. // // Security rule: /auth/*, /api/v1/settings/*, /api/v1/jobs/* and every -// non-GET request are network-only and never reach a cache. +// non-GET request are network-only and never reach a cache. Paths are matched +// as the server sees them — percent-decoded, before and after dot segments are +// resolved — so /%61uth/login is /auth/login; a malformed encoding is +// network-only. export type Route = // Network with a 3 s timeout, then the last cached response. @@ -42,20 +45,46 @@ function under(path: string, prefix: string): boolean { return path === prefix || path.startsWith(`${prefix}/`) } +// The pathname percent-decoded, as the server's router sees it, or undefined +// when the encoding is malformed. +function decodePath(pathname: string): string | undefined { + try { + return decodeURIComponent(pathname) + } catch { + return undefined + } +} + +// Resolves "." and ".." segments the way the server's path.Clean does, so a +// decoded %2F cannot carry a request out of a reserved prefix: +// /api/v1/plans/../settings cleans to /api/v1/settings. +function cleanPath(path: string): string { + const segments: string[] = [] + for (const segment of path.split('/')) { + if (segment === '' || segment === '.') continue + if (segment === '..') segments.pop() + else segments.push(segment) + } + return `/${segments.join('/')}` +} + const networkOnly: Route = { strategy: 'network-only' } export function routeFor(request: RouteRequest, origin: string): Route { const { method, url, mode } = request if (method !== 'GET') return networkOnly if (url.origin !== origin) return networkOnly - const path = url.pathname - if (neverCached.some((prefix) => under(path, prefix))) return networkOnly + const path = decodePath(url.pathname) + if (path === undefined) return networkOnly + const cleaned = cleanPath(path) + const reserved = (prefix: string) => under(path, prefix) || under(cleaned, prefix) + if (neverCached.some(reserved)) return networkOnly if (networkFirst.some((prefix) => under(path, prefix))) { return { strategy: 'network-first', cacheName: cacheNames.api } } if (under(path, '/media')) return { strategy: 'cache-first', cacheName: cacheNames.media } if (path.endsWith('.woff2')) return { strategy: 'cache-first', cacheName: cacheNames.fonts } - if (under(path, '/api')) return networkOnly + if (reserved('/api')) return networkOnly if (mode === 'navigate') return { strategy: 'navigation-fallback' } return networkOnly }