From 7e11f78eb467e4e2e89172ce6f9fd9254f66deee Mon Sep 17 00:00:00 2001 From: archipelago Date: Tue, 6 Oct 2026 01:08:31 -0400 Subject: [PATCH] Recover stale authenticated CSRF cookies and distinguish interface fetch failures --- core/archipelago/src/api/rpc/mod.rs | 128 +++++++++++++++--- neode-ui/src/api/__tests__/rpc-client.test.ts | 29 ++++ neode-ui/src/api/rpc-client.ts | 11 ++ neode-ui/src/views/Server.vue | 9 +- .../__tests__/ServerNetworkRefresh.test.ts | 26 ++++ 5 files changed, 181 insertions(+), 22 deletions(-) diff --git a/core/archipelago/src/api/rpc/mod.rs b/core/archipelago/src/api/rpc/mod.rs index 8950338f..a926d54e 100644 --- a/core/archipelago/src/api/rpc/mod.rs +++ b/core/archipelago/src/api/rpc/mod.rs @@ -424,29 +424,28 @@ impl RpcHandler { }; if !csrf_valid { - // Debug: log expected vs received for diagnosis - if let (Some(token), Some(header)) = (&session_token, &csrf_header) { - let expected = derive_csrf_token(token).await; - tracing::warn!( - method = %rpc_req.method, - session_prefix = %&token[..8.min(token.len())], - csrf_prefix = %&header[..8.min(header.len())], - expected_prefix = %&expected[..8.min(expected.len())], - "403 CSRF mismatch — session/csrf/expected prefixes shown" - ); - } else { - tracing::warn!( - method = %rpc_req.method, - has_session = session_token.is_some(), - has_header = csrf_header.is_some(), - "403 CSRF validation failed — rejecting RPC call" - ); - } - return Ok(self.error_response( + tracing::warn!(method = %rpc_req.method, "CSRF mismatch; rejecting action and refreshing authenticated session token"); + let mut response = self.error_response( 403, "CSRF token missing or invalid", StatusCode::FORBIDDEN, - )); + ); + // Authentication and RBAC have already passed. Reject this + // request without dispatch, but refresh the deterministic CSRF + // cookie so the browser can retry normally after a key rotation + // or a stale companion cookie. Never return a token to an + // unauthenticated client or relax CSRF validation on retry. + if let Some(token) = &session_token { + self.set_csrf_cookie( + &mut response, + &derive_csrf_token(token).await, + secure_suffix, + ); + } + response + .headers_mut() + .insert("Cache-Control", cookie_header("private, no-store")); + return Ok(response); } } @@ -834,3 +833,92 @@ mod session_probe_contract_tests { assert!(!DISPATCHER.contains("\"system.get-version\" =>")); } } + +#[cfg(test)] +mod csrf_recovery_tests { + use super::*; + + #[tokio::test] + async fn stale_csrf_is_refreshed_without_executing_action_or_authenticating_strangers() { + let dir = tempfile::tempdir().unwrap(); + let mut config = crate::config::Config::default(); + config.data_dir = dir.path().to_path_buf(); + config.dev_mode = false; + let sessions = + crate::session::SessionStore::new_for_tests(dir.path().join("sessions.json")); + let token = sessions.create().await; + let handler = Arc::new( + RpcHandler::new( + config, + Arc::new(crate::state::StateManager::new()), + Arc::new(crate::monitoring::MetricsStore::new()), + sessions, + None, + None, + ) + .await + .unwrap(), + ); + let request = |session: &str, csrf: Option<&str>, secure: bool| { + let mut builder = Request::builder() + .method("POST") + .uri("/rpc/v1") + .header("Cookie", format!("session={session}")); + if let Some(csrf) = csrf { + builder = builder.header("X-CSRF-Token", csrf); + } + if secure { + builder = builder.header("X-Forwarded-Proto", "https"); + } + builder.body(hyper::Body::from(serde_json::json!({ + "jsonrpc":"2.0", "id":1, "method":"system.settings.set", + "params":{"key":"ai_provider", "value":"{\"provider\":\"local\",\"openai_model\":\"\"}"} + }).to_string())).unwrap() + }; + let settings = dir.path().join("settings/model-provider.json"); + for stale in [None, Some("stale-token"), Some("00")] { + let response = handler + .clone() + .handle(request(&token, stale, true)) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::FORBIDDEN); + assert!(!settings.exists(), "rejected action must not have run"); + let cookies: Vec<_> = response + .headers() + .get_all("set-cookie") + .iter() + .map(|v| v.to_str().unwrap()) + .collect(); + assert_eq!(cookies.len(), 1); + let expected = derive_csrf_token(&token).await; + assert_eq!( + cookies[0], + format!("csrf_token={expected}; SameSite=Lax; Path=/; Secure") + ); + assert_eq!(response.headers()["cache-control"], "private, no-store"); + } + let stranger = handler + .clone() + .handle(request("not-a-session", None, true)) + .await + .unwrap(); + assert_eq!(stranger.status(), StatusCode::UNAUTHORIZED); + assert!(!stranger.headers().contains_key("set-cookie")); + assert!(!settings.exists()); + let valid = derive_csrf_token(&token).await; + let response = handler + .handle(request(&token, Some(&valid), false)) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); + let body: serde_json::Value = + serde_json::from_slice(&hyper::body::to_bytes(response.into_body()).await.unwrap()) + .unwrap(); + assert!( + body["error"].is_null(), + "valid retry must reach the handler" + ); + assert!(settings.exists()); + } +} diff --git a/neode-ui/src/api/__tests__/rpc-client.test.ts b/neode-ui/src/api/__tests__/rpc-client.test.ts index 4fb4eae1..206ad870 100644 --- a/neode-ui/src/api/__tests__/rpc-client.test.ts +++ b/neode-ui/src/api/__tests__/rpc-client.test.ts @@ -36,9 +36,38 @@ describe('RPCClient', () => { }) afterEach(() => { + document.cookie = 'csrf_token=; Max-Age=0; Path=/' vi.useRealTimers() }) + it('uses a refreshed CSRF cookie once even with a single-attempt request', async () => { + document.cookie = 'csrf_token=old; Path=/' + mockFetch.mockImplementationOnce(async () => { + document.cookie = 'csrf_token=new; Path=/' + return jsonResponse({ error: { message: 'CSRF token missing or invalid' } }, 403) + }).mockResolvedValueOnce(jsonResponse({ result: { interfaces: [] } })) + await expect(rpcClient.call({ method: 'network.list-interfaces', maxRetries: 1 })).resolves.toEqual({ interfaces: [] }) + expect(mockFetch).toHaveBeenCalledTimes(2) + expect(mockFetch.mock.calls[0]![1].headers['X-CSRF-Token']).toBe('old') + expect(mockFetch.mock.calls[1]![1].headers['X-CSRF-Token']).toBe('new') + }) + + it('never loops on changing CSRF cookies or retries a permissions rejection', async () => { + let count = 0 + mockFetch.mockImplementation(async () => { + document.cookie = `csrf_token=rotated-${++count}; Path=/` + return jsonResponse({ error: { message: 'CSRF token missing or invalid' } }, 403) + }) + await expect(rpcClient.call({ method: 'network.list-interfaces', maxRetries: 1 })).rejects.toThrow('CSRF') + expect(mockFetch).toHaveBeenCalledTimes(2) + mockFetch.mockReset().mockImplementationOnce(async () => { + document.cookie = 'csrf_token=another; Path=/' + return jsonResponse({ error: { message: 'Forbidden: insufficient permissions' } }, 403) + }) + await expect(rpcClient.call({ method: 'system.settings.set', maxRetries: 1 })).rejects.toThrow('insufficient permissions') + expect(mockFetch).toHaveBeenCalledOnce() + }) + it('makes a successful RPC call and returns the result', async () => { mockFetch.mockResolvedValueOnce(jsonResponse({ result: { did: 'did:key:z123' } })) diff --git a/neode-ui/src/api/rpc-client.ts b/neode-ui/src/api/rpc-client.ts index ce3c2745..ac6316cc 100644 --- a/neode-ui/src/api/rpc-client.ts +++ b/neode-ui/src/api/rpc-client.ts @@ -125,6 +125,7 @@ class RPCClient { private async callInner(options: RPCOptions): Promise { const { method, params = {}, timeout = 15000, signal: external } = options const maxRetries = Math.max(1, options.maxRetries ?? 3) + let csrfRefreshed = false for (let attempt = 0; attempt < maxRetries; attempt++) { if (external?.aborted) throw new Error('Aborted') @@ -187,6 +188,16 @@ class RPCClient { } catch { /* body parse failed */ } const isCsrf = !reason || reason.toLowerCase().includes('csrf') + const refreshedToken = getCsrfToken() + if (isCsrf && !csrfRefreshed && refreshedToken && refreshedToken !== csrfToken) { + // The server rejected the action before dispatch and refreshed + // the cookie for this authenticated session. Allow exactly one + // retry even for single-attempt reads; never replay on ambiguous + // network failures or a permissions rejection. + csrfRefreshed = true + attempt-- + continue + } if (isCsrf && attempt < maxRetries - 1) { // CSRF mismatch — cookie may have been updated by a concurrent // Set-Cookie response not yet visible to JS. Retry after delay. diff --git a/neode-ui/src/views/Server.vue b/neode-ui/src/views/Server.vue index 8b7cce92..a79fcf44 100644 --- a/neode-ui/src/views/Server.vue +++ b/neode-ui/src/views/Server.vue @@ -279,7 +279,11 @@ /> -

No physical interfaces detected

+
+ {{ allInterfaces.length ? 'Couldn’t refresh interfaces. Showing last known information.' : 'Couldn’t load network interfaces.' }} + +
+

No physical interfaces detected

{{ wifiRadioError }}

@@ -653,7 +657,8 @@ const interfacesRes = useCachedResource({ return res.interfaces }, }) -const interfacesLoading = computed(() => interfacesRes.loadState.value === 'loading') +const interfacesError = computed(() => interfacesRes.error.value) +const interfacesLoading = computed(() => interfacesRes.loadState.value === 'loading' || (interfacesRes.loadState.value === 'idle' && interfacesRes.data.value === null)) const interfacesRefreshing = computed(() => interfacesRes.loadState.value === 'refreshing') const allInterfaces = computed(() => interfacesRes.data.value ?? []) const physicalInterfaces = computed(() => allInterfaces.value.filter(i => i.type === 'ethernet' || i.type === 'wifi')) diff --git a/neode-ui/src/views/__tests__/ServerNetworkRefresh.test.ts b/neode-ui/src/views/__tests__/ServerNetworkRefresh.test.ts index 7fa543eb..d6d0b15f 100644 --- a/neode-ui/src/views/__tests__/ServerNetworkRefresh.test.ts +++ b/neode-ui/src/views/__tests__/ServerNetworkRefresh.test.ts @@ -43,6 +43,32 @@ function mountServer(options: { renderTorServices?: boolean } = {}) { } describe('Server network refresh states', () => { + it('shows an interface fetch failure without claiming hardware is absent, then retries', async () => { + sessionStorage.clear() + let failed = true + vi.mocked(rpcClient.call).mockImplementation((request: { method: string }) => { + if (request.method === 'network.list-interfaces') { + if (failed) return Promise.reject(new Error('CSRF token missing or invalid')) + return Promise.resolve({ interfaces: [{ name: 'wlp3s0', type: 'wifi', state: 'up', mac: '', ipv4: ['192.0.2.10'] }] }) + } + return Promise.resolve({}) + }) + vi.mocked(rpcClient.vpnStatus).mockResolvedValue({ connected: false } as never) + vi.mocked(rpcClient.dnsStatus).mockResolvedValue({ provider: 'system', resolv_conf_servers: [], doh_enabled: false } as never) + vi.mocked(rpcClient.diskStatus).mockResolvedValue({ encrypted: false, warnings: [] } as never) + const wrapper = mountServer() + await flushPromises() + expect(wrapper.text()).toContain('Couldn’t load network interfaces.') + expect(wrapper.text()).not.toContain('No physical interfaces detected') + failed = false + const retry = wrapper.findAll('button').find(button => button.text() === 'Retry')! + await retry.trigger('click') + await flushPromises() + expect(wrapper.text()).toContain('wlp3s0') + expect(wrapper.text()).not.toContain('Couldn’t load network interfaces.') + wrapper.unmount() + sessionStorage.clear() + }) it('keeps network overview visible while refresh is pending', async () => { vi.mocked(rpcClient.call).mockImplementation((request: { method: string }) => { if (request.method === 'network.diagnostics') {