mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-06-04 21:49:36 +08:00
✨ feat(security): 前端状态迁移至无明文密钥存储
This commit is contained in:
35
frontend/src/utils/globalProxyDraft.test.ts
Normal file
35
frontend/src/utils/globalProxyDraft.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
62
frontend/src/utils/globalProxyDraft.ts
Normal file
62
frontend/src/utils/globalProxyDraft.ts
Normal 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 : '',
|
||||
};
|
||||
}
|
||||
75
frontend/src/utils/legacyConnectionStorage.test.ts
Normal file
75
frontend/src/utils/legacyConnectionStorage.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
110
frontend/src/utils/legacyConnectionStorage.ts
Normal file
110
frontend/src/utils/legacyConnectionStorage.ts
Normal 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);
|
||||
}
|
||||
@@ -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', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ export function getConnectionWorkbenchState(
|
||||
if (!hasAppliedInitialGlobalProxy) {
|
||||
return {
|
||||
ready: false,
|
||||
message: '正在同步全局代理配置...',
|
||||
message: '正在加载安全配置...',
|
||||
};
|
||||
}
|
||||
return {
|
||||
@@ -24,3 +24,4 @@ export function getConnectionWorkbenchState(
|
||||
message: '',
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user