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:
Anso
2026-06-03 08:18:16 -04:00
committed by GitHub
parent c65c193a59
commit 2435da232b
10 changed files with 552 additions and 52 deletions
+27 -3
View File
@@ -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">
@@ -0,0 +1,108 @@
/**
* Coverage for ApiTokensSection load behavior.
*
* Locks the fix where a non-ok token-list response was swallowed silently: the
* section must surface an error toast and an error state with a retry, rather
* than presenting the empty "No API tokens yet" state as if no tokens existed.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, waitFor, fireEvent } from '@testing-library/react';
vi.mock('@/lib/api', () => ({
apiFetch: vi.fn(),
}));
vi.mock('@/components/ui/toast-store', () => ({
toast: {
error: vi.fn(),
success: vi.fn(),
warning: vi.fn(),
info: vi.fn(),
loading: vi.fn(),
dismiss: vi.fn(),
},
}));
// Render CapabilityGate's children directly: the capability gate is not under test.
vi.mock('@/context/NodeContext', () => ({
useNodes: () => ({
hasCapability: () => true,
activeNode: { id: 1, name: 'local' },
activeNodeMeta: { version: '1.0.0', capabilities: ['api-tokens'], fetchedAt: 0 },
}),
}));
// The masthead stats hook depends on a provider that is not mounted here.
vi.mock('../settings/MastheadStatsContext', () => ({
useMastheadStats: () => {},
}));
import { apiFetch } from '@/lib/api';
import { toast } from '@/components/ui/toast-store';
import { ApiTokensSection } from '../ApiTokensSection';
const mockedFetch = apiFetch as unknown as ReturnType<typeof vi.fn>;
const mockedToast = toast as unknown as { error: ReturnType<typeof vi.fn> };
function tokenRow(overrides: Record<string, unknown> = {}) {
return {
id: 1,
name: 'ci-pipeline',
scope: 'read-only',
created_at: 1_700_000_000_000,
last_used_at: null,
expires_at: null,
revoked_at: null,
...overrides,
};
}
describe('ApiTokensSection load behavior', () => {
beforeEach(() => {
mockedFetch.mockReset();
mockedToast.error.mockReset();
});
it('surfaces an error toast and an error state (not the empty state) when the list load fails', async () => {
mockedFetch.mockResolvedValue({ ok: false, json: async () => ({ error: 'list blew up' }) });
render(<ApiTokensSection />);
await waitFor(() => expect(mockedToast.error).toHaveBeenCalledWith('list blew up'));
expect(await screen.findByText("Couldn't load API tokens")).toBeInTheDocument();
expect(screen.queryByText('No API tokens yet')).toBeNull();
});
it('shows the empty state and does not toast on a successful empty load', async () => {
mockedFetch.mockResolvedValue({ ok: true, json: async () => [] });
render(<ApiTokensSection />);
expect(await screen.findByText('No API tokens yet')).toBeInTheDocument();
expect(screen.queryByText("Couldn't load API tokens")).toBeNull();
expect(mockedToast.error).not.toHaveBeenCalled();
});
it('renders the token list and does not toast on a successful load', async () => {
mockedFetch.mockResolvedValue({ ok: true, json: async () => [tokenRow()] });
render(<ApiTokensSection />);
expect(await screen.findByText('ci-pipeline')).toBeInTheDocument();
expect(mockedToast.error).not.toHaveBeenCalled();
});
it('recovers via Retry: a failed load then a successful one clears the error state', async () => {
mockedFetch
.mockResolvedValueOnce({ ok: false, json: async () => ({ error: 'transient' }) })
.mockResolvedValueOnce({ ok: true, json: async () => [] });
render(<ApiTokensSection />);
const retry = await screen.findByRole('button', { name: /retry/i });
fireEvent.click(retry);
expect(await screen.findByText('No API tokens yet')).toBeInTheDocument();
expect(screen.queryByText("Couldn't load API tokens")).toBeNull();
});
});