Task/Plan: Runtime security guards for LumiBase
Goal
Establish a clear runtime security foundation before the AI Harness is allowed to create or apply changes to the system. Each guard has a single responsibility, its own name, and its own mount point in the pipeline so the team can understand, review, and later enforce Harness usage of it.
Task scope
-
Control-plane access guard
- Block non-admin principals at system administration surfaces such as roles, policies, permissions, users, teams, settings, API keys, admin, and CDC.
- Protect system tables/collections such as
lumibase_users,lumibase_roles,lumibase_permissionsfrom privilege-escalation operations.
-
Security headers middleware
- Attach a default CSP to every response.
- Enable
nosniff,DENYframe,no-referrer,Permissions-Policy, COOP, and CORP to reduce XSS/CSS injection, clickjacking, and data leakage across the browser surface.
-
File upload policy
- Forbid the public role from creating upload metadata or pushing raw media bytes.
- Cap upload size via
FILE_UPLOAD_MAX_BYTES, default 10 MiB — enforced on the client-declaredContent-LengthAND on the true body byte count for raw-byte surfaces (a lying/absentContent-Lengthcannot slip past). - Allow MIME types via
FILE_UPLOAD_ALLOWED_MIME_TYPES, defaulting to common images, PDF, CSV, and text. - Cross-check the declared filename extension against the MIME type.
- Content-sniff raw-byte uploads (magic bytes) so a declared image that is really an executable/other type is rejected; reject any executable signature (PE/ELF/Mach-O) outright.
- Reject SVGs that embed script or active content (
<script>, inlineon*=handlers,javascript:,<foreignObject>,<iframe>/<embed>, and<!DOCTYPE>/<!ENTITY>XXE) — the "image with a shell in it" case. - Scan raster image bytes (
imageHasEmbeddedActivePayload) for an embedded script/executable payload —<?php,<script,<html,<!doctype htmlanywhere in the file — and reject (UPLOAD_EMBEDDED_PAYLOAD). This catches a polyglot that has valid image magic bytes AND an appended shell/HTML, which the prefix magic-byte check alone cannot see. Runs synchronously before the storage write on both runtimes. - Covered upload surfaces:
POST /api/v1/files(metadata),PUT /api/v1/files/upload/:key(signed bytes), andPOST /api/v1/media/:key(RBAC-authorized media bytes). The surface set is centralized inclassifyUploadSurface()and pinned by theclassifies every known upload surfaceregression test so a new byte-accepting route cannot be added without wiring it into the guard.
Configurable per site: the size cap and MIME allowlist resolve
per-site DB config → env override → defaultviaservices/upload-policy-service.ts(cached, fail-safe — falls back to env/default if the DB/cache are unavailable so the guard never fails open). The catalogue of selectable types lives in@lumibase/shared/schemas(upload-policy.ts) so the server allowlist and the Studio file picker share one source. Admins edit it at Studio → Settings → Uploads, backed byGET/PUT /api/v1/uploads/config(GETfor any member to drive the picker'saccept;PUTisrequireSiteAdmin, persisted to thesettingsrow keyedupload_policy, restricted to catalogued MIME types).Serving hardening (
routes/media.ts): downloads are returned withContent-Disposition: attachment+X-Content-Type-Options: nosniff, so a stored HTML/SVG object cannot be rendered (and its script executed) under the app origin when opened top-level. This composes with the global CSP (script-src 'self', nounsafe-inline) as defense-in-depth. The storage adapters map the logicalcontentTypeonto the native R2httpMetadata/ S3ContentTypefield so the servedContent-Typeround-trips correctly (previously it was written only to custom metadata and came back undefined).Feature spec (hub):
.kiro/specs/upload-file-controls/(requirements/design/tasks) is the single anchor for this control and cross-references every location above.Planned — image re-encode sanitization (not yet implemented): the false-positive-free defense against image polyglots is to re-encode the uploaded image (strip everything but the pixels) rather than scan for markers. This depends on the
ImageAdapter(Sharp on Docker / Cloudflare Images on CF) proposed in.kiro/specs/image-transform-dsl/and needs a sync-vs-async design decision (an async re-encode in themedia-processingqueue leaves a raw-file window, mitigated by the attachment+nosniff serving above). Tracked as task F1 in the upload-file-controls spec; the security rationale lives here, the image-processing mechanics in image-transform-dsl. -
Outbound URL guard
- Provide a utility that validates outbound URLs before any import/URL-fetch
feature calls
fetch. - Block non-HTTP(S) protocols, URLs with embedded credentials, localhost, RFC1918, link-local, loopback, and cloud metadata IPs.
- Provide a utility that validates outbound URLs before any import/URL-fetch
feature calls
Implementation plan
- Split the components into separate modules:
security-headers,control-plane-access-guard,file-upload-policy, andoutbound-url-guard. - Mount
security-headerson the global middleware chain. - Mount
control-plane-access-guardandfile-upload-policyon the authenticated tenant-scoped API chain. - Add the upload-policy configuration environment variables to
Bindings. - Add dedicated unit tests per guard so the test names reflect each responsibility.
- Add specific audit events for guard denials:
control_plane_access_deniedandfile_upload_policy_denied. - In the next Harness phase: require every AI-invoked tool/fetch/import to go
through
validateOutboundUrlorguardedFetch. - In the next hardening phase: map granular permissions if needed for non-admin operators.
Completion criteria
- A non-admin principal receives
CONTROL_PLANE_FORBIDDENwhen calling system control-plane routes. - The public role cannot create file upload metadata or upload raw media bytes.
- Uploads that exceed the size cap (by declared length or true body size), use a
MIME outside the allowlist, mismatch their declared extension/content, or embed
active SVG content are rejected before writing to storage — on every covered
upload surface, including
POST /api/v1/media/:key. - Media downloads are served as attachments with
nosniff, and the servedContent-Typeround-trips through both storage adapters. - Every response carries a CSP and the baseline security headers.
- The outbound URL guard has tests for localhost, private IPs, link-local metadata IPs, and dangerous protocols.
- The control-plane guard and file upload policy write dedicated audit events when the request has a DB context.
Guard: ItemService RBAC context (fail-open protection)
Problem
ItemService enforces row/field RBAC only when it is constructed with a
permissionCtx. Without one, this.permissions = null and every permission
check short-circuits to "allowed" (fail-open). This is deliberate — system
workers legitimately run without a user principal — but it means a request-scoped
call site that forgets permissionCtx silently bypasses authorization,
indistinguishable from a deliberate system context.
This bug shipped once: the AI updateItem skill ran ItemService.patch() on a
service missing permissionCtx → LLM item mutations skipped RBAC (PR #151). A
follow-up review found the MCP endpoint (routes/mcp.ts) had the same bug, even
though its comment promised "an MCP client can never do more than the same token
could via the Agent API".
Regression-prevention mechanism (implemented)
Every ItemService construction goes through two explicit helpers in
apps/cms/src/services/item-service-factory.ts:
itemServiceForRequest(c)— used for EVERY service built while handling an HTTP request (routes, GraphQL resolvers, MCP). It always attachespermissionCtxfrom the Hono context;permissionCtxis applied last sooverridescannot accidentally drop enforcement.itemServiceForSystem(deps, reason)— used for system/background flows. It requires aSystemContextReason('scheduler'|'background-worker'|'compliance-erasure') so the author must state why the fail-open posture is safe.
The regression test apps/cms/src/__tests__/item-service-rbac-context.test.ts
scans the source and fails CI if a bare new ItemService(...) appears
outside the factory (except a reviewed allowlist). A future maintainer cannot
re-introduce a fail-open path without a reviewer noticing.
Call-site classification table (audit)
| Call site | Mode | Reason |
|---|---|---|
routes/items.ts | request | REST CRUD — enforces RBAC per bearer token |
routes/ai.ts (/chat, /approvals/:id/decide) | request | AI skills enforce the same RBAC as /items (PR #151) |
routes/mcp.ts | request | fixed — previously missing permissionCtx (fail-open) |
routes/admin-sar.ts | request | SAR export — admin-gated + enforces RBAC |
graphql/context.ts | request | GraphQL inherits the REST surface's governance |
services/scheduler-worker.ts | system scheduler | cron retention sweep, no user principal |
services/veto-commit-worker.ts | system background-worker | commit after the veto window locked in the human decision |
services/agent-run-worker.ts | system background-worker | governed agent run — HITL/autonomy gated in the harness, not per-user RBAC |
services/erasure-service.ts | system compliance-erasure | erasure/SAR gated by admin + dual-control at the service layer |
Checklist when adding a new ItemService
- Is the call site inside an HTTP request path (has
Context<AppEnv>)? → useitemServiceForRequest(c). NEVER hand-writepermissionCtxinline. - Is the call site a worker/cron/compliance flow running system-privileged? → use
itemServiceForSystem(deps, reason)and pick the semantically correctreason. - If a direct
new ItemService(...)is genuinely required (rare) → add it toALLOWED_DIRECT_CONSTRUCTIONin the guard test with a justification, so a reviewer sees it. - Updated the classification table above when adding a new request/system call site.
Completion criteria (this guard)
- No production file (outside the factory + allowlist) calls
new ItemService(...)directly — guaranteed by the source-scan test. - The AI and MCP endpoints enforce row/field RBAC exactly like
/items(LLM skills cannot exceed the token's privileges). - Every system context declares an explicit, greppable
reasonpresent in the audit table.