Security
Decision boundary: keep this a restricted showcase, not an open multi-user service; the application has neither authenticated principals nor classroom ownership checks (app/api/classroom/route.ts:11-29,40-61; lib/server/classroom-storage.ts:37-42).
This page distinguishes configured controls, blocked features, and exercised evidence.
It records static source/configuration findings, not exploit execution, live gate verification, or human acceptance; actual observations belong on Status.
See Operations for the inventory and recovery procedures, and Architecture for data flow.
Why the whole application vhost is gated
No route in the inspected app/api/** tree authenticates a caller or authorizes access to an owned resource; no application middleware.ts supplies such a boundary (deploy/nginx/maic.loca.zone.conf:3-6).
Representative handlers take caller bodies, headers, and IDs directly: classroom write/read, chat, headless generation, and job polling (app/api/classroom/route.ts:11-29,40-61; app/api/chat/route.ts:45-100; app/api/generate-classroom/route.ts:11-50; app/api/generate-classroom/[jobId]/route.ts:11-38).
A provider-key check is not caller authentication: chat may fall back to a configured server key before checking whether a key exists (app/api/chat/route.ts:76-85; lib/server/provider-config.ts:235-239).
Concrete exposure without the gate
| Surface | Source-level abuse opportunity | Evidence |
|---|---|---|
POST /api/classroom | Write a caller-chosen classroom ID without the validation used by GET; the storage path is not contained before writing | app/api/classroom/route.ts:24-27,52-53; lib/server/classroom-storage.ts:21-28,76-78 |
| Chat and generation | Trigger model work through caller-selected or server-configured providers | app/api/chat/route.ts:65-100,135-152; app/api/generate/scene-content/route.ts:74,139-162; app/api/generate-classroom/route.ts:34-39 |
| Grading and PBL | Trigger model work from answer/chat requests | app/api/quiz-grade/route.ts:28-38,58-90; app/api/pbl/chat/route.ts:25-35,58-69 |
| Image, video, speech, search | Spend a usable provider credential and consume server/provider resources | app/api/generate/image/route.ts:30-77; app/api/generate/video/route.ts:31-82; app/api/generate/tts/route.ts:22-83; app/api/web-search/route.ts:15-45 |
| Provider verification | Perform real upstream requests, not just local settings validation | app/api/verify-model/route.ts:8-39; app/api/verify-image-provider/route.ts:27-61; app/api/verify-video-provider/route.ts:27-61 |
POST /api/proxy-media | Fetch a caller-supplied HTTP(S) URL through the host, subject only to the limited URL guard, and buffer its response | app/api/proxy-media/route.ts:23-55; lib/server/ssrf-guard.ts:19-48 |
| PDF/audio uploads | Parse multipart input and buffer file bytes without a per-handler byte cap | app/api/parse-pdf/route.ts:23-55; app/api/transcription/route.ts:14-48 |
/classroom/<id> | Opening a classroom can resume pending scene generation or missing-media generation | app/classroom/[id]/page.tsx:103-149 |
The write defect is specifically asymmetric: GET calls isValidClassroomId, while POST uses stage.id || randomUUID() and passes that value directly to persistence (app/api/classroom/route.ts:24-27,52-56).
Persistence joins that ID into a filename and creates parent directories before its temporary-file/rename write, without a corresponding containment check (lib/server/classroom-storage.ts:21-28,76-78).
This is a path-containment defect, not merely the ability to overwrite a same-ID classroom; no destructive reproduction is claimed here (app/api/classroom/route.ts:24-27; lib/server/classroom-storage.ts:77-78).
The app declares experimental.proxyClientMaxBodySize: '200mb', but that is not an exercised per-route upload limit and does not replace one (next.config.ts:7-9; app/api/parse-pdf/route.ts:23-55; app/api/transcription/route.ts:14-48).
Downstream prompt truncation likewise does not bound the original uploaded body (lib/constants/generation.ts:6-10; lib/generation/outline-generator.ts:49-51,102-103).
Current credential limitation
Configured: this deployment supplies no server provider credentials and leaves DEFAULT_MODEL empty (deploy/maic.env.example:3-16,22-30).
Provider-spending paths still exist for visitors who supply usable BYOK credentials; this is not a claim that anonymous visitors can spend an absent operator key (lib/utils/model-config.ts:6-21; lib/server/resolve-model.ts:33-54).
Blocked: headless POST /api/generate-classroom does not use those browser settings: it resolves the server model with {}, defaults to OpenAI gpt-4o-mini, then throws for the missing key (lib/server/classroom-generation.ts:178; lib/server/resolve-model.ts:30; lib/ai/providers.ts:945-947).
Adding server keys later would change the financial exposure of unauthenticated routes; it would not add application authentication (app/api/chat/route.ts:76-100; lib/server/provider-config.ts:235-239).
The proxy, filesystem-write, and upload risks do not require a funded model credential (app/api/proxy-media/route.ts:23-55; app/api/classroom/route.ts:11-29; lib/pdf/pdf-providers.ts:170-177).
The configured edge gate
The application HTTPS server block applies HTTP Basic authentication at server scope, using /etc/nginx/maic.htpasswd; no credential value is published here (deploy/nginx/maic.loca.zone.conf:20-29).
Both the /api/ and / locations inherit that gate, so application HTML, assets, and APIs share one admission boundary rather than an API-only exception (deploy/nginx/maic.loca.zone.conf:27-29,40-73).
TLS certificates are configured for the app; port 80 serves ACME challenges and otherwise redirects to HTTPS, not to an ungated application upstream (deploy/nginx/maic.loca.zone.conf:75-78,107-119).
These are configuration statements; loaded-config and denial/no-leak observations must be supplied separately on Status.
| Control | Configured value | Evidence |
|---|---|---|
| Application gate | Server-level HTTP Basic; credential file /etc/nginx/maic.htpasswd | deploy/nginx/maic.loca.zone.conf:27-29 |
| Gate-header boundary | Empty Authorization upstream header in both proxy locations | deploy/nginx/maic.loca.zone.conf:44-51,64-71 |
| Request body ceiling | client_max_body_size 32m | deploy/nginx/maic.loca.zone.conf:31-33 |
| UI request rate | Per-client-IP 20r/s, burst 60, nodelay, rejection status 429 | deploy/nginx/maic-ratelimit.conf:3; deploy/nginx/maic.loca.zone.conf:60-62 |
| API request rate | Per-client-IP 2r/s, burst 20, nodelay, rejection status 429 | deploy/nginx/maic-ratelimit.conf:4; deploy/nginx/maic.loca.zone.conf:40-42 |
| Backend exposure | Docker publishes only 127.0.0.1:52300:3000 | deploy/compose.yml:19-20 |
| Runtime ceiling | 3 GiB, 3 CPUs, no-new-privileges:true | deploy/compose.yml:25-28 |
The backend binding supplies no unauthenticated public alternate port; it does not turn loopback access by local host processes into authenticated access (deploy/compose.yml:19-20; app/api/classroom/route.ts:11-29,40-61).
Do not change that binding to 0.0.0.0 or publish another backend listener without preserving the gate boundary (deploy/compose.yml:19-20; deploy/nginx/maic.loca.zone.conf:27-29,43,63).
Authorization is explicitly stripped inside each proxy location, because a location’s own proxy_set_header directives replace inheritance of that directive set (deploy/nginx/maic.loca.zone.conf:35-37,44-51,64-71).
The gate credential must not reach OpenMAIC; BYOK headers and request-body fields are separate and deliberately still reach the application (deploy/nginx/maic.loca.zone.conf:49-50,69; lib/server/resolve-model.ts:64-71; app/api/chat/route.ts:49-69).
Request rates are not provider-spend budgets or limits on the number of outstanding generation jobs; the submission route independently schedules background generation (deploy/nginx/maic-ratelimit.conf:3-4; app/api/generate-classroom/route.ts:34-39).
The incoming 32m body ceiling does not cap remote response bytes downloaded by the server (deploy/nginx/maic.loca.zone.conf:33; app/api/proxy-media/route.ts:38-46).
Why Authelia was not used
The shared Authelia instance has access_control.default_policy: bypass; an unruled new domain would therefore be authorized instead of denied (deploy/nginx/maic.loca.zone.conf:13-19, recording the shared-instance decision).
Adding a domain rule would require editing shared, other-project configuration, which is outside this deployment’s scope (deploy/nginx/maic.loca.zone.conf:15-19).
The chosen boundary is consequently a dedicated HTTP Basic file over TLS on this vhost, not an assumed-safe forward-auth call into the permissive default (deploy/nginx/maic.loca.zone.conf:18-29,75-78).
There is no claim that Authelia cannot implement a suitable policy; it was rejected for this instance’s current policy and ownership boundary (deploy/nginx/maic.loca.zone.conf:13-19).
The companion wiki is intentionally public and serves a separate static root, not /app/data or the app upstream (deploy/nginx/maic.loca.zone.conf:81-105; deploy/compose.yml:21-22).
Its public status is not permission to publish classroom contents, browser keys, or gate credentials; those belong to different storage/configuration boundaries (lib/server/classroom-storage.ts:6-7; lib/store/settings.ts:944-946; deploy/nginx/maic.loca.zone.conf:29,88).
Residual risks the gate does not fix
Basic authentication restricts who reaches these code paths; it does not rewrite their validation, introduce ownership, or isolate one admitted visitor from another (deploy/nginx/maic.loca.zone.conf:27-29,40-73; app/api/classroom/route.ts:11-61).
The following are source-level residual risks, not claims that attacks were exercised against this deployment.
Hostname-string SSRF checks, without DNS pinning
validateUrlForSSRF parses HTTP(S) URLs and compares the hostname against a small set of names, exact addresses, prefixes, and suffixes (lib/server/ssrf-guard.ts:19-48).
It has no DNS resolution, complete resolved-IP classification, or socket/address pinning (lib/server/ssrf-guard.ts:8-48).
[INFERENCE] A public-looking hostname is therefore not evidence that the actual connection stays outside private/local networks; string filtering cannot establish that destination boundary (lib/server/ssrf-guard.ts:31-48).
Several provider endpoints validate only a client-supplied base URL in production; server-configured bases bypass those branches (lib/server/resolve-model.ts:33-45; app/api/generate/image/route.ts:38-61; app/api/parse-pdf/route.ts:36-51).
The deployment does set NODE_ENV=production, so those conditional checks are configured to run, but their implementation remains string-based (deploy/compose.yml:15-18; lib/server/ssrf-guard.ts:31-48).
Redirect protection is not universal
/api/proxy-media explicitly disables redirect following and rejects 3xx responses; do not incorrectly describe that handler as following redirects (app/api/proxy-media/route.ts:37-41).
Azure voice listing and PDF-provider verification also reject redirects (app/api/azure-voices/route.ts:36-46; app/api/verify-pdf-provider/route.ts:35-43).
Other calls lack corresponding redirect controls and per-hop revalidation: MinerU parsing, TTS requests, and the Seedream adapter use fetch without that restriction (lib/pdf/pdf-providers.ts:321-325; lib/audio/tts-providers.ts:153-165,198-206,272-289; lib/media/adapters/seedream-adapter.ts:87-92).
Model SDK construction likewise supplies no redirect-hop validation in the inspected setup (lib/ai/providers.ts:958-1025).
[INFERENCE] Validating the initial client URL alone does not establish the destination of later redirect hops (lib/server/ssrf-guard.ts:19-48; lib/pdf/pdf-providers.ts:321-325).
Provider-returned URLs bypass the guard
Qwen TTS takes data.output.audio.url from the provider response, fetches it directly, and buffers the returned audio without a URL or byte-count check (lib/audio/tts-providers.ts:296-311).
Classroom image/video generation also downloads provider-returned URLs through downloadToBuffer, without calling the SSRF guard (lib/server/classroom-media-generation.ts:51-58,114-117,155).
Those classroom download calls use default redirect behavior rather than revalidating every hop (lib/server/classroom-media-generation.ts:51-58).
The headless classroom path is blocked by missing credentials here, but its download behavior remains relevant before anyone enables server-side generation (lib/server/classroom-generation.ts:178,365-399; lib/ai/providers.ts:945-947).
Response sizes are not reliably bounded
The classroom downloader has a 120-second timeout and checks a 100 MiB threshold against Content-Length, then reads the complete response with arrayBuffer() (lib/server/classroom-media-generation.ts:48-58).
An absent header becomes zero, and no streamed byte counter enforces that declared ceiling when the header is missing or under-reports the body (lib/server/classroom-media-generation.ts:54-58).
/api/proxy-media buffers a complete blob() without an explicit fetch timeout or response-byte limit; the route’s maxDuration declaration is not a byte cap (app/api/proxy-media/route.ts:21,38-55).
The Qwen audio download has the same unbounded arrayBuffer() pattern (lib/audio/tts-providers.ts:303-311).
[INFERENCE] Container memory ceilings can limit total container consumption but may turn oversized responses into application failure rather than a controlled per-request rejection (deploy/compose.yml:27; app/api/proxy-media/route.ts:46).
Interactive iframes are not an inert-content boundary
Interactive scenes render supplied HTML through srcDoc, or load content.url, with sandbox permissions allow-scripts allow-same-origin allow-forms allow-popups (components/scene-renderers/interactive-renderer.tsx:12-26).
The HTML patcher adjusts layout/CSS; it is not a script-removal sanitizer (components/scene-renderers/interactive-renderer.tsx:31-66).
[INFERENCE] Combining scripts with same-origin permissions for srcDoc is unsafe as a strong isolation claim, especially when the parent origin stores provider keys (components/scene-renderers/interactive-renderer.tsx:20-25; lib/store/settings.ts:28-149,944-946).
Do not describe generated or imported interactive content as guaranteed offline, harmless, or unable to interact with its origin (components/scene-renderers/interactive-renderer.tsx:21-25).
BYOK still trusts the server
Visitor provider settings, including keys, are persisted in browser settings-storage; model configuration retrieves them for requests (lib/store/settings.ts:28-149,944-946; lib/utils/model-config.ts:6-21).
Generation sends model/key/base URL headers to the app server, while chat and grading also forward credential/configuration fields (app/generation-preview/page.tsx:91-116,472-482; components/chat/use-chat-sessions.ts:400-414; components/scene-renderers/quiz-view.tsx:90-109).
The server constructs the provider client with the selected key; BYOK is not a direct browser-to-provider secret boundary (lib/server/resolve-model.ts:33-54; lib/ai/providers.ts:934-954,958-1025).
The empty server environment avoids supplying an operator-owned provider account; it does not make visitor prompts or credentials invisible to the server processing their request (deploy/maic.env.example:3-8,22-30; app/api/chat/route.ts:49-100).
Data exposure boundary
Server-persisted classrooms are shared behind one gate
Any gate-authenticated visitor who knows a valid classroom ID can retrieve that server-persisted classroom; GET validates ID syntax and existence, not ownership (app/api/classroom/route.ts:40-61).
The record is { id, stage, scenes, createdAt }, with no owner field and no per-user ACL (lib/server/classroom-storage.ts:37-42,69-78).
This means every persisted classroom is available to every admitted visitor by ID; it does not imply the application provides a browse-all catalogue (app/api/classroom/route.ts:42-61).
Job polling similarly checks the job identifier and presence rather than the caller’s identity (app/api/generate-classroom/[jobId]/route.ts:11-38).
Classroom-media reads do contain path defenses: ID validation, rejection of traversal/NUL, required media/audio directory, real-path containment, and file checks (app/api/classroom-media/[classroomId]/[...path]/route.ts:26-55).
Those defenses protect the read path; they are not ownership checks and do not repair classroom POST’s separate write defect (app/api/classroom-media/[classroomId]/[...path]/route.ts:20-62; app/api/classroom/route.ts:24-27).
Treat server records and their backups as shared-showcase data, not confidential per-user storage (lib/server/classroom-storage.ts:37-42; app/api/classroom/route.ts:40-61).
Browser-local classrooms are not a shared server catalogue
Browser-generated classroom saves go to that visitor’s IndexedDB, and the recent-classroom list reads local stage records (lib/store/stage.ts:249-264; lib/utils/stage-storage.ts:36-71,137-159).
Another visitor in a separate browser does not gain access to those browser-local records merely by passing the gate; the server classroom endpoint reads server files, not another browser’s database (lib/utils/database.ts:176-196,298-310; lib/server/classroom-storage.ts:48-52).
Loading first tries the browser database and only then asks the server for that ID (app/classroom/[id]/page.tsx:35-59).
This distinction is a storage boundary, not a promise that locally held data never leaves the browser: generation and chat send context and credentials through the server (app/generation-preview/page.tsx:91-116,472-482; components/chat/use-chat-sessions.ts:400-414).
It also is not protection against shared-browser access or malicious same-origin interactive content ([INFERENCE]; components/scene-renderers/interactive-renderer.tsx:20-25; lib/utils/database.ts:176-196).
Server backups do not cover browser storage, and Clear Local Cache deletes it; recovery limits are detailed in Operations (deploy/compose.yml:21-22; components/settings/general-settings.tsx:35-51).
Hardening backlog — NOT implemented here
These are requirements for a future change, not controls delivered by the D-088 gate. Each item names the present code gap; completion would require its own source change and exercised evidence.
- Per-route request-byte limits. Bound JSON, PDF, and audio input at the transport/stream boundary before buffering; reject excess with a controlled response rather than relying solely on the edge’s broad 32m ceiling (
app/api/generate-classroom/route.ts:13-31;app/api/parse-pdf/route.ts:23-55;app/api/transcription/route.ts:14-48;deploy/nginx/maic.loca.zone.conf:33). - Classroom-ID validation on writes. Apply a runtime ID/type check and path containment before persistence; GET’s current syntax validation is not shared by POST (
app/api/classroom/route.ts:24-27,52-53;lib/server/classroom-storage.ts:44-46,76-78). - DNS-pinned outbound requests. Resolve and classify all candidate addresses, pin the permitted socket destination, and consistently reject or revalidate redirects and provider-returned URLs (
lib/server/ssrf-guard.ts:19-48;lib/audio/tts-providers.ts:303-311;lib/server/classroom-media-generation.ts:51-58). - Per-user identity and authorization. Introduce an authenticated principal, durable ownership, and consistent read/write/job/media ACLs; a shared Basic-auth gate is not an application account model (
deploy/nginx/maic.loca.zone.conf:27-29;lib/server/classroom-storage.ts:37-42;app/api/generate-classroom/[jobId]/route.ts:11-38).
The same outbound-request work also needs actual streamed response-byte limits, not just Content-Length checks (lib/server/classroom-media-generation.ts:54-58; app/api/proxy-media/route.ts:46).
Interactive-content isolation needs a separate explicit design before treating untrusted classrooms as safe to share (components/scene-renderers/interactive-renderer.tsx:20-25).
Until those changes are implemented and independently exercised, widening the audience widens who can reach the defects rather than removing them (deploy/nginx/maic.loca.zone.conf:27-29; app/api/classroom/route.ts:24-27; lib/server/ssrf-guard.ts:19-48).