🐛 fix: 修复受限环境下 secure-storage 触发 sessionStorage SecurityError

- 新增 safeGetStorage 探测能力探针,捕获 sessionStorage/localStorage 读取与写入探测时抛出的 SecurityError,返回可用的 Storage 或 null
- 将顶层敏感数据迁移逻辑外层包裹 try-catch,避免 iframe/sandbox/隐私设置等受限上下文导致模块初始化阶段未捕获异常中断
- 在 migrateFromLocalStorage 中基于 safeGetStorage 动态获取 localStorage,localStorage 不可用时直接跳过迁移而非抛错
- 补充受限环境降级单元测试:模拟 sessionStorage/localStorage 属性读取被拒、以及 sessionStorage 写入失败时确保降级到 fallbackStorage 且不抛异常
This commit is contained in:
Abner
2026-05-08 18:33:59 +08:00
parent be2c4e4dfb
commit b5ec8eb18a
2 changed files with 125 additions and 16 deletions

View File

@@ -6,11 +6,32 @@ import Logger from './logger.js';
* 防御 XSS 攻击窃取敏感数据
*/
/**
* 安全探测 Web Storage API 可用性
* 在跨域 iframe、sandbox 上下文或用户禁用存储时,
* 直接读取 sessionStorage/localStorage 属性可能抛出 SecurityError
* typeof 检查无法安全拦截此类异常。
* @param {'sessionStorage'|'localStorage'} kind - 存储类型
* @returns {Storage|null} 可用的存储对象,或 null
*/
function safeGetStorage(kind) {
try {
const storage = window[kind];
// 写入测试:某些浏览器在隐身模式下返回对象但实际拒绝读写
const testKey = `__storage_test_${kind}__`;
storage.setItem(testKey, '1');
storage.removeItem(testKey);
return storage;
} catch (e) {
return null;
}
}
class SecureStorage {
constructor() {
// 优先使用 sessionStorage (关闭浏览器即清除)
// 若需跨标签页共享,可使用 localStorage但必须加过期时间
this.storage = typeof sessionStorage !== 'undefined' ? sessionStorage : null;
this.storage = safeGetStorage('sessionStorage');
this.fallbackStorage = new Map(); // 内存降级
}
@@ -115,15 +136,16 @@ class SecureStorage {
* @param {string} key - 键名
*/
migrateFromLocalStorage(key) {
if (typeof localStorage === 'undefined') return;
try {
const oldValue = localStorage.getItem(key);
const local = safeGetStorage('localStorage');
if (!local) return;
const oldValue = local.getItem(key);
if (oldValue) {
// 迁移数据(不带过期时间,视为短期数据)
this.setItem(key, oldValue, 1800000); // 30分钟
// 清除旧数据
localStorage.removeItem(key);
local.removeItem(key);
Logger.log(`[SecureStorage] Migrated ${key} from localStorage`);
}
} catch (error) {
@@ -144,18 +166,25 @@ class SecureStorage {
const secureStorage = new SecureStorage();
// 自动迁移已知的敏感数据键
if (typeof window !== 'undefined') {
const SENSITIVE_KEYS = [
'giffgaff_cookie',
'gg_esim_activationCode',
'gg_esim_lpa',
'gg_esim_iccid',
'gg_esim_eid',
'gg_access_token'
];
// 顶层执行必须包裹 try-catch防止受限上下文iframe/sandbox/隐私设置)
// 导致 sessionStorage/localStorage 访问异常未被捕获
try {
if (typeof window !== 'undefined') {
const SENSITIVE_KEYS = [
'giffgaff_cookie',
'gg_esim_activationCode',
'gg_esim_lpa',
'gg_esim_iccid',
'gg_esim_eid',
'gg_access_token'
];
// 页面加载时自动迁移
secureStorage.migrateAll(SENSITIVE_KEYS);
// 页面加载时自动迁移
secureStorage.migrateAll(SENSITIVE_KEYS);
}
} catch (e) {
// 降级到内存存储,模块仍可正常使用
console.warn('[SecureStorage] Module-level initialization failed:', e.message);
}
export default secureStorage;

View File

@@ -125,4 +125,84 @@ describe('SecureStorage', () => {
expect(store.getItem('bad')).toBeNull();
});
});
describe('受限环境降级', () => {
let originalSessionStorage;
let originalLocalStorage;
beforeEach(() => {
originalSessionStorage = Object.getOwnPropertyDescriptor(window, 'sessionStorage');
originalLocalStorage = Object.getOwnPropertyDescriptor(window, 'localStorage');
});
afterEach(() => {
if (originalSessionStorage) {
Object.defineProperty(window, 'sessionStorage', originalSessionStorage);
} else {
delete window.sessionStorage;
}
if (originalLocalStorage) {
Object.defineProperty(window, 'localStorage', originalLocalStorage);
} else {
delete window.localStorage;
}
});
it('应该在 sessionStorage 访问被拒时降级到内存存储', () => {
// 模拟 SecurityError属性读取抛异常
Object.defineProperty(window, 'sessionStorage', {
get() {
throw new DOMException('Access is denied', 'SecurityError');
},
configurable: true
});
// 直接构造新实例,构造函数内部调用 safeGetStorage 会捕获异常
const SecureStorageClass = store.constructor;
const restricted = new SecureStorageClass();
expect(restricted.storage).toBeNull();
restricted.setItem('key', 'value');
expect(restricted.getItem('key')).toBe('value');
expect(restricted.fallbackStorage.has('key')).toBe(true);
});
it('应该在 localStorage 访问被拒时跳过迁移', () => {
Object.defineProperty(window, 'localStorage', {
get() {
throw new DOMException('Access is denied', 'SecurityError');
},
configurable: true
});
// migrateFromLocalStorage 内部调用 safeGetStorage 会捕获异常
expect(() => store.migrateFromLocalStorage('any_key')).not.toThrow();
});
it('应该在 sessionStorage 写入失败时降级到内存', () => {
// 模拟 sessionStorage.setItem 拒绝写入
store.storage = {
getItem: jest.fn(() => null),
setItem: jest.fn(() => {
throw new DOMException('Quota exceeded', 'QuotaExceededError');
}),
removeItem: jest.fn()
};
// 迁移数据localStorage 读取成功,但 sessionStorage 写入失败
const local = {
getItem: jest.fn(() => 'old_value'),
setItem: jest.fn(),
removeItem: jest.fn()
};
Object.defineProperty(window, 'localStorage', {
get: () => local,
configurable: true
});
store.migrateFromLocalStorage('test_key');
// 数据应降级到 fallbackStorage
expect(store.fallbackStorage.has('test_key')).toBe(true);
});
});
});