Recover stale authenticated CSRF cookies and distinguish interface fetch failures

This commit is contained in:
archipelago
2026-10-06 01:08:31 -04:00
parent 2fa82e4506
commit 7e11f78eb4
5 changed files with 181 additions and 22 deletions
+108 -20
View File
@@ -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());
}
}
@@ -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' } }))
+11
View File
@@ -125,6 +125,7 @@ class RPCClient {
private async callInner<T>(options: RPCOptions): Promise<T> {
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.
+7 -2
View File
@@ -279,7 +279,11 @@
/>
</div>
</div>
<p v-if="physicalInterfaces.length === 0" class="text-sm text-white/50 text-center py-4">No physical interfaces detected</p>
<div v-if="interfacesError" role="status" class="flex items-center justify-between gap-3 text-sm text-white/60 py-2">
<span>{{ allInterfaces.length ? 'Couldn’t refresh interfaces. Showing last known information.' : 'Couldn’t load network interfaces.' }}</span>
<button class="glass-button rounded px-3 py-1.5 text-xs shrink-0" :disabled="interfacesRefreshing" @click="loadInterfaces">Retry</button>
</div>
<p v-else-if="physicalInterfaces.length === 0 && interfacesRes.data.value !== null" class="text-sm text-white/50 text-center py-4">No physical interfaces detected</p>
<p v-if="wifiRadioError" class="text-xs text-red-400">{{ wifiRadioError }}</p>
</div>
</template>
@@ -653,7 +657,8 @@ const interfacesRes = useCachedResource<NetworkInterface[]>({
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'))
@@ -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') {