Skip to content

Commit

Permalink
feat(offline): add 'clear sensitive caches' function (#1002)
Browse files Browse the repository at this point in the history
The function is intended to clear cached data between users that might be sensitive. Expected to be used in app adapter and header bar.  Doesn't add any runtime dependencies, but can be a bit inelegant (though functional) in Firefox.
  • Loading branch information
KaiVandivier authored Sep 14, 2021
1 parent c2dee63 commit bb85fe9
Show file tree
Hide file tree
Showing 8 changed files with 326 additions and 85 deletions.
26 changes: 13 additions & 13 deletions examples/cra/yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -1047,24 +1047,24 @@
integrity sha512-ij4wRiunFfaJxjB0BdrYHIH8FxBJpOwNPhhAcunlmPdXudL1WQV1qoP9un6JsEBAgQH+7UXyyjh0g7jTxXK6tg==

"@dhis2/app-runtime@file:../../runtime":
version "2.10.0-pwa.4"
version "2.11.1"
dependencies:
"@dhis2/app-service-alerts" "2.10.0-pwa.4"
"@dhis2/app-service-config" "2.10.0-pwa.4"
"@dhis2/app-service-data" "2.10.0-pwa.4"
"@dhis2/app-service-offline" "2.10.0-pwa.4"
"@dhis2/app-service-alerts" "2.11.1"
"@dhis2/app-service-config" "2.11.1"
"@dhis2/app-service-data" "2.11.1"
"@dhis2/app-service-offline" "2.11.1"

"@dhis2/app-service-alerts@2.10.0-pwa.4", "@dhis2/app-service-alerts@file:../../services/alerts":
version "2.10.0-pwa.4"
"@dhis2/app-service-alerts@2.11.1", "@dhis2/app-service-alerts@file:../../services/alerts":
version "2.11.1"

"@dhis2/app-service-config@2.10.0-pwa.4", "@dhis2/app-service-config@file:../../services/config":
version "2.10.0-pwa.4"
"@dhis2/app-service-config@2.11.1", "@dhis2/app-service-config@file:../../services/config":
version "2.11.1"

"@dhis2/app-service-data@2.10.0-pwa.4", "@dhis2/app-service-data@file:../../services/data":
version "2.10.0-pwa.4"
"@dhis2/app-service-data@2.11.1", "@dhis2/app-service-data@file:../../services/data":
version "2.11.1"

"@dhis2/app-service-offline@2.10.0-pwa.4", "@dhis2/app-service-offline@file:../../services/offline":
version "2.10.0-pwa.4"
"@dhis2/app-service-offline@2.11.1", "@dhis2/app-service-offline@file:../../services/offline":
version "2.11.1"
dependencies:
lodash "^4.17.21"

Expand Down
26 changes: 13 additions & 13 deletions examples/query-playground/yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -1792,24 +1792,24 @@
"@dhis2/app-service-data" "2.8.0"

"@dhis2/app-runtime@^2.2.2", "@dhis2/app-runtime@file:../../runtime":
version "2.10.0-pwa.4"
version "2.11.1"
dependencies:
"@dhis2/app-service-alerts" "2.10.0-pwa.4"
"@dhis2/app-service-config" "2.10.0-pwa.4"
"@dhis2/app-service-data" "2.10.0-pwa.4"
"@dhis2/app-service-offline" "2.10.0-pwa.4"
"@dhis2/app-service-alerts" "2.11.1"
"@dhis2/app-service-config" "2.11.1"
"@dhis2/app-service-data" "2.11.1"
"@dhis2/app-service-offline" "2.11.1"

"@dhis2/app-service-alerts@2.10.0-pwa.4", "@dhis2/app-service-alerts@2.8.0", "@dhis2/app-service-alerts@file:../../services/alerts":
version "2.10.0-pwa.4"
"@dhis2/app-service-alerts@2.11.1", "@dhis2/app-service-alerts@2.8.0", "@dhis2/app-service-alerts@file:../../services/alerts":
version "2.11.1"

"@dhis2/app-service-config@2.10.0-pwa.4", "@dhis2/app-service-config@2.8.0", "@dhis2/app-service-config@file:../../services/config":
version "2.10.0-pwa.4"
"@dhis2/app-service-config@2.11.1", "@dhis2/app-service-config@2.8.0", "@dhis2/app-service-config@file:../../services/config":
version "2.11.1"

"@dhis2/app-service-data@2.10.0-pwa.4", "@dhis2/app-service-data@2.8.0", "@dhis2/app-service-data@file:../../services/data":
version "2.10.0-pwa.4"
"@dhis2/app-service-data@2.11.1", "@dhis2/app-service-data@2.8.0", "@dhis2/app-service-data@file:../../services/data":
version "2.11.1"

"@dhis2/app-service-offline@2.10.0-pwa.4", "@dhis2/app-service-offline@file:../../services/offline":
version "2.10.0-pwa.4"
"@dhis2/app-service-offline@2.11.1", "@dhis2/app-service-offline@file:../../services/offline":
version "2.11.1"
dependencies:
lodash "^4.17.21"

Expand Down
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
1 change: 1 addition & 0 deletions runtime/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ export {
useCacheableSection,
CacheableSection,
useCachedSections,
clearSensitiveCaches,
} from '@dhis2/app-service-offline'

export { Provider } from './Provider'
1 change: 1 addition & 0 deletions services/offline/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
152 changes: 152 additions & 0 deletions services/offline/src/lib/__tests__/clear-sensitive-caches.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
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

const keysMockDefault = jest.fn().mockImplementation(async () => [])
const deleteMockDefault = jest.fn().mockImplementation(async () => null)
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.toBeDefined()
})

it('clears potentially sensitive caches', async () => {
const keysMock = jest
.fn()
.mockImplementation(async () => ['cache1', 'cache2', 'app-shell'])
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')
})

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)
})
})
83 changes: 83 additions & 0 deletions services/offline/src/lib/clear-sensitive-caches.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
// 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<void> => {
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<any> {
console.debug('Clearing sensitive caches')

const cacheKeys = await caches.keys()
return Promise.all([
clearDB(dbName),
// remove caches if not in keepable list
...cacheKeys.map(key => {
if (!KEEPABLE_CACHES.some(pattern => pattern.test(key))) {
// .then() satisfies typescript
return caches.delete(key).then(() => undefined)
}
}),
]).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)
})
}
Loading

0 comments on commit bb85fe9

Please sign in to comment.