mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-24 17:36:42 +00:00
fix(api-tokens): harden rate limiting and surface list-load errors (#1292)
* fix(api-tokens): scope per-token rate limits to live tokens Forged or token-shaped Authorization headers no longer mint their own rate-limit budget. The key generator now grants a per-token budget only to a real, active token and falls back to per-IP keying for anything else, so a single source cannot evade the global limiter by rotating fake tokens. The validated token is memoized on the request, so authentication reuses it without a second database lookup. Token validation (format, checksum, lookup, revocation, expiry) is now a single shared helper used by the HTTP auth middleware, the WebSocket upgrade handler, and the rate-limit key generator, replacing two near-identical inline copies that could drift apart. The last-used timestamp write is throttled so a busy token no longer writes to the database on every request. * fix(api-tokens): surface token list-load failures with a retry A failed load of the API tokens list was swallowed: a server error rendered the empty "no tokens yet" state with no sign that anything went wrong. The list now shows an error card with a Retry action and raises a toast on any non-ok response or network error, matching the create and revoke flows. Adds a troubleshooting entry for the error. * test(api-tokens): seed tokens via the shared test helper The new hardening and WS-scope suites computed sha256 of a raw token directly, which CodeQL flags as js/insufficient-password-hash (a false positive: these are 256-bit CSPRNG opaque tokens, not passwords). Route token creation through the existing apiTokenTestHelper and read the stored token_hash back from the row, so the suites no longer hash anything themselves. Also removes the duplicated createToken helpers. * fix(api-tokens): key the rate limiter by the same credential auth uses The rate-limit key generator checked the session cookie before the Authorization bearer, while authMiddleware authenticates bearer-over-cookie (bearerToken || cookieToken). A request could send a Bearer API token plus a forged cookie and be keyed by the cookie's (forgeable, rotatable) username, sidestepping the per-token / per-IP keying the limiter applies to API tokens: a valid token would lose its own bucket, and a forged token-shaped bearer would no longer collapse to per-IP. Reorder the generator to mirror auth: process the bearer first (validate the API token and key per-token or fall back to per-IP; otherwise decode the JWT by username/sub), and consult the cookie only when there is no bearer. Regression tests cover a valid and a forged sen_sk_ bearer, each sent with a forged cookie.
This commit is contained in:
@@ -10,7 +10,7 @@ import { toast } from '@/components/ui/toast-store';
|
||||
import { apiFetch } from '@/lib/api';
|
||||
import { copyToClipboard } from '@/lib/clipboard';
|
||||
import { CapabilityGate } from './CapabilityGate';
|
||||
import { Zap, Plus, Copy, Trash2, CheckCircle, RefreshCw, Clock } from 'lucide-react';
|
||||
import { Zap, Plus, Copy, Trash2, CheckCircle, RefreshCw, Clock, AlertTriangle } from 'lucide-react';
|
||||
import { SettingsPrimaryButton } from './settings/SettingsActions';
|
||||
import { SettingsCallout } from './settings/SettingsCallout';
|
||||
import { useMastheadStats } from './settings/MastheadStatsContext';
|
||||
@@ -60,6 +60,7 @@ export function ApiTokensSection() {
|
||||
const [showForm, setShowForm] = useState(false);
|
||||
const [newToken, setNewToken] = useState<{ id: number; token: string } | null>(null);
|
||||
const [revokeTarget, setRevokeTarget] = useState<ApiTokenListItem | null>(null);
|
||||
const [loadError, setLoadError] = useState(false);
|
||||
|
||||
const [formName, setFormName] = useState('');
|
||||
const [formScope, setFormScope] = useState('read-only');
|
||||
@@ -71,8 +72,16 @@ export function ApiTokensSection() {
|
||||
if (res.ok) {
|
||||
const data: ApiTokenListItem[] = await res.json();
|
||||
setTokens(data.filter(t => !t.revoked_at));
|
||||
setLoadError(false);
|
||||
} else {
|
||||
const err = await res.json().catch(() => ({}));
|
||||
setLoadError(true);
|
||||
toast.error(err?.error || err?.message || 'Failed to load API tokens.');
|
||||
}
|
||||
} catch { toast.error('Failed to load API tokens.'); } finally { setLoading(false); }
|
||||
} catch {
|
||||
setLoadError(true);
|
||||
toast.error('Failed to load API tokens.');
|
||||
} finally { setLoading(false); }
|
||||
};
|
||||
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
@@ -224,7 +233,7 @@ export function ApiTokensSection() {
|
||||
)}
|
||||
|
||||
{/* Empty state */}
|
||||
{!loading && tokens.length === 0 && !showForm && (
|
||||
{!loading && !loadError && tokens.length === 0 && !showForm && (
|
||||
<SettingsCallout
|
||||
icon={<Zap className="h-4 w-4" />}
|
||||
title="No API tokens yet"
|
||||
@@ -232,6 +241,21 @@ export function ApiTokensSection() {
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Load error state */}
|
||||
{!loading && loadError && tokens.length === 0 && !showForm && (
|
||||
<SettingsCallout
|
||||
tone="error"
|
||||
icon={<AlertTriangle className="h-4 w-4" />}
|
||||
title="Couldn't load API tokens"
|
||||
subtitle="Check your connection and try again."
|
||||
action={
|
||||
<Button variant="outline" size="sm" onClick={() => { setLoading(true); fetchTokens(); }}>
|
||||
<RefreshCw className="w-4 h-4" strokeWidth={1.5} /> Retry
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Token list */}
|
||||
{!loading && tokens.map(token => (
|
||||
<div key={token.id} className="rounded-lg border border-card-border border-t-card-border-top bg-card text-card-foreground shadow-card-bevel transition-colors hover:border-t-card-border-hover p-4 space-y-3">
|
||||
|
||||
Reference in New Issue
Block a user