diff --git a/package.json b/package.json index 09c7129c..32d5a018 100644 --- a/package.json +++ b/package.json @@ -27,6 +27,8 @@ "concurrently": "^5.0.2", "eslint-config-prettier": "^6.15.0", "eslint-plugin-react-hooks": "^4.2.0", + "fake-indexeddb": "^3.1.3", + "idb": "^6.1.3", "jest": "^24.9.0", "loop": "^3.3.4", "prop-types": "^15.7.2", diff --git a/runtime/src/index.ts b/runtime/src/index.ts index 341798b9..36d1a656 100644 --- a/runtime/src/index.ts +++ b/runtime/src/index.ts @@ -17,6 +17,7 @@ export { useCacheableSection, CacheableSection, useCachedSections, + clearSensitiveCaches, } from '@dhis2/app-service-offline' export { Provider } from './Provider' diff --git a/services/offline/src/index.ts b/services/offline/src/index.ts index 81c89a4e..0b140f6f 100644 --- a/services/offline/src/index.ts +++ b/services/offline/src/index.ts @@ -2,3 +2,4 @@ export { OfflineProvider } from './lib/offline-provider' export { CacheableSection, useCacheableSection } from './lib/cacheable-section' export { useCachedSections } from './lib/cacheable-section-state' export { useOnlineStatus } from './lib/online-status' +export { clearSensitiveCaches } from './lib/clear-sensitive-caches' diff --git a/services/offline/src/lib/__tests__/clear-sensitive-caches.test.ts b/services/offline/src/lib/__tests__/clear-sensitive-caches.test.ts new file mode 100644 index 00000000..26ced29e --- /dev/null +++ b/services/offline/src/lib/__tests__/clear-sensitive-caches.test.ts @@ -0,0 +1,162 @@ +import FDBFactory from 'fake-indexeddb/lib/FDBFactory' +import { openDB } from 'idb' +import 'fake-indexeddb/auto' +import { + clearSensitiveCaches, + SECTIONS_DB, + SECTIONS_STORE, +} from '../clear-sensitive-caches' + +// Mocks for CacheStorage API + +// Returns true if an existing cache is deleted +const makeCachesDeleteMock = (keys: string[]) => { + return jest + .fn() + .mockImplementation(key => Promise.resolve(keys.includes(key))) +} + +const keysMockDefault = jest.fn().mockImplementation(async () => []) +const deleteMockDefault = makeCachesDeleteMock([]) +const cachesDefault = { + keys: keysMockDefault, + delete: deleteMockDefault, +} +window.caches = cachesDefault + +afterEach(() => { + window.caches = cachesDefault + jest.clearAllMocks() +}) + +// silence debug logs for these tests +const originalDebug = console.debug +beforeAll(() => { + jest.spyOn(console, 'debug').mockImplementation((...args) => { + const pattern = /Clearing sensitive caches/ + if (typeof args[0] === 'string' && pattern.test(args[0])) { + return + } + return originalDebug.call(console, ...args) + }) +}) +afterAll(() => { + ;(console.debug as jest.Mock).mockRestore() +}) + +it('does not fail if there are no caches or no sections-db', () => { + return expect(clearSensitiveCaches()).resolves.toBe(false) +}) + +it('clears potentially sensitive caches', async () => { + const testKeys = ['cache1', 'cache2', 'app-shell'] + const keysMock = jest + .fn() + .mockImplementation(() => Promise.resolve(testKeys)) + const deleteMock = makeCachesDeleteMock(testKeys) + window.caches = { keys: keysMock, delete: deleteMock } + + const cachesDeleted = await clearSensitiveCaches() + expect(cachesDeleted).toBe(true) + + expect(deleteMock).toHaveBeenCalledTimes(3) + expect(deleteMock.mock.calls[0][0]).toBe('cache1') + expect(deleteMock.mock.calls[1][0]).toBe('cache2') + expect(deleteMock.mock.calls[2][0]).toBe('app-shell') +}) + +it('preserves keepable caches', async () => { + const keysMock = jest + .fn() + .mockImplementation(async () => [ + 'cache1', + 'cache2', + 'app-shell', + 'other-assets', + 'workbox-precache-v2-https://hey.howareya.now/', + ]) + window.caches = { ...cachesDefault, keys: keysMock } + + await clearSensitiveCaches() + + expect(deleteMockDefault).toHaveBeenCalledTimes(3) + expect(deleteMockDefault.mock.calls[0][0]).toBe('cache1') + expect(deleteMockDefault.mock.calls[1][0]).toBe('cache2') + expect(deleteMockDefault.mock.calls[2][0]).toBe('app-shell') + expect(deleteMockDefault).not.toHaveBeenCalledWith('other-assets') + expect(deleteMockDefault).not.toHaveBeenCalledWith( + 'workbox-precache-v2-https://hey.howareya.now/' + ) +}) + +describe('clears sections-db', () => { + // Test DB + function openTestDB(dbName: string) { + // simplified version of app platform openDB logic + return openDB(dbName, 1, { + upgrade(db) { + db.createObjectStore(SECTIONS_STORE, { keyPath: 'sectionId' }) + }, + }) + } + + afterEach(() => { + // reset indexedDB state + window.indexedDB = new FDBFactory() + }) + + it('clears sections-db if it exists', async () => { + // Open and populate test DB + const db = await openTestDB(SECTIONS_DB) + await db.put(SECTIONS_STORE, { + sectionId: 'id-1', + lastUpdated: new Date(), + requests: 3, + }) + await db.put(SECTIONS_STORE, { + sectionId: 'id-2', + lastUpdated: new Date(), + requests: 3, + }) + + await clearSensitiveCaches() + + // Sections-db should be cleared + const allSections = await db.getAll(SECTIONS_STORE) + expect(allSections).toHaveLength(0) + }) + + it("doesn't clear sections-db if it doesn't exist and doesn't open a new one", async () => { + const openMock = jest.fn() + window.indexedDB.open = openMock + + expect(await indexedDB.databases()).not.toContain(SECTIONS_DB) + + await clearSensitiveCaches() + + expect(openMock).not.toHaveBeenCalled() + return expect(await indexedDB.databases()).not.toContain(SECTIONS_DB) + }) + + it("doesn't handle IDB if 'databases' property is not on window.indexedDB", async () => { + // Open DB -- 'indexedDB.open' _would_ get called in this test + // if 'databases' property exists + await openTestDB(SECTIONS_DB) + const openMock = jest.fn() + window.indexedDB.open = openMock + + // Remove 'databases' from indexedDB prototype for this test + // (simulates Firefox environment) + const idbProto = Object.getPrototypeOf(window.indexedDB) + const databases = idbProto.databases + delete idbProto.databases + + expect('databases' in window.indexedDB).toBe(false) + await expect(clearSensitiveCaches()).resolves.toBeDefined() + expect(openMock).not.toHaveBeenCalled() + + // Restore indexedDB prototype for later tests + idbProto.databases = databases + expect('databases' in window.indexedDB).toBe(true) + }) +}) diff --git a/services/offline/src/lib/clear-sensitive-caches.ts b/services/offline/src/lib/clear-sensitive-caches.ts new file mode 100644 index 00000000..fca654b3 --- /dev/null +++ b/services/offline/src/lib/clear-sensitive-caches.ts @@ -0,0 +1,84 @@ +// IndexedDB names; should be the same as in @dhis2/pwa +export const SECTIONS_DB = 'sections-db' +export const SECTIONS_STORE = 'sections-store' + +// Non-sensitive caches that can be kept: +const KEEPABLE_CACHES = [ + /^workbox-precache/, // precached static assets + /^other-assets/, // static assets cached at runtime - shouldn't be sensitive +] + +declare global { + interface IDBFactory { + databases(): Promise<[{ name: string; version: number }]> + } +} + +/* + * Clears the 'sections-db' IndexedDB if it exists. Designed to avoid opening + * a new DB if it doesn't exist yet. Firefox can't check if 'sections-db' + * exists, in which circumstance the IndexedDB is unaffected. It's inelegant + * but acceptable because the IndexedDB has no sensitive data (only metadata + * of recorded sections), and the OfflineInterface handles discrepancies + * between CacheStorage and IndexedDB. + */ +const clearDB = async (dbName: string): Promise => { + if (!('databases' in indexedDB)) { + // FF does not have indexedDB.databases. For that, just clear caches, + // and offline interface will handle discrepancies in PWA apps. + return + } + + const dbs = await window.indexedDB.databases() + if (!dbs.some(({ name }) => name === dbName)) { + // Sections-db is not created; nothing to do here + return + } + + return new Promise((resolve, reject) => { + // IndexedDB fun: + const openDBRequest = indexedDB.open(dbName) + openDBRequest.onsuccess = e => { + const db = (e.target as IDBOpenDBRequest).result + const tx = db.transaction(SECTIONS_STORE, 'readwrite') + // When the transaction completes is when the operation is done: + tx.oncomplete = () => resolve() + tx.onerror = e => reject((e.target as IDBRequest).error) + const os = tx.objectStore(SECTIONS_STORE) + const clearReq = os.clear() + clearReq.onerror = e => reject((e.target as IDBRequest).error) + } + openDBRequest.onerror = e => { + reject((e.target as IDBOpenDBRequest).error) + } + }) +} + +/** + * Used to clear caches and 'sections-db' IndexedDB when a user logs out or a + * different user logs in to prevent someone from accessing a different user's + * caches. Should be able to be used in a non-PWA app. + */ +export async function clearSensitiveCaches( + dbName: string = SECTIONS_DB +): Promise { + console.debug('Clearing sensitive caches') + + const cacheKeys = await caches.keys() + return Promise.all([ + // (Resolves to 'false' because this can't detect if anything was deleted): + clearDB(dbName).then(() => false), + // Remove caches if not in keepable list + ...cacheKeys.map(key => { + if (!KEEPABLE_CACHES.some(pattern => pattern.test(key))) { + return caches.delete(key) + } + return false + }), + ]).then(responses => { + // Return true if any caches have been cleared + // (caches.delete() returns true if a cache is deleted successfully) + // PWA apps can reload to restore their app shell cache + return responses.some(response => response) + }) +} diff --git a/yarn.lock b/yarn.lock index 9c024377..ddcc089b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1126,9 +1126,9 @@ regenerator-runtime "^0.13.4" "@babel/runtime@^7.10.0", "@babel/runtime@^7.10.2", "@babel/runtime@^7.11.2", "@babel/runtime@^7.12.0", "@babel/runtime@^7.12.1", "@babel/runtime@^7.12.5", "@babel/runtime@^7.5.5", "@babel/runtime@^7.7.2", "@babel/runtime@^7.8.4", "@babel/runtime@^7.9.2": - version "7.13.7" - resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.13.7.tgz#d494e39d198ee9ca04f4dcb76d25d9d7a1dc961a" - integrity sha512-h+ilqoX998mRVM5FtB5ijRuHUDVt5l3yfoOi2uh18Z/O3hvyaHQ39NpxVkCIG5yFs+mLq/ewFp8Bss6zmWv6ZA== + version "7.13.17" + resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.13.17.tgz#8966d1fc9593bf848602f0662d6b4d0069e3a7ec" + integrity sha512-NCdgJEelPTSh+FEFylhnP1ylq848l1z9t9N0j1Lfbcw0+KXGjsTvUmkxy+voLLXB5SOKMbLLx4jxYliGrYQseA== dependencies: regenerator-runtime "^0.13.4" @@ -3679,6 +3679,11 @@ balanced-match@^1.0.0: resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-1.0.0.tgz#89b4d199ab2bee49de164ea02b89ce462d71b767" integrity sha1-ibTRmasr7kneFk6gK4nORi1xt2c= +base64-arraybuffer-es6@^0.7.0: + version "0.7.0" + resolved "https://registry.yarnpkg.com/base64-arraybuffer-es6/-/base64-arraybuffer-es6-0.7.0.tgz#dbe1e6c87b1bf1ca2875904461a7de40f21abc86" + integrity sha512-ESyU/U1CFZDJUdr+neHRhNozeCv72Y7Vm0m1DCbjX3KBjT6eYocvAJlSk6+8+HkVwXlT1FNxhGW6q3UKAlCvvw== + base64-js@^1.0.2, base64-js@^1.3.1: version "1.5.1" resolved "https://registry.yarnpkg.com/base64-js/-/base64-js-1.5.1.tgz#1b1b440160a5bf7ad40b650f095963481903930a" @@ -4831,7 +4836,7 @@ core-js-pure@^3.0.0: resolved "https://registry.yarnpkg.com/core-js-pure/-/core-js-pure-3.9.0.tgz#326cc74e1fef8b7443a6a793ddb0adfcd81f9efb" integrity sha512-3pEcmMZC9Cq0D4ZBh3pe2HLtqxpGNJBLXF/kZ2YzK17RbKp94w0HFbdbSx8H8kAlZG5k76hvLrkPm57Uyef+kg== -core-js@^2.4.0: +core-js@^2.4.0, core-js@^2.5.3: version "2.6.12" resolved "https://registry.yarnpkg.com/core-js/-/core-js-2.6.12.tgz#d9333dfa7b065e347cc5682219d6f690859cc2ec" integrity sha512-Kb2wC0fvsWfQrgk8HU5lW6U/Lcs8+9aaYcy4ZFc6DDlo4nZ7n70dEgE5rtR0oG6ufKDUnrwfWL1mXR5ljDatrQ== @@ -5556,7 +5561,7 @@ doctrine@^3.0.0: dependencies: esutils "^2.0.2" -dom-accessibility-api@^0.5.4, dom-accessibility-api@^0.5.6: +dom-accessibility-api@^0.5.6: version "0.5.6" resolved "https://registry.yarnpkg.com/dom-accessibility-api/-/dom-accessibility-api-0.5.6.tgz#3f5d43b52c7a3bd68b5fb63fa47b4e4c1fdf65a9" integrity sha512-DplGLZd8L1lN64jlT27N9TVSESFR5STaEJvX+thCby7fuCHonfPpAlodYc3vuUYbDuDec5w8AMP7oCM5TWFsqw== @@ -6434,6 +6439,14 @@ extsprintf@^1.2.0: resolved "https://registry.yarnpkg.com/extsprintf/-/extsprintf-1.4.0.tgz#e2689f8f356fad62cca65a3a91c5df5f9551692f" integrity sha1-4mifjzVvrWLMplo6kcXfX5VRaS8= +fake-indexeddb@^3.1.3: + version "3.1.3" + resolved "https://registry.yarnpkg.com/fake-indexeddb/-/fake-indexeddb-3.1.3.tgz#76d59146a6b994b9bb50ac9949cbd96ad6cca760" + integrity sha512-kpWYPIUGmxW8Q7xG7ampGL63fU/kYNukrIyy9KFj3+KVlFbE/SmvWebzWXBiCMeR0cPK6ufDoGC7MFkPhPLH9w== + dependencies: + realistic-structured-clone "^2.0.1" + setimmediate "^1.0.5" + fast-deep-equal@^3.1.1: version "3.1.3" resolved "https://registry.yarnpkg.com/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz#3a7d56b559d6cbc3eb512325244e619a65c6c525" @@ -7599,6 +7612,11 @@ icss-utils@^4.0.0, icss-utils@^4.1.1: dependencies: postcss "^7.0.14" +idb@^6.1.3: + version "6.1.3" + resolved "https://registry.yarnpkg.com/idb/-/idb-6.1.3.tgz#e6cd3b9c38f5c696a82a4b435754f3873c5a7891" + integrity sha512-oIRDpVcs5KXpI1hRnTJUwkY63RB/7iqu9nSNuzXN8TLHjs7oO20IoPFbBTsqxIL5IjzIUDi+FXlVcK4zm26J8A== + identity-obj-proxy@3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/identity-obj-proxy/-/identity-obj-proxy-3.0.0.tgz#94d2bda96084453ef36fbc5aaec37e0f79f1fc14" @@ -9672,7 +9690,7 @@ lodash.uniq@^4.5.0: resolved "https://registry.yarnpkg.com/lodash.uniq/-/lodash.uniq-4.5.0.tgz#d0225373aeb652adc1bc82e4945339a842754773" integrity sha1-0CJTc662Uq3BvILklFM5qEJ1R3M= -"lodash@>=3.5 <5", lodash@^4.0.0, lodash@^4.17.11, lodash@^4.17.13, lodash@^4.17.14, lodash@^4.17.15, lodash@^4.17.19, lodash@^4.17.2, lodash@^4.17.20, lodash@^4.17.21, lodash@^4.17.5, lodash@~4.17.10: +"lodash@>=3.5 <5", lodash@^4.0.0, lodash@^4.17.11, lodash@^4.17.13, lodash@^4.17.14, lodash@^4.17.15, lodash@^4.17.19, lodash@^4.17.2, lodash@^4.17.20, lodash@^4.17.21, lodash@^4.17.5, lodash@^4.7.0, lodash@~4.17.10: version "4.17.21" resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.21.tgz#679591c564c3bffaae8454cf0b3df370c3d6911c" integrity sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg== @@ -12391,6 +12409,16 @@ readdirp@~3.5.0: dependencies: picomatch "^2.2.1" +realistic-structured-clone@^2.0.1: + version "2.0.3" + resolved "https://registry.yarnpkg.com/realistic-structured-clone/-/realistic-structured-clone-2.0.3.tgz#8a252a87db8278d92267ad7a168c4f43fa485795" + integrity sha512-XYTwWZi5+lU4Wf+rnsQ7pukN9hF2cbJJf/yruBr1w23WhGflM6WoTBkdMVAun+oHFW2mV7UquyYo5oOI7YLJrQ== + dependencies: + core-js "^2.5.3" + domexception "^1.0.1" + typeson "^6.1.0" + typeson-registry "^1.0.0-alpha.20" + realpath-native@^1.1.0: version "1.1.0" resolved "https://registry.yarnpkg.com/realpath-native/-/realpath-native-1.1.0.tgz#2003294fea23fb0672f2476ebe22fcf498a2d65c" @@ -13135,7 +13163,7 @@ set-value@^2.0.0, set-value@^2.0.1: is-plain-object "^2.0.3" split-string "^3.0.1" -setimmediate@^1.0.4: +setimmediate@^1.0.4, setimmediate@^1.0.5: version "1.0.5" resolved "https://registry.yarnpkg.com/setimmediate/-/setimmediate-1.0.5.tgz#290cbb232e306942d7d7ea9b83732ab7856f8285" integrity sha1-KQy7Iy4waULX1+qbg3Mqt4VvgoU= @@ -14316,10 +14344,10 @@ tr46@^1.0.1: dependencies: punycode "^2.1.0" -tr46@^2.0.2: - version "2.0.2" - resolved "https://registry.yarnpkg.com/tr46/-/tr46-2.0.2.tgz#03273586def1595ae08fedb38d7733cee91d2479" - integrity sha512-3n1qG+/5kg+jrbTzwAykB5yRYtQCTqOGKq5U5PE3b0a1/mzo6snDhjGS0zJVJunO0NrT3Dg1MLy5TjWP/UJppg== +tr46@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/tr46/-/tr46-2.1.0.tgz#fa87aa81ca5d5941da8cbf1f9b749dc969a4e240" + integrity sha512-15Ih7phfcdP5YxqiB+iDtLoaTz4Nd35+IiAv0kQ5FNKHzXgdWqPoTIqEDDJmXceQt4JZk6lVPT8lnDlPpGDppw== dependencies: punycode "^2.1.1" @@ -14481,6 +14509,20 @@ typescript@^4.0.5: resolved "https://registry.yarnpkg.com/typescript/-/typescript-4.2.2.tgz#1450f020618f872db0ea17317d16d8da8ddb8c4c" integrity sha512-tbb+NVrLfnsJy3M59lsDgrzWIflR4d4TIUjz+heUnHZwdF7YsrMTKoRERiIvI2lvBG95dfpLxB21WZhys1bgaQ== +typeson-registry@^1.0.0-alpha.20: + version "1.0.0-alpha.39" + resolved "https://registry.yarnpkg.com/typeson-registry/-/typeson-registry-1.0.0-alpha.39.tgz#9e0f5aabd5eebfcffd65a796487541196f4b1211" + integrity sha512-NeGDEquhw+yfwNhguLPcZ9Oj0fzbADiX4R0WxvoY8nGhy98IbzQy1sezjoEFWOywOboj/DWehI+/aUlRVrJnnw== + dependencies: + base64-arraybuffer-es6 "^0.7.0" + typeson "^6.0.0" + whatwg-url "^8.4.0" + +typeson@^6.0.0, typeson@^6.1.0: + version "6.1.0" + resolved "https://registry.yarnpkg.com/typeson/-/typeson-6.1.0.tgz#5b2a53705a5f58ff4d6f82f965917cabd0d7448b" + integrity sha512-6FTtyGr8ldU0pfbvW/eOZrEtEkczHRUtduBnA90Jh9kMPCiFNnXIon3vF41N0S4tV1HHQt4Hk1j4srpESziCaA== + typical@^2.4.2, typical@^2.6.0, typical@^2.6.1: version "2.6.1" resolved "https://registry.yarnpkg.com/typical/-/typical-2.6.1.tgz#5c080e5d661cbbe38259d2e70a3c7253e873881d" @@ -15100,13 +15142,13 @@ whatwg-url@^7.0.0: tr46 "^1.0.1" webidl-conversions "^4.0.2" -whatwg-url@^8.0.0: - version "8.4.0" - resolved "https://registry.yarnpkg.com/whatwg-url/-/whatwg-url-8.4.0.tgz#50fb9615b05469591d2b2bd6dfaed2942ed72837" - integrity sha512-vwTUFf6V4zhcPkWp/4CQPr1TW9Ml6SF4lVyaIMBdJw5i6qUUJ1QWM4Z6YYVkfka0OUIzVo/0aNtGVGk256IKWw== +whatwg-url@^8.0.0, whatwg-url@^8.4.0: + version "8.7.0" + resolved "https://registry.yarnpkg.com/whatwg-url/-/whatwg-url-8.7.0.tgz#656a78e510ff8f3937bc0bcbe9f5c0ac35941b77" + integrity sha512-gAojqb/m9Q8a5IV96E3fHJM70AzCkgt4uXYX2O7EmuyOnLrViCQlsEBmF9UQIu3/aeAIp2U17rtbpZWNntQqdg== dependencies: - lodash.sortby "^4.7.0" - tr46 "^2.0.2" + lodash "^4.7.0" + tr46 "^2.1.0" webidl-conversions "^6.1.0" which-module@^2.0.0: