Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
78 changes: 73 additions & 5 deletions src/plugins/pwa/serviceWorkerHandlers.js
Original file line number Diff line number Diff line change
Expand Up @@ -32,10 +32,13 @@
* - Every build asset listed in the precache manifest is fetched and stored in
* a cache named after the build version during `install`. Requests for those
* assets are then served cache-first, so the application loads offline.
* - Navigation requests (page loads) are served network-first. Successful
* responses are cached so the page can be reopened offline; if the network is
* unavailable and no copy of the requested page exists, the precached
* `index.html` is used as a fallback.
* - Navigation requests (page loads) are served network-first. A successful
* response is cached so the page can be reopened offline, but only when the
* response is safe to persist: a response guarded by `Cache-Control`
* directives such as `no-store`, `private` or `no-cache` may contain
* authenticated or user-specific data and is never written to the cache.
* If the network is unavailable and no copy of the requested page exists,
* the precached `index.html` is used as a fallback.
* - Every other request (cross-origin requests, non-GET requests, telemetry
* and persistence API calls, etc.) is passed straight to the network and is
* never cached.
Expand All @@ -46,6 +49,23 @@ export const CACHE_NAME_PREFIX = 'openmct-pwa-';
export const NAVIGATION_FALLBACK_ASSET = 'index.html';
export const SKIP_WAITING_MESSAGE_TYPE = 'SKIP_WAITING';

/**
* `Cache-Control` response directives that make a response unsuitable for
* storage in the service worker cache, per RFC 9111:
*
* - `no-store`: the server explicitly forbids storing the response anywhere.
* - `private`: the response is intended for a single user and must not be
* stored by a shared cache. The service worker cache outlives the page (and
* potentially the session) that requested it, so it is treated as a shared
* cache here.
* - `no-cache`: a stored copy must be revalidated with the server before it
* can be reused. Revalidation is impossible while offline, so a stored copy
* could never legitimately be served and is not stored at all.
*
* @type {Set<string>}
*/
const NON_CACHEABLE_DIRECTIVES = new Set(['no-store', 'no-cache', 'private']);

/**
* @typedef {Object} PrecacheManifest
* @property {string} version A string that uniquely identifies the build.
Expand Down Expand Up @@ -141,12 +161,23 @@ export function createServiceWorkerHandlers({ manifest, scriptUrl, caches, fetch

try {
const response = await fetch(request);
if (response.ok && !response.redirected) {

// SECURITY: only persist responses that are safe to cache. A navigation
// response guarded by `Cache-Control: no-store`, `private` or
// `no-cache` may contain authenticated or user-specific data; writing
// it to the persistent Cache Storage would expose that data to later
// (potentially unauthenticated) offline sessions and to anyone able to
// inspect the browser profile. The live response is still returned to
// the browser untouched.
if (isCacheableNavigationResponse(response)) {
await cache.put(cacheKey, response.clone());
}

return response;
} catch (error) {
// Network-first fallback for offline use: serve the page as it was seen
// last time, otherwise the precached application shell. Pages withheld
// from the cache above can, by design, never be served here.
const cachedResponse =
(await cache.match(cacheKey)) ?? (await cache.match(navigationFallbackUrl));
if (cachedResponse) {
Expand Down Expand Up @@ -180,6 +211,43 @@ export function createServiceWorkerHandlers({ manifest, scriptUrl, caches, fetch
};
}

/**
* Determines whether a navigation response may be written to the offline
* cache. Only complete, non-redirected responses that are not explicitly
* guarded against storage are persisted; anything else is passed through to
* the browser without being cached.
*
* @param {Response} response
* @returns {boolean}
*/
function isCacheableNavigationResponse(response) {
return (
response.status === 200 &&
!response.redirected &&
!cacheControlForbidsCaching(response.headers.get('Cache-Control'))
);
}

/**
* Parses a `Cache-Control` header value and reports whether it contains a
* directive that forbids storing the response. Directive matching is
* case-insensitive and ignores directive arguments such as the field list in
* `no-cache="Set-Cookie"`, in line with RFC 9111.
*
* @param {string | null} cacheControl The raw `Cache-Control` header value, if any.
* @returns {boolean} `true` when the response must not be cached.
*/
function cacheControlForbidsCaching(cacheControl) {
if (!cacheControl) {
return false;
}

return cacheControl
.split(',')
.map((directive) => directive.split('=', 1)[0].trim().toLowerCase())
.some((directive) => NON_CACHEABLE_DIRECTIVES.has(directive));
}

/**
* Cache entries are keyed by origin and path only, so that a request for
* `openmct.js?v=2` is served by the precached `openmct.js`.
Expand Down
220 changes: 220 additions & 0 deletions src/plugins/pwa/serviceWorkerHandlersSecuritySpec.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,220 @@
/*****************************************************************************
* Open MCT, Copyright (c) 2014-2024, United States Government
* as represented by the Administrator of the National Aeronautics and Space
* Administration. All rights reserved.
*
* Open MCT is licensed under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* http://www.apache.org/licenses/LICENSE-2.0.
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
* License for the specific language governing permissions and limitations
* under the License.
*
* Open MCT includes source code licensed under additional open source
* licenses. See the Open Source Licenses file (LICENSES.md) included with
* this source code distribution or the Licensing information page available
* at runtime from the About dialog for additional information.
*****************************************************************************/

import { CACHE_NAME_PREFIX, createServiceWorkerHandlers } from './serviceWorkerHandlers.js';

const SCRIPT_URL = `${window.location.origin}/app/serviceWorker.js`;
const MANIFEST = {
version: 'test-build-security',
assets: ['index.html']
};

// A hardened navigation cache must never persist a response whose
// `Cache-Control` header forbids storage, but must keep caching ordinary
// public pages so that the offline experience is not silently lost.
describe('The PWA service worker navigation cache security', () => {
let handlers;
let fetchSpy;
let clients;
let responses;

function navigationResponse(body, cacheControl) {
const headers = { 'Content-Type': 'text/html' };
if (cacheControl !== undefined) {
headers['Cache-Control'] = cacheControl;
}

return new Response(body, { status: 200, headers });
}

// Pages cannot construct a Request whose mode is 'navigate', so navigations are
// represented by a plain object exposing the properties the handlers read.
function navigationRequest(url) {
return { method: 'GET', url, mode: 'navigate' };
}

async function deleteTestCaches() {
const names = await caches.keys();
await Promise.all(
names.filter((name) => name.startsWith(CACHE_NAME_PREFIX)).map((name) => caches.delete(name))
);
}

async function cachedResponseFor(url) {
const cache = await caches.open(handlers.cacheName);

return cache.match(url);
}

beforeEach(async () => {
await deleteTestCaches();
responses = {};
fetchSpy = jasmine.createSpy('fetch').and.callFake((request) => {
const key = new URL(request.url).pathname;
const factory = responses[key];
if (!factory) {
return Promise.reject(new TypeError(`Failed to fetch ${request.url}`));
}

return Promise.resolve(factory());
});
clients = jasmine.createSpyObj('clients', { claim: Promise.resolve() });
handlers = createServiceWorkerHandlers({
manifest: MANIFEST,
scriptUrl: SCRIPT_URL,
caches,
fetch: fetchSpy,
clients
});

// Precache the app shell so that the offline fallback is available.
responses['/app/index.html'] = () => navigationResponse('app shell');
await handlers.install();
fetchSpy.calls.reset();
});

afterEach(async () => {
await deleteTestCaches();
});

describe('cacheable navigation responses', () => {
it('caches a 200 response that has no Cache-Control header', async () => {
responses['/app/'] = () => navigationResponse('plain page');

await handlers.handleFetch(navigationRequest(`${window.location.origin}/app/`));

const cached = await cachedResponseFor(`${window.location.origin}/app/`);
expect(cached).toBeDefined();
expect(await cached.text()).toBe('plain page');
});

it('caches a 200 response that is explicitly marked public', async () => {
responses['/app/'] = () => navigationResponse('public page', 'public, max-age=3600');

await handlers.handleFetch(navigationRequest(`${window.location.origin}/app/`));

const cached = await cachedResponseFor(`${window.location.origin}/app/`);
expect(await cached.text()).toBe('public page');
});

it('caches a response with max-age=0, which asks for revalidation but does not forbid storage', async () => {
responses['/app/'] = () => navigationResponse('revalidating page', 'max-age=0');

await handlers.handleFetch(navigationRequest(`${window.location.origin}/app/`));

expect(await cachedResponseFor(`${window.location.origin}/app/`)).toBeDefined();
});
});

describe('responses that must never be cached', () => {
// Deployments that serve authenticated or personalized pages typically
// guard them with one of these header shapes.
const guardedHeaderShapes = [
['no-store', 'no-store'],
['private', 'private'],
['no-cache', 'no-cache'],
['combined directives', 'private, no-cache, max-age=0'],
['mixed case and inner whitespace', ' Private , NO-STORE '],
['a directive with a field argument', 'no-cache="Set-Cookie"']
];

guardedHeaderShapes.forEach(([description, cacheControl]) => {
it(`does not cache a 200 response guarded by Cache-Control: ${description}`, async () => {
responses['/app/'] = () => navigationResponse('authenticated page', cacheControl);

await handlers.handleFetch(navigationRequest(`${window.location.origin}/app/`));

expect(await cachedResponseFor(`${window.location.origin}/app/`)).toBeUndefined();
});
});

it('does not cache non-200 responses', async () => {
responses['/app/'] = () => new Response('Internal Server Error', { status: 500 });

await handlers.handleFetch(navigationRequest(`${window.location.origin}/app/`));

expect(await cachedResponseFor(`${window.location.origin}/app/`)).toBeUndefined();
});

it('still returns the guarded response to the browser; only storage is skipped', async () => {
responses['/app/'] = () => navigationResponse('authenticated page', 'no-store');

const response = await handlers.handleFetch(
navigationRequest(`${window.location.origin}/app/`)
);

expect(response.status).toBe(200);
expect(await response.text()).toBe('authenticated page');
expect(await cachedResponseFor(`${window.location.origin}/app/`)).toBeUndefined();
});

it('does not overwrite an already cached public page with a later guarded response', async () => {
responses['/app/'] = () => navigationResponse('public page', 'public');
const request = navigationRequest(`${window.location.origin}/app/`);
await handlers.handleFetch(request);

// The same URL starts serving authenticated content.
responses['/app/'] = () => navigationResponse('authenticated page', 'no-store');
await handlers.handleFetch(request);

const cached = await cachedResponseFor(`${window.location.origin}/app/`);
expect(await cached.text()).toBe('public page');
});
});

describe('offline fallback behavior', () => {
it('serves a previously cached public page when the network is unavailable', async () => {
responses['/app/'] = () => navigationResponse('public page', 'public');
const request = navigationRequest(`${window.location.origin}/app/`);

await handlers.handleFetch(request);

delete responses['/app/'];
const offline = await handlers.handleFetch(request);
expect(await offline.text()).toBe('public page');
});

it('falls back to the precached index.html offline instead of leaking a guarded page', async () => {
responses['/app/'] = () => navigationResponse('authenticated page', 'no-store');
const request = navigationRequest(`${window.location.origin}/app/`);

// Online: the guarded page is served live, but never stored.
const online = await handlers.handleFetch(request);
expect(await online.text()).toBe('authenticated page');

// Offline: the same navigation must fall back to the app shell,
// never to the guarded response.
delete responses['/app/'];
const offline = await handlers.handleFetch(request);
expect(await offline.text()).toBe('app shell');
});

it('propagates the network error when no cacheable page and no fallback are available', async () => {
const cache = await caches.open(handlers.cacheName);
await cache.delete(`${window.location.origin}/app/index.html`);

await expectAsync(
handlers.handleFetch(navigationRequest(`${window.location.origin}/app/`))
).toBeRejectedWithError(TypeError);
});
});
});