One path: /v1/*. We do not use /api/ — that's Casdoor's path. The
BASE_API_PREFIX env knob is gone; the prefix is hard-coded in
apis/base.go and apis/serve.go banner.
Server-side mounts:
- apis: bindHealthApi, bindCollectionApi, bindRecordCrudApi, etc. all
attach under r.Group("/v1") (was r.Group(BASE_API_PREFIX, default /api))
- platform plugin: /api/platform/* -> /v1/platform/* (including auth and
org sub-groups)
- kms plugin: /api/kms/* -> /v1/kms/*
- functions plugin: /api/functions/* -> /v1/functions/*
- cloudsql plugin: /api/cloud-sql/*, /api/meta/* -> /v1/*
- superuser OIDC: /_/api/oidc/config -> /_/v1/oidc/config
- core settings: default RateLimitRule labels /api/batch, /api/ ->
/v1/batch, /v1/
- apis/batch.go: ValidBatchActions regexes match /v1/collections/*;
internal rewrite targets /v1/collections/* (was /v1/base/collections/*,
a stale artifact)
Clients:
- sdk/base-js (BaseClient.collections, files, realtime, CRDT WS,
signIn/refresh/health) targets /v1/*
- sdk/base-react (use-crdt WS path) targets /v1/crdt
- ui-react admin (lib/api.ts, settings.application copy) targets /v1/*
- cmd/cli (NewClient.APIPrefix) defaults to /v1; client_test.go and
cmd/cli_test.go stubServer use /v1/*
- cmd/typegen calls /v1/collections
Upstream paths untouched: Casdoor IAM (/api/login, /api/userinfo,
/api/signup, /api/send-verification-code, /api/get-user), KMS Infisical
(/api/v1/auth/universal-auth/login, /api/v2/workspace/environments),
Kubernetes tasks (/api/v1/namespaces/...), IPFS (/api/v0/...), Gitea/
GitLab/Discord/Strava/Patreon/Box/Wakatime upstream APIs. Those are
their contracts, not ours.
Tests: all 437 prior /api/ test URLs across apis/*_test.go and
plugins/platform/auth_proxy_test.go updated to /v1/. Build + go vet
clean. ./apis ./core ./plugins/platform ./plugins/jsvm ./forms all
green. Pre-existing red probes (network/TestRedProbe_*,
migratecmd/TestAutomigrateCollection*) unchanged.
20 KiB
Security Review — ui-react Collection + Record CRUD
Commit reviewed: f7f5ae77 (feat: ui-react collection + record CRUD)
Date: 2026-04-12
Reviewer: internal security pass
Scope: collections.$id.tsx, collections.$id.records.tsx, collections.$id.records.$recordId.tsx, __root.tsx
Findings Summary
| # | Severity | Title | Status |
|---|---|---|---|
| 1 | HIGH | Realtime subscription thrash on query refetch | vulnerable |
| 2 | MEDIUM | JSON.parse in buildFormData throws unhandled, crashes entire submit | vulnerable |
| 3 | MEDIUM | Bulk delete captures collectionName by closure, not by value at confirm time | safe (re-evaluated) |
| 4 | LOW | No double-submit guard on record create (new record path) | vulnerable |
| 5 | LOW | Auth token expiry during long edit silently loses work | vulnerable |
| 6 | INFO | activeProps className replaces base class instead of merging |
safe (cosmetic) |
| 7 | INFO | Rule fields are passthrough to server (no client-side escaping) | safe (by design) |
Blue's Attack Vectors -- Detailed Analysis
Vector 1: Route parameter injection ($id path traversal)
Status: safe
Severity: info
Evidence: TanStack Router's $id param is extracted from a URL path segment. The browser's URL parser and TanStack's path matcher enforce that $id maps to a single path segment -- / characters cannot appear in the param value because the router splits on / before matching. A URL like /collections/..%2F..%2Fsettings/records would need $id = "../../settings" but the router sees four segments (collections, ..%2F..%2Fsettings, records, ...) and ..%2F..%2Fsettings is the literal param value. This value is then passed to base.collections.getOne(id) which calls encodeURIComponent(id) in the PocketBase SDK's baseCrudPath getter before constructing the API URL. The Go backend receives ..%252F..%252Fsettings -- no traversal.
Additionally, even if the param were decoded, the PocketBase Go backend resolves collections by name or 15-char ID lookup in the DB, not by filesystem path. There is no path traversal surface.
Vector 2: Stored XSS via record text fields in CellValue
Status: safe
Severity: info
Evidence: CellValue in collections.$id.records.tsx:360-382 renders all values through React JSX expressions ({String(val)}, {JSON.stringify(val).slice(0, 80)}, {val.join(', ')}). React auto-escapes all text content placed inside JSX expressions. There are no dangerouslySetInnerHTML calls, no <a href> rendering of URL fields (URL values are displayed as text via the default case {String(val)}), and no raw HTML injection path.
The editor field type (rich text) is also rendered as plain text in the table -- the CellValue default case treats it as a string. In the record editor form, editor fields use a <textarea> which naturally escapes HTML.
JSON fields use JSON.stringify(val).slice(0, 80) -- JSON.stringify escapes all special characters in strings, so no XSS through JSON display.
Vector 3: _new sentinel collision with real record ID
Status: safe
Severity: info
Evidence: collections.$id.records.$recordId.tsx:25 uses const isNew = recordId === '_new'. PocketBase record IDs are 15-character alphanumeric strings generated server-side (pattern: [a-z0-9]{15}). The string _new is 4 characters and starts with _, which cannot be generated by PocketBase's ID generator. The PocketBase SDK also rejects custom IDs that don't match its ID pattern on create. Zero collision risk.
Vector 4: Realtime subscription leak on rapid collection switching
Status: vulnerable Severity: HIGH
Evidence: collections.$id.records.tsx:50-73
The useEffect depends on collection.data (line 73: [collection.data, qc]). collection.data is an object reference from useQuery. Every time the query refetches -- which happens on every realtime event because the subscription callback calls qc.invalidateQueries({ queryKey: ['records', name] }) -- React Query returns a new object reference for collection.data (even if the data is structurally identical). This triggers the effect cleanup and re-run.
Attack scenario (concrete):
- User navigates to a busy collection receiving ~5 writes/second.
- Each write triggers the realtime callback, which invalidates the records query.
- The records query refetch also invalidates the
['collections', id]query (due to query observer re-evaluation). collection.datagets a new object reference, triggering the useEffect cleanup+re-run cycle.- Each cycle: unsubscribe old SSE connection, create new SSE connection. Under load, this creates a subscribe/unsubscribe storm that:
- Opens/closes SSE connections rapidly (TCP overhead, server-side goroutine churn)
- May hit the PocketBase server's max concurrent SSE connections
- Causes visible UI flicker as records list goes loading->loaded repeatedly
Even without high write rates, simply navigating between collections rapidly creates orphaned subscriptions during the window between subscribe() call and .then() resolution, IF the user navigates away before the promise resolves AND navigates to another collection before React's cleanup cycle completes. The cancelled flag handles the promise resolution correctly, but the frequency of the subscribe/unsubscribe cycle is the issue.
Fix: Stabilize the effect dependency. Use collection.data?.name (a string primitive) instead of collection.data (an object reference).
Vector 5: Bulk delete targeting wrong collection after navigation
Status: safe Severity: N/A
Evidence: collections.$id.records.tsx:84-92
The bulkDel mutation captures collectionName from the component closure. The collectionName is derived from collection.data?.name ?? id which updates when id (from route params) changes. When the user navigates to a different collection, the entire component remounts because the route param $id changes, which means bulkSelected state resets to new Set() and collectionName is fresh. The mutation cannot fire against a stale collection because the selection is empty after navigation.
Confirmed safe. The React component identity is tied to the route, so navigation = remount = clean state.
Vector 6: FormData field pollution bypassing schema validation
Status: safe (client-side), info (server behavior) Severity: info
Evidence: buildFormData in collections.$id.records.$recordId.tsx:216-258 iterates over fields (the collection schema from the server) and only appends values for fields that exist in the schema. Unknown fields in the data object are silently dropped because only field.name keys from the schema are iterated. The loop for (const field of fields) is schema-driven, not data-driven.
However, the fileUploads ref (line 251-255) iterates over Object.entries(fileUploads.current) which is populated by handleFileChange from <input type="file"> elements. The file input's onChange handler is bound to a specific field.name, so only schema-valid field names can appear in fileUploads. An attacker with devtools could modify the DOM to add arbitrary file inputs, but the PocketBase server validates fields against the collection schema and rejects unknown field names with a 400 error.
Vector 7: Auth token expiry during long edit sessions
Status: vulnerable Severity: LOW
Evidence: The PocketBase SDK does NOT have autoRefresh enabled (it requires explicit opt-in via authWithPassword({autoRefresh: true})). The admin UI in lib/base.ts creates a bare new PocketBase(url) with no auto-refresh. The auth check in beforeLoad only runs on route transitions, not on API calls.
Attack scenario:
- Admin opens record editor, starts editing a complex record.
- Token expires (default PocketBase token lifetime: configurable, typically 14 days for superusers, but can be set shorter).
- Admin clicks Save.
- PocketBase SDK sends the request with the expired token.
- Server returns 401.
save.errordisplays "Something went wrong" (the PocketBase SDK's generic error message for non-200 responses).- The edit is lost if the user navigates away to re-authenticate.
The save.error display on line 179 of $id.tsx and line 145 of $recordId.tsx will show the error, but there is no redirect to /login and no retry mechanism. The user must manually navigate to login, then back, and re-enter their changes.
For an admin tool with long token lifetimes, this is low severity but poor UX.
Vector 8: File upload MIME type bypass
Status: safe (server-side defense) Severity: info
Evidence: The client-side file input has no accept attribute restriction (line 404 of $recordId.tsx), so any file type can be uploaded. However, the PocketBase Go backend:
- Detects content type from file magic bytes, not the
Content-Typeheader (tools/filesystem/filesystem.go:444usesbr.ContentType()which reads the actual blob content type). - Only serves images/video/audio/PDF inline; everything else gets
Content-Disposition: attachment(inlineServeContentTypesallowlist atfilesystem.go:400-410). - Sets
Content-Security-Policy: default-src 'none'; media-src 'self'; style-src 'unsafe-inline'; sandboxon every file download (filesystem.go:458), which blocks script execution even for inline-served files. - HTML files uploaded with a
.pngextension would be detected astext/htmlby magic bytes and served asattachment, not inline.
The backend defense is solid. An HTML file disguised as an image cannot execute JavaScript when served.
Vector 9: JSON field injection via __proto__
Status: safe (with caveat) Severity: info
Evidence: buildFormData at line 230-233 of $recordId.tsx:
if (field.type === 'json' && typeof val === 'string' && val.trim()) {
JSON.parse(val); // validation only -- return value discarded
fd.append(field.name, val);
}
The JSON.parse(val) validates syntax but the return value is discarded. The original string val containing {"__proto__": {"polluted": true}} is sent as-is to the server. However:
- The Go backend's
encoding/jsonunmarshaler does not have prototype pollution -- Go structs are statically typed.__proto__is just a regular JSON key with no special semantics. - The PocketBase JSON field stores the value as a raw JSON blob in SQLite/Postgres. No deserialization into a prototype chain occurs.
- On read-back, the value is served as-is and the frontend
JSON.stringifyinCellValuejust displays it.
No prototype pollution path exists because the server is Go (not JavaScript). The client never deserializes untrusted JSON into live objects -- it treats JSON field values as strings.
Caveat: The JSON.parse call (line 232) throws on invalid JSON. This exception is NOT caught in buildFormData. It propagates up to the mutationFn which makes save.mutate() reject. The save.error state then displays the SyntaxError message. This is functional but the error message will be something like "SyntaxError: Unexpected token x in JSON at position 5" which is not user-friendly.
Vector 10: Collection rule field accepting executable content
Status: safe (by design) Severity: info
Evidence: Rule fields in collections.$id.tsx:330-345 are plain <input> elements registered with react-hook-form. The values are sent as strings to the PocketBase server, which parses them using its own filter/rule parser. The server:
- Validates the rule syntax against its own grammar (not
eval). - Returns a 400 error for invalid rule expressions.
- Does not execute rules as JavaScript or SQL -- rules are parsed into an AST and evaluated against the request context.
The client is correctly a passthrough. Attempting to validate or escape rules on the client would be redundant and could break valid rule expressions. The server is the authority.
Additional Findings (Not on Blue's List)
Finding A: confirm() usage for destructive operations
Status: safe
Evidence: All destructive operations use window.confirm():
- Collection delete:
$id.tsx:119 - Single record delete:
$id.records.tsx:141 - Bulk record delete:
$id.records.tsx:135 - Record editor delete:
$recordId.tsx:96
For an internal admin tool, window.confirm() is acceptable. It blocks the UI thread and requires explicit user interaction.
Finding B: Password in plain JSON over the wire
Status: safe (channel secured)
Evidence: Password is sent via FormData.append('password', pw) at $recordId.tsx:245. The PocketBase admin UI is served from the same origin as the API server. The Go server at apis/serve.go:115 supports HTTPS configuration. The CSP at line 104 allows connect-src 'self', constraining API calls to the same origin. When deployed with HTTPS (which is required for production per the admin UI's same-origin design), passwords are encrypted in transit.
No plaintext password storage occurs -- PocketBase server hashes passwords with bcrypt server-side before storage.
Finding C: activeProps className behavior
Status: safe (cosmetic only)
Evidence: In __root.tsx:20-24, activeProps replaces the className entirely rather than merging with the base className. This means an active link loses hover:bg-neutral-800 (which is fine since active state already has bg-neutral-800). The Dashboard link uses activeOptions={{ exact: true }} (line 23) to prevent false activation on child routes. The Collections link does NOT use exact: true, which means it stays highlighted when viewing /collections/abc123 -- this is correct and intended behavior for a parent nav item.
Finding D: Double-submit on record create
Status: vulnerable Severity: LOW
Evidence: $recordId.tsx:136-137:
disabled={(!isNew && !form.formState.isDirty) || save.isPending}
For isNew === true, the disabled condition simplifies to save.isPending. This means:
- First click:
save.isPendingisfalse, button is enabled, mutation fires. save.isPendingbecomestrue, button is disabled.- BUT: there is a React render cycle between the click event and the
isPendingstate update. A sufficiently fast double-click can fire two mutations before the first render cycle completes.
This is a theoretical race in React's batched state updates. In practice, React 19's automatic batching should prevent this in most cases. The PocketBase server does not have idempotency keys, so two creates would produce two records.
Practical risk: Very low. React 19 batches state updates synchronously within event handlers. The mutation's isPending state is set synchronously by TanStack Query before the handler returns. The window is effectively zero for human interaction. Downgrading from medium to low.
Finding E: beforeLoad auth check -- all protected routes covered
Status: safe Evidence: Auth gate audit:
| Route | beforeLoad present |
Notes |
|---|---|---|
/ (index) |
YES | index.tsx:54 |
/collections |
YES | collections.tsx:86 |
/collections/$id |
YES | $id.tsx:404 |
/collections/$id/records |
YES | $id.records.tsx:389 |
/collections/$id/records/$recordId |
YES | $recordId.tsx:481 |
/settings |
YES | settings.tsx:51 |
/settings/ (index) |
YES | settings.index.tsx:4 (redirects to /settings/smtp) |
/settings/smtp |
inherited | Parent /settings has beforeLoad |
/settings/auth |
inherited | Parent /settings has beforeLoad |
/settings/superusers |
inherited | Parent /settings has beforeLoad |
/settings/* (all) |
inherited | Parent /settings has beforeLoad |
/logs |
YES | logs.tsx:73 |
/login |
NO (correct) | Public route |
TanStack Router's beforeLoad runs top-down: parent before children. Since /settings has beforeLoad, all /settings/* child routes are protected even without their own beforeLoad. This is documented TanStack Router behavior and confirmed in the route tree.
All routes are correctly protected.
Finding F: Subscription thrash (DETAILED) -- the HIGH finding
Status: vulnerable Severity: HIGH
Location: collections.$id.records.tsx:50-73
Root cause: The useEffect at line 50 has [collection.data, qc] as its dependency array. collection.data is a React Query cache object. React Query returns a NEW object reference on every refetch, even when the data is structurally identical (it uses structural sharing by default, but the top-level reference still changes). This means:
- Realtime event fires.
- Subscription callback invalidates
['records', name]. - React Query refetches records.
- The
['collections', id]query may also be re-evaluated (React Query's observer). collection.datagets a new reference.- React re-runs the effect: cleanup (unsubscribe) + setup (new subscribe).
- New subscription immediately receives pending events, goto step 1.
Concrete reproduction:
1. Open /collections/posts/records on a collection with active writes.
2. Open browser DevTools > Network tab.
3. Watch SSE connections: you'll see repeated CONNECT/DISCONNECT cycles on
/v1/realtime as the effect tears down and rebuilds the subscription.
4. Each cycle is a full SSE handshake (HTTP upgrade, auth token validation,
server-side subscription map update).
Impact: Server resource exhaustion under moderate write load. Each subscribe/unsubscribe cycle costs a TCP connection setup, Go goroutine allocation on the server, and SSE event replay. With 10 concurrent admin sessions on a busy collection, this can saturate the SSE connection pool.
Fix applied: Change the dependency from collection.data to collection.data?.name (a string primitive that doesn't change reference on refetch).
Fixes Applied
Fix 1: Stabilize realtime subscription dependency (HIGH)
File: ui-react/src/routes/collections.$id.records.tsx
Change the useEffect dependency from collection.data to collection.data?.name:
- }, [collection.data, qc]);
+ }, [collection.data?.name, qc]);
The effect only needs to re-run when the collection name changes (i.e., when navigating to a different collection). The collection.data object changing on refetch is irrelevant to the subscription, which is keyed by name.
Fix 2: Wrap JSON.parse in try/catch (MEDIUM)
File: ui-react/src/routes/collections.$id.records.$recordId.tsx
The unhandled JSON.parse throw produces a poor error UX. Wrap it to produce a clear validation error:
if (field.type === 'json' && typeof val === 'string' && val.trim()) {
- // Validate JSON before sending
- JSON.parse(val);
- fd.append(field.name, val);
+ try {
+ JSON.parse(val);
+ } catch {
+ throw new Error(`Invalid JSON in field "${field.name}"`);
+ }
+ fd.append(field.name, val);
Blue Handoff
What Blue got right:
- The
cancelledflag pattern for async subscription cleanup is correct and handles the race between cleanup and promise resolution. Solid async hygiene. beforeLoadauth gates on every protected route, including the parent/settingscovering all children. No auth bypass.CellValuerendering is safe -- nodangerouslySetInnerHTML, no<a href>with user-controlled URLs. React's auto-escape is correctly relied upon.buildFormDatais schema-driven (iteratesfields, notdata), preventing field pollution.- The
_newsentinel is safe against collision with PocketBase's ID format. confirm()on all destructive operations.- Disabled submit button during mutation pending state.
What Blue missed:
- The
useEffectdependency oncollection.data(object reference) instead ofcollection.data?.name(stable string) causes subscription thrash under any write load. - The unhandled
JSON.parsethrow inbuildFormDataproduces a crypticSyntaxErrorin the UI instead of a user-friendly validation message.
Fix priority for Blue:
- Subscription dependency stabilization (HIGH -- causes real server load under normal usage)
- JSON.parse error handling (MEDIUM -- poor UX but no security impact)
- Consider
autoRefreshfor long-lived admin sessions (LOW -- optional UX improvement)
Re-review scope: Only the subscription fix needs verification -- confirm that the subscription correctly re-establishes when navigating between collections (the collection.data?.name dep should change on navigation since id changes).