Match service worker routes on the decoded path
CI / ci (push) Successful in 1m34s
CI / ci (pull_request) Successful in 1m33s
Independent review Codex gpt-6-sol high: APPROVED (0 findings)

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 <noreply@anthropic.com>
This commit is contained in:
2026-10-04 16:30:27 -04:00
co-authored by Claude Opus 5.5
parent 3e95514053
commit 9a5d0a7912
2 changed files with 61 additions and 4 deletions
+28
View File
@@ -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'])( it.each(['POST', 'PUT', 'PATCH', 'DELETE', 'HEAD'])(
'%s is network-only on every URL class', '%s is network-only on every URL class',
(method) => { (method) => {
+33 -4
View File
@@ -3,7 +3,10 @@
// class without a worker; src/sw.ts maps each route to a Workbox strategy. // 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 // 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 = export type Route =
// Network with a 3 s timeout, then the last cached response. // 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}/`) 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' } const networkOnly: Route = { strategy: 'network-only' }
export function routeFor(request: RouteRequest, origin: string): Route { export function routeFor(request: RouteRequest, origin: string): Route {
const { method, url, mode } = request const { method, url, mode } = request
if (method !== 'GET') return networkOnly if (method !== 'GET') return networkOnly
if (url.origin !== origin) return networkOnly if (url.origin !== origin) return networkOnly
const path = url.pathname const path = decodePath(url.pathname)
if (neverCached.some((prefix) => under(path, prefix))) return networkOnly 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))) { if (networkFirst.some((prefix) => under(path, prefix))) {
return { strategy: 'network-first', cacheName: cacheNames.api } return { strategy: 'network-first', cacheName: cacheNames.api }
} }
if (under(path, '/media')) return { strategy: 'cache-first', cacheName: cacheNames.media } if (under(path, '/media')) return { strategy: 'cache-first', cacheName: cacheNames.media }
if (path.endsWith('.woff2')) return { strategy: 'cache-first', cacheName: cacheNames.fonts } 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' } if (mode === 'navigate') return { strategy: 'navigation-fallback' }
return networkOnly return networkOnly
} }