Skip to content

Commit 60dd9d8

Browse files
committed
security(frontend): harden CSP and session token handling
- keep auth token session-persisted and migrate legacy localStorage token - add abortable preview hydration to cancel stale in-flight polling - tighten app security header expectations in specs
1 parent 09e058a commit 60dd9d8

6 files changed

Lines changed: 182 additions & 62 deletions

File tree

app.rb

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,9 +41,14 @@ def development? = self.class.development?
4141
use Rack::Cache, metastore: 'file:./tmp/rack-cache-meta', entitystore: 'file:./tmp/rack-cache-body',
4242
verbose: development?
4343

44+
# rubocop:disable Metrics/BlockLength
4445
plugin :content_security_policy do |csp|
4546
csp.default_src :none
46-
csp.style_src :self, "'unsafe-inline'"
47+
if development?
48+
csp.style_src :self, "'unsafe-inline'"
49+
else
50+
csp.style_src :self
51+
end
4752
csp.script_src :self
4853
csp.connect_src :self
4954
csp.img_src :self
@@ -65,6 +70,7 @@ def development? = self.class.development?
6570
csp.block_all_mixed_content
6671
csp.upgrade_insecure_requests
6772
end
73+
# rubocop:enable Metrics/BlockLength
6874

6975
plugin :default_headers, {
7076
'X-Content-Type-Options' => 'nosniff',

frontend/src/__tests__/App.contract.test.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ describe('App contract', () => {
1414
});
1515

1616
const authenticate = () => {
17-
globalThis.localStorage.setItem('html2rss_access_token', token);
17+
globalThis.sessionStorage.setItem('html2rss_access_token', token);
1818
};
1919

2020
it('shows feed result when API responds with success', async () => {
@@ -168,6 +168,6 @@ describe('App contract', () => {
168168

169169
expect(screen.getByText('Enter access token')).toBeInTheDocument();
170170
expect(screen.queryByText('Could not create feed link')).not.toBeInTheDocument();
171-
expect(globalThis.localStorage.getItem('html2rss_access_token')).toBeNull();
171+
expect(globalThis.sessionStorage.getItem('html2rss_access_token')).toBeNull();
172172
});
173173
});

frontend/src/__tests__/useAccessToken.test.ts

Lines changed: 12 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,8 @@ describe('useAccessToken', () => {
88
globalThis.sessionStorage.clear();
99
});
1010

11-
it('loads the persisted token from localStorage', async () => {
12-
globalThis.localStorage.setItem('html2rss_access_token', 'persisted-token');
11+
it('loads the persisted token from sessionStorage', async () => {
12+
globalThis.sessionStorage.setItem('html2rss_access_token', 'persisted-token');
1313

1414
const { result } = renderHook(() => useAccessToken());
1515

@@ -19,18 +19,18 @@ describe('useAccessToken', () => {
1919
expect(result.current.error).toBeUndefined();
2020
});
2121

22-
it('migrates a legacy session token into localStorage', async () => {
23-
globalThis.sessionStorage.setItem('html2rss_access_token', 'legacy-token');
22+
it('migrates a legacy localStorage token into sessionStorage', async () => {
23+
globalThis.localStorage.setItem('html2rss_access_token', 'legacy-token');
2424

2525
const { result } = renderHook(() => useAccessToken());
2626

2727
expect(result.current.isLoading).toBe(false);
2828
expect(result.current.token).toBe('legacy-token');
29-
expect(globalThis.localStorage.getItem('html2rss_access_token')).toBe('legacy-token');
30-
expect(globalThis.sessionStorage.getItem('html2rss_access_token')).toBeNull();
29+
expect(globalThis.sessionStorage.getItem('html2rss_access_token')).toBe('legacy-token');
30+
expect(globalThis.localStorage.getItem('html2rss_access_token')).toBeNull();
3131
});
3232

33-
it('saves new tokens to the persistent storage path', async () => {
33+
it('saves new tokens to sessionStorage only', async () => {
3434
const { result } = renderHook(() => useAccessToken());
3535

3636
await act(async () => {
@@ -39,13 +39,13 @@ describe('useAccessToken', () => {
3939

4040
expect(result.current.token).toBe('new-token');
4141
expect(result.current.hasToken).toBe(true);
42-
expect(globalThis.localStorage.getItem('html2rss_access_token')).toBe('new-token');
43-
expect(globalThis.sessionStorage.getItem('html2rss_access_token')).toBeNull();
42+
expect(globalThis.sessionStorage.getItem('html2rss_access_token')).toBe('new-token');
43+
expect(globalThis.localStorage.getItem('html2rss_access_token')).toBeNull();
4444
});
4545

46-
it('clears both persistent and legacy token copies', async () => {
47-
globalThis.localStorage.setItem('html2rss_access_token', 'persisted-token');
48-
globalThis.sessionStorage.setItem('html2rss_access_token', 'legacy-token');
46+
it('clears both session and legacy local token copies', async () => {
47+
globalThis.sessionStorage.setItem('html2rss_access_token', 'persisted-token');
48+
globalThis.localStorage.setItem('html2rss_access_token', 'legacy-token');
4949

5050
const { result } = renderHook(() => useAccessToken());
5151

frontend/src/hooks/useAccessToken.ts

Lines changed: 49 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { useEffect, useState } from 'preact/hooks';
2-
import { getPersistentStorage } from '../utils/persistentStorage';
32

43
const ACCESS_TOKEN_KEY = 'html2rss_access_token';
4+
let inMemoryToken = '';
55

66
interface AccessTokenState {
77
token?: string;
@@ -19,33 +19,66 @@ const clearLegacySessionToken = () => {
1919
}
2020
};
2121

22+
const clearLegacyLocalToken = () => {
23+
if (globalThis.window === undefined) return;
24+
25+
try {
26+
globalThis.localStorage?.removeItem(ACCESS_TOKEN_KEY);
27+
} catch {
28+
// Ignore restricted localStorage access (privacy mode, sandboxed contexts).
29+
}
30+
};
31+
32+
const readSessionToken = (): string => {
33+
if (globalThis.window === undefined) return inMemoryToken;
34+
35+
try {
36+
return globalThis.sessionStorage?.getItem(ACCESS_TOKEN_KEY)?.trim() ?? '';
37+
} catch {
38+
return inMemoryToken;
39+
}
40+
};
41+
42+
const writeSessionToken = (token: string) => {
43+
inMemoryToken = token;
44+
if (globalThis.window === undefined) return;
45+
46+
try {
47+
if (token) {
48+
globalThis.sessionStorage?.setItem(ACCESS_TOKEN_KEY, token);
49+
} else {
50+
globalThis.sessionStorage?.removeItem(ACCESS_TOKEN_KEY);
51+
}
52+
} catch {
53+
// Keep in-memory fallback only when sessionStorage is unavailable.
54+
}
55+
};
56+
2257
export function useAccessToken() {
2358
const [state, setState] = useState<AccessTokenState>({
2459
isLoading: true,
2560
});
2661

2762
useEffect(() => {
28-
const storage = getPersistentStorage();
29-
3063
try {
31-
const token = storage.getItem(ACCESS_TOKEN_KEY)?.trim() ?? '';
32-
let legacyToken = '';
64+
const token = readSessionToken();
65+
let legacyLocalToken = '';
3366
if (!token && globalThis.window !== undefined) {
3467
try {
35-
legacyToken = globalThis.sessionStorage?.getItem(ACCESS_TOKEN_KEY)?.trim() ?? '';
68+
legacyLocalToken = globalThis.localStorage?.getItem(ACCESS_TOKEN_KEY)?.trim() ?? '';
3669
} catch {
37-
// Treat restricted sessionStorage access as no legacy token.
38-
legacyToken = '';
70+
// Treat restricted localStorage access as no legacy token.
71+
legacyLocalToken = '';
3972
}
4073
}
4174

42-
if (!token && legacyToken) {
43-
storage.setItem(ACCESS_TOKEN_KEY, legacyToken);
44-
clearLegacySessionToken();
75+
if (!token && legacyLocalToken) {
76+
writeSessionToken(legacyLocalToken);
77+
clearLegacyLocalToken();
4578
}
4679

4780
setState({
48-
token: token || legacyToken || undefined,
81+
token: token || legacyLocalToken || undefined,
4982
isLoading: false,
5083
});
5184
} catch {
@@ -60,8 +93,8 @@ export function useAccessToken() {
6093
const normalized = token.trim();
6194
if (!normalized) throw new Error('Access token is required');
6295

63-
const storage = getPersistentStorage();
64-
storage.setItem(ACCESS_TOKEN_KEY, normalized);
96+
writeSessionToken(normalized);
97+
clearLegacyLocalToken();
6598
clearLegacySessionToken();
6699

67100
setState({
@@ -71,8 +104,8 @@ export function useAccessToken() {
71104
};
72105

73106
const clearToken = () => {
74-
const storage = getPersistentStorage();
75-
storage.removeItem(ACCESS_TOKEN_KEY);
107+
writeSessionToken('');
108+
clearLegacyLocalToken();
76109
clearLegacySessionToken();
77110

78111
setState({

0 commit comments

Comments
 (0)