fix(admin): security-audit findings — paginate chapters, lock down unchecked helper (0.41.2)

Addresses the security-audit findings on top of the admin feature stack:

M1: /admin/mangas/:id/chapters now paginates (default limit 200, max 500).
A long-runner with thousands of chapters would otherwise produce a multi-MB
response with that many scalar subqueries per row — admin-only but a real
stall risk on one expand-click. Adds explicit pagination tests for the cap
and offset; frontend renders a "Showing first N of M" hint when the cap
clips the result.

L1: repo::user::set_is_admin renamed to set_is_admin_unchecked with a
doc-comment pointing at admin_safe_set_is_admin for production use. The
short name was a footgun — a future contributor reaching for it would
silently bypass self-protection, the last-admin invariant, and the audit
log. Used only by integration-test setup; production code goes through
the admin_safe_* paths.

CSRF posture: build_session_cookie carries a comment that the
SameSite=Lax default is the project's CSRF defense for state-changing
mutations and breaks the instant anyone adds a side-effecting GET under
/admin/*. Spells out what to do then (Strict + explicit token check).

Test counts: 43 backend admin tests + 12 vitest admin tests all green;
svelte-check 0/0 across 446 files.
This commit is contained in:
MechaCat02
2026-05-30 22:23:55 +02:00
parent aa2159ca06
commit f6728dc71a
13 changed files with 214 additions and 47 deletions

View File

@@ -1,6 +1,6 @@
{
"name": "mangalord-frontend",
"version": "0.41.1",
"version": "0.41.2",
"private": true,
"type": "module",
"scripts": {

View File

@@ -149,7 +149,7 @@ describe('admin api client', () => {
expect(url).toContain('limit=100');
});
it('listAdminChapters GETs the nested chapter route', async () => {
it('listAdminChapters GETs the nested chapter route and parses the paged envelope', async () => {
const chapter = {
id: 'c-1',
manga_id: 'm-1',
@@ -160,13 +160,26 @@ describe('admin api client', () => {
sync_state: 'synced' as const,
latest_seen_at: null
};
fetchSpy.mockResolvedValueOnce(ok([chapter]));
const rows = await listAdminChapters('m-1');
expect(rows).toEqual([chapter]);
fetchSpy.mockResolvedValueOnce(
ok({ items: [chapter], page: { limit: 200, offset: 0, total: 1 } })
);
const resp = await listAdminChapters('m-1');
expect(resp.items).toEqual([chapter]);
expect(resp.page.total).toBe(1);
const url = fetchSpy.mock.calls[0][0] as string;
expect(url).toMatch(/\/v1\/admin\/mangas\/m-1\/chapters$/);
});
it('listAdminChapters forwards limit + offset query params', async () => {
fetchSpy.mockResolvedValueOnce(
ok({ items: [], page: { limit: 50, offset: 100, total: 0 } })
);
await listAdminChapters('m-1', { limit: 50, offset: 100 });
const url = fetchSpy.mock.calls[0][0] as string;
expect(url).toContain('limit=50');
expect(url).toContain('offset=100');
});
// ---- system ----
it('getSystemStats GETs /v1/admin/system and parses the four-key envelope', async () => {

View File

@@ -102,9 +102,26 @@ export type AdminChapterRow = {
latest_seen_at: string | null;
};
export async function listAdminChapters(mangaId: string): Promise<AdminChapterRow[]> {
return request<AdminChapterRow[]>(
`/v1/admin/mangas/${encodeURIComponent(mangaId)}/chapters`
export type AdminChaptersPage = {
items: AdminChapterRow[];
page: Page;
};
export type ListAdminChaptersOptions = {
limit?: number;
offset?: number;
};
export async function listAdminChapters(
mangaId: string,
opts: ListAdminChaptersOptions = {}
): Promise<AdminChaptersPage> {
const params = new URLSearchParams();
if (opts.limit != null) params.set('limit', String(opts.limit));
if (opts.offset != null) params.set('offset', String(opts.offset));
const qs = params.toString();
return request<AdminChaptersPage>(
`/v1/admin/mangas/${encodeURIComponent(mangaId)}/chapters${qs ? `?${qs}` : ''}`
);
}

View File

@@ -14,7 +14,11 @@
let syncFilter: MangaSyncState | '' = $state('');
let error: string | null = $state(null);
let expandedId: string | null = $state(null);
let chaptersByManga: Record<string, AdminChapterRow[] | 'loading'> = $state({});
type ChaptersView = {
items: AdminChapterRow[];
total: number;
};
let chaptersByManga: Record<string, ChaptersView | 'loading'> = $state({});
async function load() {
error = null;
@@ -40,7 +44,11 @@
if (!chaptersByManga[id]) {
chaptersByManga[id] = 'loading';
try {
chaptersByManga[id] = await listAdminChapters(id);
const resp = await listAdminChapters(id, { limit: 500 });
chaptersByManga[id] = {
items: resp.items,
total: resp.page.total ?? resp.items.length
};
} catch {
delete chaptersByManga[id];
error = 'failed to load chapters';
@@ -113,10 +121,16 @@
{#if chaptersByManga[m.id] === 'loading'}
<p>Loading chapters…</p>
{:else if chaptersByManga[m.id]}
{@const list = chaptersByManga[m.id] as AdminChapterRow[]}
{#if list.length === 0}
{@const view = chaptersByManga[m.id] as ChaptersView}
{#if view.items.length === 0}
<p class="muted">No chapters.</p>
{:else}
{#if view.total > view.items.length}
<p class="muted">
Showing first {view.items.length} of {view.total}
chapters (cap reached).
</p>
{/if}
<table class="inner">
<thead>
<tr>
@@ -127,7 +141,7 @@
</tr>
</thead>
<tbody>
{#each list as c (c.id)}
{#each view.items as c (c.id)}
<tr>
<td>{c.number}</td>
<td>{c.title ?? '—'}</td>