Skip to content

Commit 960d044

Browse files
committed
Sanitize connection profiles before storing
Avoid persisting sensitive credentials in localStorage by stripping secret fields (proxyPassword, pangolinToken) before serializing connection profiles. Add stripSensitiveConnectionFields and canAutoRestoreConnectionProfile helpers to normalize profiles and only auto-restore sessions when required secrets are present/valid. Initialize LoginPage state from sanitized stored profile when available and update tests to reflect sanitized storage and conditional restore behavior.
1 parent 07a70a7 commit 960d044

5 files changed

Lines changed: 98 additions & 5 deletions

File tree

mobile/src/contexts/ApiContext.tsx

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,15 @@
11
import React, { createContext, useCallback, useContext, useMemo, useState } from 'react';
22
import {
33
buildConnectionCandidates,
4+
canAutoRestoreConnectionProfile,
45
createDefaultConnectionProfileDraft,
56
finalizeConnectionProfile,
67
isExplicitHttpUrl,
78
normalizeConnectionProfileDraft,
89
parsePangolinAccessToken,
910
parseStoredConnectionProfile,
1011
serializeConnectionProfile,
12+
stripSensitiveConnectionFields,
1113
type ConnectionMode,
1214
type ConnectionProfile,
1315
type ConnectionProfileDraft,
@@ -35,7 +37,7 @@ function readInitialConnectionProfile(): ConnectionProfile | null {
3537
const stored = parseStoredConnectionProfile(
3638
localStorage.getItem(CONNECTION_PROFILE_KEY),
3739
);
38-
if (stored) return stored;
40+
if (stored && canAutoRestoreConnectionProfile(stored)) return stored;
3941

4042
const legacyBaseUrl = localStorage.getItem(LEGACY_BASE_URL_KEY);
4143
if (!legacyBaseUrl) return null;
@@ -162,7 +164,9 @@ export const ApiProvider: React.FC<{ children: React.ReactNode }> = ({
162164
setConnectionProfile(candidateProfile);
163165
localStorage.setItem(
164166
CONNECTION_PROFILE_KEY,
165-
serializeConnectionProfile(candidateProfile),
167+
serializeConnectionProfile(
168+
stripSensitiveConnectionFields(candidateProfile),
169+
),
166170
);
167171
localStorage.removeItem(LEGACY_BASE_URL_KEY);
168172
localStorage.removeItem(LEGACY_INSECURE_KEY);

mobile/src/lib/connection.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -136,6 +136,37 @@ export function parseStoredConnectionProfile(value: string | null): ConnectionPr
136136
}
137137
}
138138

139+
export function stripSensitiveConnectionFields(
140+
profile: ConnectionProfile,
141+
): ConnectionProfile {
142+
const normalized = normalizeConnectionProfileDraft(profile);
143+
144+
return {
145+
...normalized,
146+
proxyPassword: '',
147+
pangolinToken: '',
148+
};
149+
}
150+
151+
export function canAutoRestoreConnectionProfile(
152+
profile: ConnectionProfile,
153+
): boolean {
154+
const normalized = normalizeConnectionProfileDraft(profile);
155+
156+
if (normalized.mode === 'proxy-basic') {
157+
return Boolean(normalized.baseUrl && normalized.proxyPassword);
158+
}
159+
160+
if (normalized.mode === 'pangolin') {
161+
return Boolean(
162+
normalized.baseUrl &&
163+
parsePangolinAccessToken(normalized.pangolinToken),
164+
);
165+
}
166+
167+
return Boolean(normalized.baseUrl);
168+
}
169+
139170
export function serializeConnectionProfile(profile: ConnectionProfile): string {
140171
return JSON.stringify(normalizeConnectionProfileDraft(profile));
141172
}

mobile/src/pages/LoginPage.tsx

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,14 +7,20 @@ import { Button } from '@/components/ui/button';
77
import {
88
createDefaultConnectionProfileDraft,
99
isConnectionDraftComplete,
10+
parseStoredConnectionProfile,
1011
type ConnectionProfileDraft,
1112
} from '@/lib/connection';
1213

14+
const CONNECTION_PROFILE_KEY = 'csm_connection_profile';
15+
1316
export default function LoginPage() {
1417
const navigate = useNavigate();
1518
const { login, isLoading, error } = useApi();
1619
const [profile, setProfile] = useState<ConnectionProfileDraft>(
17-
createDefaultConnectionProfileDraft,
20+
() =>
21+
parseStoredConnectionProfile(
22+
localStorage.getItem(CONNECTION_PROFILE_KEY),
23+
) ?? createDefaultConnectionProfileDraft(),
1824
);
1925

2026
const handleSubmit = async (event: React.FormEvent) => {

mobile/src/test/api-context.test.tsx

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -111,7 +111,7 @@ describe('ApiContext', () => {
111111
});
112112
});
113113

114-
it('persists the full profile and clears it on logout', async () => {
114+
it('persists only non-secret profile fields and clears them on logout', async () => {
115115
vi.spyOn(globalThis, 'fetch').mockResolvedValue(
116116
new Response(
117117
JSON.stringify({
@@ -150,7 +150,7 @@ describe('ApiContext', () => {
150150
mode: 'proxy-basic',
151151
baseUrl: 'https://proxy.example.com',
152152
proxyUsername: 'alice',
153-
proxyPassword: 'secret',
153+
proxyPassword: '',
154154
});
155155

156156
fireEvent.click(screen.getByRole('button', { name: 'Logout' }));
@@ -161,6 +161,30 @@ describe('ApiContext', () => {
161161
expect(localStorage.getItem('csm_connection_profile')).toBeNull();
162162
});
163163

164+
it('does not restore secret-backed authenticated sessions from sanitized storage', () => {
165+
localStorage.setItem(
166+
'csm_connection_profile',
167+
JSON.stringify({
168+
mode: 'pangolin',
169+
baseUrl: 'https://pangolin.example.com',
170+
allowInsecure: false,
171+
proxyUsername: '',
172+
proxyPassword: '',
173+
pangolinToken: '',
174+
pangolinTokenParam: 'p_token',
175+
}),
176+
);
177+
178+
render(
179+
<ApiProvider>
180+
<Harness draft={createDraft()} />
181+
</ApiProvider>,
182+
);
183+
184+
expect(screen.getByTestId('base-url')).toHaveTextContent('');
185+
expect(screen.getByTestId('mode')).toHaveTextContent('');
186+
});
187+
164188
it('migrates legacy url-only storage into the new profile shape', () => {
165189
localStorage.setItem('csm_base_url', 'https://legacy.example.com');
166190
localStorage.setItem('csm_allow_insecure', 'true');

mobile/src/test/login-page.test.tsx

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ vi.mock('react-router-dom', async () => {
2121

2222
describe('LoginPage', () => {
2323
beforeEach(() => {
24+
localStorage.clear();
2425
mockUseApi.mockReset();
2526
mockNavigate.mockReset();
2627
mockUseApi.mockReturnValue({
@@ -88,4 +89,31 @@ describe('LoginPage', () => {
8889
});
8990
expect(screen.getByRole('button', { name: 'Connect' })).toBeEnabled();
9091
});
92+
93+
it('prefills non-secret saved connection fields from storage', () => {
94+
localStorage.setItem(
95+
'csm_connection_profile',
96+
JSON.stringify({
97+
mode: 'proxy-basic',
98+
baseUrl: 'https://proxy.example.com',
99+
allowInsecure: false,
100+
proxyUsername: 'alice',
101+
proxyPassword: '',
102+
pangolinToken: '',
103+
pangolinTokenParam: 'p_token',
104+
}),
105+
);
106+
107+
render(<LoginPage />);
108+
109+
const proxyTab = screen.getByRole('tab', { name: 'Proxy' });
110+
fireEvent.mouseDown(proxyTab);
111+
fireEvent.click(proxyTab);
112+
113+
expect(screen.getByLabelText('Server URL')).toHaveValue(
114+
'https://proxy.example.com',
115+
);
116+
expect(screen.getByLabelText('Proxy username')).toHaveValue('alice');
117+
expect(screen.getByLabelText('Proxy password')).toHaveValue('');
118+
});
91119
});

0 commit comments

Comments
 (0)