(core) Fix problem with localStorage in some cross-origin embed situations

Summary:
- Handle the possibility that any access to localStorage causes error.
- Move getStorage() and getSessionStorage() safe functions to a separate file.
- Use these safe functions in more places.

Test Plan:
Added a test case, using a webdriver instance that blocks third-party cookies,
to enforce third-party restrictions. Added to gristUtil a way to override the
webdriver instance.

Reviewers: jarek

Reviewed By: jarek

Differential Revision: https://phab.getgrist.com/D3719
This commit is contained in:
Dmitry S
2022-11-30 10:55:47 -05:00
parent 59942a23b6
commit 29a7eadb85
8 changed files with 93 additions and 75 deletions

View File

@@ -1,68 +1,6 @@
import {safeJsonParse} from 'app/common/gutil';
import {Observable} from 'grainjs';
/**
* Returns true if storage is functional. In some cass (e.g. when embedded), localStorage may
* throw errors. If so, we return false. This implementation is the approach taken by store.js.
*/
function testStorage(storage: Storage) {
try {
const testStr = '__localStorage_test';
storage.setItem(testStr, testStr);
const ok = (storage.getItem(testStr) === testStr);
storage.removeItem(testStr);
return ok;
} catch (e) {
return false;
}
}
/**
* Returns localStorage if functional, or sessionStorage, or an in-memory storage. The fallbacks
* help with tests, and may help when Grist is embedded.
*/
export function getStorage(): Storage {
return _storage || (_storage = createStorage());
}
/**
* Similar to `getStorage`, but always returns sessionStorage (or an in-memory equivalent).
*/
export function getSessionStorage(): Storage {
return _sessionStorage || (_sessionStorage = createSessionStorage());
}
let _storage: Storage|undefined;
let _sessionStorage: Storage|undefined;
function createStorage(): Storage {
if (typeof localStorage !== 'undefined' && testStorage(localStorage)) {
return localStorage;
} else {
return createSessionStorage();
}
}
function createSessionStorage(): Storage {
if (typeof sessionStorage !== 'undefined' && testStorage(sessionStorage)) {
return sessionStorage;
} else {
// Fall back to a Map-based implementation of (non-persistent) sessionStorage.
return createInMemoryStorage();
}
}
function createInMemoryStorage(): Storage {
const values = new Map<string, string>();
return {
setItem(key: string, val: string) { values.set(key, val); },
getItem(key: string) { return values.get(key) ?? null; },
removeItem(key: string) { values.delete(key); },
clear() { values.clear(); },
get length() { return values.size; },
key(index: number): string|null { throw new Error('Not implemented'); },
};
}
import {getSessionStorage, getStorage} from 'app/client/lib/storage';
function getStorageBoolObs(store: Storage, key: string, defValue: boolean) {
const storedNegation = defValue ? 'false' : 'true';

View File

@@ -4,6 +4,7 @@
*/
import {safeJsonParse} from 'app/common/gutil';
import {IDisposableOwner, Observable} from 'grainjs';
import {getSessionStorage} from 'app/client/lib/storage';
export interface SessionObs<T> extends Observable<T> {
pauseSaving(yesNo: boolean): void;
@@ -45,14 +46,15 @@ export function createSessionObs<T>(
return value === _default || !isValid(value) ? null : JSON.stringify(value);
}
let _pauseSaving = false;
const obs = Observable.create<T>(owner, fromString(window.sessionStorage.getItem(key)));
const storage = getSessionStorage();
const obs = Observable.create<T>(owner, fromString(storage.getItem(key)));
obs.addListener((value: T) => {
if (_pauseSaving) { return; }
const stored = toString(value);
if (stored == null) {
window.sessionStorage.removeItem(key);
storage.removeItem(key);
} else {
window.sessionStorage.setItem(key, stored);
storage.setItem(key, stored);
}
});
return Object.assign(obs, {pauseSaving(yesNo: boolean) { _pauseSaving = yesNo; }});

65
app/client/lib/storage.ts Normal file
View File

@@ -0,0 +1,65 @@
/**
* Expose localStorage and sessionStorage with fallbacks for cases when they don't work (e.g.
* cross-domain embeds in Firefox and Safari).
*
* Usage:
* import {getStorage, getSessionStorage} from 'app/client/lib/storage';
* ... use getStorage() in place of localStorage...
* ... use getSessionStorage() in place of sessionStorage...
*/
/**
* Returns localStorage if functional, or sessionStorage, or an in-memory storage. The fallbacks
* help with tests, and when Grist is embedded.
*/
export function getStorage(): Storage {
_storage ??= testStorage('localStorage') || getSessionStorage();
return _storage;
}
/**
* Return window.sessionStorage, or when not available, an in-memory storage.
*/
export function getSessionStorage(): Storage {
// If can't use sessionStorage, fall back to a Map-based non-persistent implementation.
_sessionStorage ??= testStorage('sessionStorage') || createInMemoryStorage();
return _sessionStorage;
}
let _storage: Storage|undefined;
let _sessionStorage: Storage|undefined;
/**
* Returns the result of window[storageName] if storage is functional, or null otherwise. In some
* cases (e.g. when embedded), using localStorage may throw errors, in which case we return null.
* This is similar to the approach taken by store.js.
*/
function testStorage(storageName: 'localStorage'|'sessionStorage'): Storage|null {
try {
const testStr = '__localStorage_test';
const storage = window[storageName];
storage.setItem(testStr, testStr);
const ok = (storage.getItem(testStr) === testStr);
storage.removeItem(testStr);
if (ok) {
return storage;
}
} catch (e) {
// Fall through
}
console.warn(`${storageName} is not available; will use fallback`);
return null;
}
function createInMemoryStorage(): Storage {
const values = new Map<string, string>();
return {
setItem(key: string, val: string) { values.set(key, val); },
getItem(key: string) { return values.get(key) ?? null; },
removeItem(key: string) { values.delete(key); },
clear() { values.clear(); },
get length() { return values.size; },
key(index: number): string|null { throw new Error('Not implemented'); },
};
}