feat(security): 前端状态迁移至无明文密钥存储

This commit is contained in:
tianqijiuyun-latiao
2026-04-03 08:06:43 +08:00
parent 263db6bf30
commit c842201bf4
9 changed files with 460 additions and 62 deletions

View File

@@ -0,0 +1,35 @@
import { describe, expect, it } from 'vitest';
import { createGlobalProxyDraft, toPersistedGlobalProxy } from './globalProxyDraft';
describe('global proxy draft', () => {
it('hydrates a secretless draft from backend metadata while keeping password input blank', () => {
const draft = createGlobalProxyDraft({
enabled: true,
type: 'http',
host: '127.0.0.1',
port: 8080,
user: 'ops',
hasPassword: true,
password: 'should-be-ignored',
});
expect(draft.password).toBe('');
expect(draft.hasPassword).toBe(true);
});
it('drops password from persisted metadata but preserves hasPassword', () => {
const persisted = toPersistedGlobalProxy({
enabled: true,
type: 'http',
host: '127.0.0.1',
port: 8080,
user: 'ops',
password: 'proxy-secret',
hasPassword: true,
});
expect('password' in persisted).toBe(false);
expect(persisted.hasPassword).toBe(true);
});
});

View File

@@ -0,0 +1,62 @@
import { GlobalProxyConfig } from '../types';
const toTrimmedString = (value: unknown): string => {
if (typeof value === 'string') {
return value.trim();
}
if (typeof value === 'number' || typeof value === 'boolean') {
return String(value).trim();
}
return '';
};
const normalizeProxyType = (value: unknown): 'socks5' | 'http' => {
return toTrimmedString(value).toLowerCase() === 'http' ? 'http' : 'socks5';
};
const normalizePort = (value: unknown, fallbackPort: number): number => {
const parsed = Number(value);
if (!Number.isFinite(parsed)) {
return fallbackPort;
}
const port = Math.trunc(parsed);
if (port <= 0 || port > 65535) {
return fallbackPort;
}
return port;
};
export function createGlobalProxyDraft(value: Partial<GlobalProxyConfig> = {}): GlobalProxyConfig {
const type = normalizeProxyType(value.type);
return {
enabled: value.enabled === true,
type,
host: toTrimmedString(value.host),
port: normalizePort(value.port, type === 'http' ? 8080 : 1080),
user: toTrimmedString(value.user),
password: '',
hasPassword: value.hasPassword === true,
secretRef: toTrimmedString(value.secretRef) || undefined,
};
}
export function toPersistedGlobalProxy(value: Partial<GlobalProxyConfig> = {}): Omit<GlobalProxyConfig, 'password'> {
const draft = createGlobalProxyDraft(value);
return {
enabled: draft.enabled,
type: draft.type,
host: draft.host,
port: draft.port,
user: draft.user,
hasPassword: draft.hasPassword,
secretRef: draft.secretRef,
};
}
export function toSaveGlobalProxyInput(value: Partial<GlobalProxyConfig> = {}): GlobalProxyConfig {
const draft = createGlobalProxyDraft(value);
return {
...draft,
password: typeof value.password === 'string' ? value.password : '',
};
}

View File

@@ -0,0 +1,75 @@
import { describe, expect, it } from 'vitest';
import { readLegacyPersistedSecrets, stripLegacyPersistedSecrets } from './legacyConnectionStorage';
describe('legacy connection storage', () => {
it('extracts legacy saved connections and global proxy password from lite-db-storage', () => {
const payload = JSON.stringify({
state: {
connections: [
{
id: 'conn-1',
name: 'Primary',
config: {
id: 'conn-1',
type: 'postgres',
host: 'db.local',
port: 5432,
user: 'postgres',
password: 'secret',
},
},
],
globalProxy: {
enabled: true,
type: 'http',
host: '127.0.0.1',
port: 8080,
user: 'ops',
password: 'proxy-secret',
},
},
});
const result = readLegacyPersistedSecrets(payload);
expect(result.connections).toHaveLength(1);
expect(result.connections[0]?.config.password).toBe('secret');
expect(result.globalProxy?.password).toBe('proxy-secret');
});
it('strips persisted connection secrets but keeps secretless proxy metadata', () => {
const payload = JSON.stringify({
state: {
connections: [
{
id: 'conn-1',
name: 'Primary',
config: {
id: 'conn-1',
type: 'postgres',
host: 'db.local',
port: 5432,
user: 'postgres',
password: 'secret',
},
},
],
globalProxy: {
enabled: true,
type: 'http',
host: '127.0.0.1',
port: 8080,
user: 'ops',
password: 'proxy-secret',
},
},
});
const sanitized = stripLegacyPersistedSecrets(payload);
const parsed = JSON.parse(sanitized);
expect(parsed.state.connections).toEqual([]);
expect(parsed.state.globalProxy.password).toBeUndefined();
expect(parsed.state.globalProxy.hasPassword).toBe(true);
});
});

View File

@@ -0,0 +1,110 @@
import { GlobalProxyConfig, SavedConnection } from '../types';
export const LEGACY_PERSIST_KEY = 'lite-db-storage';
const toTrimmedString = (value: unknown): string => {
if (typeof value === 'string') {
return value.trim();
}
if (typeof value === 'number' || typeof value === 'boolean') {
return String(value).trim();
}
return '';
};
const normalizeProxyType = (value: unknown): 'socks5' | 'http' => {
return toTrimmedString(value).toLowerCase() === 'http' ? 'http' : 'socks5';
};
const normalizePort = (value: unknown, fallbackPort: number): number => {
const parsed = Number(value);
if (!Number.isFinite(parsed)) {
return fallbackPort;
}
const port = Math.trunc(parsed);
if (port <= 0 || port > 65535) {
return fallbackPort;
}
return port;
};
const parsePersistedEnvelope = (payload: string | null | undefined): Record<string, unknown> => {
if (!payload || typeof payload !== 'string') {
return {};
}
try {
const parsed = JSON.parse(payload) as Record<string, unknown>;
if (parsed.state && typeof parsed.state === 'object') {
return parsed.state as Record<string, unknown>;
}
return parsed;
} catch {
return {};
}
};
export function readLegacyPersistedSecrets(payload: string | null | undefined): {
connections: SavedConnection[];
globalProxy: GlobalProxyConfig | null;
} {
const state = parsePersistedEnvelope(payload);
const connections = Array.isArray(state.connections)
? state.connections.filter((item): item is SavedConnection => !!item && typeof item === 'object')
: [];
const proxyRaw = state.globalProxy && typeof state.globalProxy === 'object'
? state.globalProxy as Record<string, unknown>
: null;
if (!proxyRaw) {
return { connections, globalProxy: null };
}
const type = normalizeProxyType(proxyRaw.type);
const password = toTrimmedString(proxyRaw.password);
const globalProxy: GlobalProxyConfig = {
enabled: proxyRaw.enabled === true,
type,
host: toTrimmedString(proxyRaw.host),
port: normalizePort(proxyRaw.port, type === 'http' ? 8080 : 1080),
user: toTrimmedString(proxyRaw.user),
password,
hasPassword: proxyRaw.hasPassword === true || password !== '',
secretRef: toTrimmedString(proxyRaw.secretRef) || undefined,
};
const hasMeaningfulProxyState = globalProxy.enabled || globalProxy.host !== '' || globalProxy.user !== '' || globalProxy.password !== '' || globalProxy.hasPassword === true;
return {
connections,
globalProxy: hasMeaningfulProxyState ? globalProxy : null,
};
}
export function stripLegacyPersistedSecrets(payload: string | null | undefined): string {
if (!payload || typeof payload !== 'string') {
return '';
}
let parsed: Record<string, unknown>;
try {
parsed = JSON.parse(payload) as Record<string, unknown>;
} catch {
return payload;
}
const state = parsed.state && typeof parsed.state === 'object'
? parsed.state as Record<string, unknown>
: parsed;
state.connections = [];
if (state.globalProxy && typeof state.globalProxy === 'object') {
const proxy = { ...(state.globalProxy as Record<string, unknown>) };
const password = toTrimmedString(proxy.password);
delete proxy.password;
if (password !== '') {
proxy.hasPassword = true;
}
state.globalProxy = proxy;
}
return JSON.stringify(parsed);
}

View File

@@ -10,10 +10,10 @@ describe('startup readiness helpers', () => {
});
});
it('keeps sidebar blocked until initial global proxy sync finishes', () => {
it('keeps sidebar blocked until secure config bootstrap finishes', () => {
expect(getConnectionWorkbenchState(true, false)).toEqual({
ready: false,
message: '正在同步全局代理配置...',
message: '正在加载安全配置...',
});
});
@@ -24,3 +24,4 @@ describe('startup readiness helpers', () => {
});
});
});

View File

@@ -16,7 +16,7 @@ export function getConnectionWorkbenchState(
if (!hasAppliedInitialGlobalProxy) {
return {
ready: false,
message: '正在同步全局代理配置...',
message: '正在加载安全配置...',
};
}
return {
@@ -24,3 +24,4 @@ export function getConnectionWorkbenchState(
message: '',
};
}