Repository navigation
fix: give the chat upload size cap and type allowlist a value (issue #1405) - #1426
Conversation
…1405) A 28.6 MB attachment produced a composer chip and a POST that had still not returned after 105 seconds, with no progress, no timeout and no error, and a Windows executable uploaded and was processed with a 200. Both limits were simply unset. Open WebUI already enforces both, in the pinned image's own upload_file_handler, read out of the running container rather than assumed. An extension outside rag.file.allowed_extensions is refused with 400 before the bytes reach storage. A file over rag.file.max_size megabytes is refused with 413 and the stored object deleted. The size cap is also published through /api/config as file.max_size, which is what lets the composer refuse an oversized file before the request is made. docker-compose.yml has carried RAG_FILE_MAX_SIZE since the #1108 follow-up, with an empty default and a comment claiming it served that client-side guard. It never did: the key was missing from hive_rag_env_config.py's reconcile map, so the value could not reach a box that had already booted. That is the #722 class exactly, and the same seam #1388 built for the permission tree is the fix. The cap is 25 MB because RAG_MAX_UPLOAD_BYTES is already 26214400 on edge-api and on the markitdown sidecar, and Open WebUI multiplies its megabyte value by 1024 * 1024, so this is byte for byte the ceiling the product already enforces on its own document ingest path. The allowlist is derived by a rule rather than by taste: Open WebUI's own known_source_ext plus the extensions Loader._get_loader names in its branches plus txt, so nothing the product can read is refused. A test asserts that derivation against the vendored loader so an upstream bump that adds a format fails there rather than silently starting to refuse it. Two coercions carry weight. The size persists as an int, matching the row a first boot seeds, because a type that differs from the seeded one is how the boolean keys went wrong before. The allowlist persists as a list, because upstream evaluates `file_extension not in allowed_file_extensions` and a persisted comma string would turn that membership test into a substring test, admitting `df` because `pdf` contains it. A malformed size, or an allowlist that parses to nothing, fails the container at startup naming the variable rather than booting with no limit. An unset variable still writes nothing, so an administrator's own choice survives and an enterprise deployment that never sets these is not capped.
|
You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard. |
|
Warning Review limit reachedNext included review available in 43 minutes. View limit detailsLimit details: You’ve used the included review currently available. You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository. Review configuration: ⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Plus Run ID: 📒 Files selected for processing (4)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
Five findings from the CodeRabbit CLI and the Antigravity adversarial pass, four fixed and one rebutted. A size cap of zero is now refused. It is not merely a useless value, it is a value the two consumers read oppositely: Open WebUI's server-side check is `if max_size and len(contents) > ...`, where zero is falsy and enforces nothing, while the browser's is `file.size > max_size * 1024 * 1024`, where zero rejects every file of non-zero length. A deployment set to zero would refuse every upload in the composer while leaving the API accepting files of unlimited size, and would read as a working cap. Allowlist entries written with a leading dot now match. `.pdf,.txt` is the obvious thing for an operator to write, and upstream compares against an extension it has already stripped the dot from, so persisting the dot would produce a list matching nothing and refuse every upload while the deployment looked correctly configured. The refusal message for an all-separator allowlist no longer suggests leaving the variable unset as a way to remove the check. In a compose deployment an unset variable means the compose default applies, and in the reconcile it means the persisted row is left alone, so neither removes anything. Removing the allowlist is a deliberate change to the compose default. `.env.example` carried the same wrong implication for both variables and is corrected the same way. The vendored-loader coverage test could not fail: its regex assumed single quotes, and an upstream reformat would have yielded an empty set, which is a subset of anything, so the test would have reported full coverage over nothing. It now accepts either quote style and asserts a plausible count first. Rebutted: the `?process=false` bypass of upstream's extension check is real and is filed as #1425. It is pre-existing, is not created or widened here, and needs its own exact-literal backend patch rather than riding along with a config reconcile.
Adversarial review, two streams. Six findings, five fixed, one rebuttedThis diff is an input-parsing path, so the security pass is mandatory and was run. Stream 1: CodeRabbit CLIRan against the committed diff on this branch, base
Fixed. Both blocks now state the substitution rule and say that removing a limit is a deliberate change to the compose default. This is the CLI, not the GitHub App check. If the App reports Stream 2: Antigravity,
|
CodeRabbit GitHub App: SKIPPED, not passed
That is a green check that cannot go red. It reviewed nothing, and counting it as a passing review stream would be exactly the quiet-absence shape this repository has spent the day removing. Reported as SKIPPED. The CodeRabbit CLI did run against this branch and its result is recorded in the review comment above. That is the CodeRabbit signal for this pull request; the App's green square is not. |
CI note: one required check failed on an unrelated flaky test, and it was not silently rerun away
This diff contains no Go code. Four files, none under Diagnosed rather than shrugged at, and filed as #1427: the test's own comment says "whether the publish or the reap wins is a legitimate race in outcome and this deliberately does not pin it", and then line 1439 pins it by requiring Recorded here so the rerun is visible as a rerun. A required check that fails at random on unrelated work trains everyone to rerun on red, which is how a real failure eventually gets rerun away. |
One more thing verified rather than assumed: compose interpolation of a 69-entry defaultA All 69 entries survive intact, and the size cap arrives as the string Note the deliberate omission: |
|
CI green: all 9 required checks pass or skip on |
) (#1436) Closes #1428. Follow-up to #1426, which was correct and landed inert. ## What was wrong `deploy/docker/docker-compose.yml` passed `RAG_FILE_MAX_SIZE: ${RAG_FILE_MAX_SIZE:-25}`, derived carefully in #1426 to match `RAG_MAX_UPLOAD_BYTES` byte for byte. Line 98 of the demo box's `.env` reads `RAG_FILE_MAX_SIZE=100`. An explicit value beats a compose fallback, so the chat surface went on publishing a 100 MB cap to every browser while edge-api and the markitdown sidecar refused anything over 25 MB. The defect is not the number. Two services held two independently settable ceilings for one user action, in two different units, with nothing making them agree. Setting either alone diverges the product, and the chat surface diverged in the generous direction: it accepted what Hive refuses everywhere else. Editing the box's `.env` to 25 would have closed today's gap and left the mechanism that produced it fully intact. ## What this does One settable ceiling, `RAG_MAX_UPLOAD_BYTES`, in bytes. Compose already interpolated the identical expression into edge-api and into the markitdown sidecar as `MAX_UPLOAD_BYTES`. Open WebUI was the lone outlier. It now takes that same expression, and `hive_rag_env_config.derived_upload_cap` floors it into the whole megabytes Open WebUI wants. That unit mismatch was the only reason a second variable ever existed, and it is the only reason this is code rather than plain interpolation. `RAG_FILE_MAX_SIZE` is no longer read. A container that finds it set refuses to start, naming both variables, rather than quietly obeying or quietly ignoring it. Rounds down, deliberately. The chat surface must never accept a file the ingest path would refuse, so a byte value that is not a whole number of megabytes yields the smaller cap. `edge-api`'s own parse of the same variable now fails the boot on a malformed value instead of logging a warning and falling back to 25 MB. One variable, one parse rule, and that claim is literally true rather than approximately true only because review caught that it was not. `strconv.ParseInt` accepts a leading sign and Python's `isdigit()` does not, so `RAG_MAX_UPLOAD_BYTES=+26214400` would have booted edge-api and crashed the open-webui container: the divergence had moved from the value to the parser, which is this pull request's own defect class one layer down. Both sides now require ASCII digits only, and both refuse the Unicode digits `str.isdigit()` accepts, one of which `int()` parses happily and the other of which it raises on. ## The box `.env` needs no change, and I made none Because compose no longer names `RAG_FILE_MAX_SIZE` in the `open-webui` service's `environment:` block, the box's line 98 never enters the container. The fix is correct on the deployment without editing a file that has no off-box backup. Removing that now-inert line from `/home/sakib/hive/.env` is recommended tidy-up, not a requirement. I deliberately did not do it silently. If it is ever re-plumbed into the container, `derived_upload_cap` fails the boot rather than letting it win. ## Measured before, on the deployed chat Signed in as a run-key-scoped fixture account (`owui-e2e+capdiv2908@…`, created through `scripts/seed-owui-e2e-user.py`, not the demo account). No password was set, reset or rotated anywhere in this work. | Path | File | Result | | --- | --- | --- | | Composer | 64 KB `.txt` | `POST /api/v1/files/?process=true` 200, chip resolves | | Composer | 30 MB `.txt` | POST issued, no response in 44 s, spinner, no progress, no error | | Composer | 110 MB `.txt` | Refused client side in under 1 s, toast "File size should not exceed 100 MB.", zero requests issued | | Straight at the container | 30 MB | 200 in 0.33 s, accepted and stored | | Straight at the container | 110 MB | 413 in 14 s | | Authenticated `GET /api/config` | | `file.max_size: 100` | Two claims that circulated during triage are wrong, and the measurements above are why. The client-side guard is not dead code and `/api/config` does publish a `file` block. It is auth-gated, and Open WebUI's token lives in `localStorage` rather than a cookie, so any fetch without an explicit `Authorization` header sees the unauthenticated shape, which genuinely has no `file` key. A guard reading `undefined` cannot produce the string "File size should not exceed 100 MB." The backend was not hanging. 30 MB straight at the container is a 200 in a third of a second. The stall is transport, and it has its own issue now: see below. ## What is lost by capping chat at 25 MB Something real, and it is worth saying rather than implying otherwise. The chat attachment path does not traverse edge-api's upload cap or the markitdown sidecar. There is no `CONTENT_EXTRACTION_ENGINE` on the `open-webui` service, so Open WebUI extracts in process and only the embedding call leaves through the gateway. A 30 MB attachment therefore did work on that path, in 0.33 s, and this change refuses it. So this is a coherence decision, not the removal of a pure lie: Hive should give one answer to "how large a document can you read", and the smaller of its two existing answers is the honest one, since the developer API and the sidecar cannot be talked into the larger. Part of the old 100 MB was a lie regardless, because nothing that large survives the transport (see #1435). ## The advertised 25 MB is close to what the transport delivers, and #1435 says so Raised during review of this change, and it is a fair challenge, so it was measured rather than estimated. Every upload that traverses Cloudflare hits a wall at about 120 seconds. From the box itself, out to the public hostname and back: 24 MB completes with a 200 in 118.9 s, 25 MB fails with a 524 at 125.1 s, 30 MB fails with a 524 at 125.1 s. The 125 s figure is constant and size independent, which is a timeout rather than a bandwidth limit. So a file sitting exactly at the cap this change advertises can still die at Cloudflare after two minutes of silence. That is not something this change can fix, because the wall is in front of the developer API's `/v1/rag` upload too, and lowering the chat cap below the ingest ceiling would re-create the very divergence being removed here. It is filed as **#1435** with the measurements, and the composer's missing per-file progress, which is what makes a 119 s success and a 125 s failure look identical while running, is filed there too. What this change does fix is the reported case: a 30 MB attachment now takes the immediate, readable path the 110 MB one already took, naming the limit before a single byte is transferred. ## What was and was not verified end to end #1426 carried this disclosure and it is why its inertness was caught in a day, so it is a habit rather than a one-off. **Exercised against the deployed stack, signed in, with a real file through the real composer:** every "before" row in the table above, the authenticated and unauthenticated `/api/config` shapes, and the server-side statuses at 24, 25, 30 and 110 MB from three network positions. **Exercised against the deployed frontend with one value stubbed:** the "after" behaviour. The frontend, account, session and file are the deployed ones; the only thing rewritten is `file.max_size` in the `/api/config` response, from the 100 the box publishes today to the 25 this change derives. That is the single value the merge changes, and the deploy that sets it happens on merge, so this is the only way to exercise the post-change composer beforehand. **It is not an end-to-end capture.** That the derivation produces 25 from 26214400 is proven by the unit tests, not by that screenshot. The scope is stated in the committed capture log too, not only here. **Not exercised at all:** the reconcile actually running inside a rebuilt open-webui container, and therefore the boot-time refusal when `RAG_FILE_MAX_SIZE` is present. Both are unit tested and neither has been observed on a real container start. The first real deploy is the first time `derived_upload_cap` runs in place, and if it raises, open-webui fails to start. That risk is the reason the refusal path has four tests rather than one, but tests are not a deploy. **The recommended follow-up after merge:** re-run the same probe with the stub removed and confirm the deployed `/api/config` publishes `file.max_size: 25`, plus `docker compose logs open-webui` for the reconcile's summary line. ## Not in scope **#1425 is untouched and stays open.** The allowlist is bypassed by `process=false`, which the composer's own image path uses. That needs a new exact-literal backend patch under `deploy/docker/owui-patches/`, a larger and riskier diff than a config derivation, and it should not ride along. Nothing here narrows or widens that seam, so its survival is not a regression from this change. The extension allowlist #1426 landed is unchanged. ## Guard that can go red `scripts/test_owui_rag_env_config.py` fails if `docker-compose.yml` ever sets `RAG_FILE_MAX_SIZE` again as a key or an interpolation, or if the three services stop sharing one expression. Verified red by re-introducing the knob and running it, then reverted: ``` AssertionError: docker-compose.yml sets 'RAG_FILE_MAX_SIZE:': a second, independently settable chat upload cap is what issue #1428 is. ``` That file is run by `make test-scripts`, which is a required check. ## Tests - `python3 scripts/test_owui_rag_env_config.py` green, including derivation, floor-not-round, refusal of the superseded variable, refusal of a malformed or sub-megabyte ceiling, and the single-source-of-truth compose guard. - `go test ./apps/edge-api/cmd/server/... -run TestParseRAGMaxUploadBytes` green, covering the empty fallback and refusal of `25MB`, `26214400 bytes`, `0`, `-1`, `1.5` and `twenty`. ## Buglog entry ```json {"id":"BUG-1428-upload-cap-divergence","date":"2026-08-29","title":"Chat upload cap and RAG ingest ceiling were two independently settable numbers, so the #1426 fix deployed inert","error_message":"Chat composer published a 100 MB attachment cap while edge-api and the markitdown sidecar enforced 26214400 bytes; a 30 MB attachment was accepted and stored by Open WebUI after a silent multi-minute upload","root_cause":"docker-compose.yml gave the open-webui service its own RAG_FILE_MAX_SIZE variable, in whole megabytes, settable independently of the RAG_MAX_UPLOAD_BYTES expression it passes edge-api and the sidecar in bytes. PR #1426 set that variable's compose default to 25, but the demo box's .env carried an explicit RAG_FILE_MAX_SIZE=100 and an explicit value beats a compose fallback, so the fix never reached the deployment it was written for.","fix":"Deleted RAG_FILE_MAX_SIZE as a settable knob. The open-webui service now takes the same ${RAG_MAX_UPLOAD_BYTES:-26214400} expression as the other two services, and hive_rag_env_config.derived_upload_cap floors it into whole megabytes, rounding down so the chat surface can never accept what the ingest path refuses. The container refuses to start if RAG_FILE_MAX_SIZE is present, and edge-api now fails the boot on a malformed ceiling instead of warning and falling back. A compose guard in scripts/test_owui_rag_env_config.py fails if a second knob is re-introduced.","tags":["config","docker-compose","open-webui","rag","uploads","silent-no-op","deploy-drift"]} ```
## Summary This is the batched buglog follow-up for the pull requests merged to `main` on 2026-08-29. Its diff is `.wolf/buglog.jsonl` and nothing else. Per `.claude/rules/openwolf.md`, every fixed bug, error, failed test or failed build must be logged, but the line may never be appended on a fix branch. `merge=union` in `.gitattributes` resolves concurrent appends locally and is ignored by GitHub's server side merge, so two branches that both appended land in hard conflict there. An unmergeable pull request gets no `refs/pull/N/merge`, no `pull_request` run and therefore zero checks, and the required status gate then blocks the merge for a reason the page never states (issue #873). Each fix accordingly carried its entry in its own pull request body, and this pull request copies them onto `main` in one batch, which the protocol explicitly prefers over one pull request per entry. ## Scope examined Fifty nine pull requests merged to `main` on 2026-08-29. Forty eight of them carried at least one entry, for eighty two entries in total. Thirty two of those were already on `main` and are skipped, leaving fifty appended here from thirty four pull requests. The largest block of skips comes from #1342, the equivalent batch for the 2026-08-28 merges, which merged earlier the same day and already landed thirty six entries covering #1257, #1268, #1276, #1277, #1287, #1292, #1293, #1294, #1296, #1301, #1303, #1305, #1313, #1335 and #1337. ## What landed Fifty entries appended, one JSON object per line, append only. The 232 pre-existing lines are byte identical to `origin/main` (verified by hashing the first 232 lines of the result against the base file). Every line in the resulting file parses as JSON and carries `error_message`, `root_cause`, `fix` and `tags`. | Source | Entries | |---|---| | #1083 | 2 | | #1277 | 1 | | #1278 | 1 | | #1298 | 1 | | #1334 | 1 | | #1336 | 3 | | #1343 | 1 | | #1346 | 1 | | #1351 | 1 | | #1365 | 2 | | #1368 | 1 | | #1369 | 1 | | #1371 | 3 | | #1375 | 3 | | #1376 | 1 | | #1378 | 1 | | #1379 | 2 | | #1388 | 5 | | #1389 | 3 | | #1390 | 2 | | #1393 | 1 | | #1394 | 1 | | #1410 | 1 | | #1417 | 1 | | #1421 | 1 | | #1423 | 1 | | #1424 | 1 | | #1426 | 1 | | #1429 | 1 | | #1431 | 1 | | #1433 | 1 | | #1434 | 1 | | #1436 | 1 | | #1439 | 1 | Entries are copied verbatim from their source pull request bodies. Nothing was rewritten, no field was invented, and no field was added. No JSON needed repair: all eighty two extracted entries parsed on the first attempt and all four required fields were present on every one. ## Merged pull requests that carried no entry Eleven of the fifty nine. Recorded here because the gap is itself the useful signal. | Pull request | Title | Assessment | |---|---|---| | #1013 | chore(deps): bump the go-minor-patch group across 1 directory with 4 updates | Dependabot bump, no defect fixed, no entry expected | | #1015 | chore(deps): bump the go-minor-patch group across 1 directory with 6 updates | Dependabot bump, no entry expected | | #1016 | chore(deps): bump golang from 1.26-alpine to 1.27-alpine in /deploy/docker | Dependabot bump, no entry expected | | #1218 | chore(deps): bump postcss from 8.5.19 to 8.5.26 in /apps/desktop | Dependabot bump, no entry expected | | #1219 | chore(deps): bump golang.org/x/crypto from 0.41.0 to 0.52.0 in /apps/control-plane | Dependabot bump, no entry expected | | #1342 | chore: batch buglog entries for the 2026-08-28 merges | The previous batch pull request itself, correctly carries no entry of its own | | #1364 | chore: remove four dead skills and record the patterns that cost time | Protocol gap. The body records patterns that cost time, which is the shape of a buglog entry, but none was written as one | | #1383 | test: retire stale expected-failure markers, restore the ones that are true (#1381, #1382, #1324) | Protocol gap. Stale `it.fails` markers reading as red is a real defect that was fixed here and should have carried an entry | | #1384 | docs: correct D-047, hive-auto reverted to variable pricing (D-059) | Decision ledger correction, arguably a documentation defect, no entry written | | #1387 | chore(deps): bump next from 15.5.23 to 16.3.3 in /apps/agent-console | Dependabot bump, no entry expected | | #1398 | docs: rescue the 2026-08-25 parity captures and add the 2026-08-29 QA matrix evidence | Documentation and evidence rescue, no entry written | Six of the eleven are Dependabot bumps and one is the previous batch, so the genuine protocol gaps are #1364, #1383, #1384 and #1398. Of those, #1383 is the one worth a follow-up: it fixed a real defect class (a stale expected-failure marker reads as a red "Expect test to fail" and gets dismissed as pre-existing) and left no record. ## Entries skipped as already present Thirty two. Thirty of them matched an entry already on `main` on `error_message`, `id` or `fix`. Two more from #1278 are semantic duplicates that an exact match would have missed, and were skipped after reading the landed entries they duplicate: - #1278's `streaming content_block_start omits text field` entry is covered by the consolidated `bug-2026-08-28-anthropic-sdk-wire-conformance` entry landed from #1296, whose root cause names the same `omitempty` on `StreamContentBlock.Text`. - #1278's `GET /v1/models leaked an upstream provider name` entry is covered by `BUG-1284`, landed from #1300, which names the same `public.model_aliases.summary` publication path. #1278's third entry, on `top_k` forwarding producing a 400, is not covered anywhere on `main` and is appended here. #1342 recorded #1278 as fully "merged into #1296", which was accurate for two of its three entries. ## Note on entry quality One appended entry is thin: #1277's parity re-score record carries `error_message` of `n/a` and a root cause of "console had no privacy/data-policy surface at all". It is a parity gap record rather than a defect record. It is included exactly as written rather than embellished, per the protocol's preference for the author's own words. ## Test plan - [x] Branch cut fresh from `origin/main`, diff is `.wolf/buglog.jsonl` and nothing else - [x] First 232 lines byte identical to the base file (md5 match) - [x] All 282 resulting lines parse as JSON and carry `error_message`, `root_cause`, `fix` and `tags` - [x] No `.wolf/` telemetry (`anatomy.md`, `memory.md`, `token-ledger.json`, `hooks/_session.json`, `buglog.json`) in the commit - [ ] The six required checks report green via the inert path allowlist in `.github/workflows/ci.yml` --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Closes #1405.
What was happening
Measured live on
https://chat-hive.scubed.coon 2026-08-29: a 28.6 MB attachment produced a composer chip and aPOST /api/v1/files/?process=truethat had still not returned after 105 seconds, with no progress bar, no spinner text, no timeout and no error. The chip looked identical to the chip for a 12 byte file that uploaded in under a second. Separately,evil.exeuploaded and was processed with a 200.The stall matters as much as the missing cap. It is the interface asserting a state the backend has not reached, which is the quiet-absence shape this repository has spent the day removing.
The enforcement already exists. It had no value to enforce
Read out of the running container rather than assumed (
docker exec hive-open-webui-1, imagehive-open-webui:v0.10.2-branded, box at69e9be9b2):routers/files.py:315-323refuses an extension outsiderag.file.allowed_extensionswith 400 naming the type, before the bytes reach storage.routers/files.py:355-361refuses a file overrag.file.max_sizemegabytes with 413 naming the limit, and deletes the object it had just stored.main.py:2014publishesrag.file.max_sizeto clients asfile.max_size, which is what letsMessageInput.svelterefuse an oversized file before the request is made.Both config keys were unset on the box, so all three checks were inert.
The trap, which is #722's and is why this needs more than an env var
docker-compose.ymlhas carriedRAG_FILE_MAX_SIZEsince the #1108 follow-up, with an empty default and a comment saying it servesrag.file.max_sizeto clients so the composer's guard fires client-side. It never did.deploy/docker/owui-patches/hive_rag_env_config.py'sRAG_CONFIG_ENVdid not list the key, so nothing reconciled it, and the row Open WebUI'sseed_defaultswrote on first boot (None, meaning no limit) has outranked the environment ever since.RAG_ALLOWED_FILE_EXTENSIONSwas not in compose at all.So setting either variable on an already-booted box was a silent no-op, exactly like #722, #772, #832 and #997, and the same shape as the skills permission #1388 fixed. This change uses the seam #1388 built.
Where the limits are actually enforced
Server-side, in the upload handler, on every request. The client-side check is a faster and more legible refusal of the same rule, driven by the same persisted value published through
/api/config; it is not the enforcement and does not need to be trusted. A client that skips it gets a 413.One residual is stated rather than left unsaid: upstream gates the type check on
if process and allowed_file_extensions, so aPOST ?process=falseskips it. The size cap is not gated that way and applies to every upload. This is pre-existing, is not made worse here (there was no allowlist at all before), and needs its own exact-literal backend patch, so it is filed as #1425. It is also why this change does not need image extensions in the allowlist and does not break image attachments:MessageInput.svelteuploads anything with animage/MIME type through that sameprocess=falsepath.The values, chosen deliberately
Size: 25 MB. Not a preference and not a guess.
RAG_MAX_UPLOAD_BYTESis already26214400on edge-api and on the markitdown sidecar, and Open WebUI multiplies its megabyte value by1024 * 1024, so 25 is byte for byte the ceiling Hive already enforces on its own document ingest path. Accepting more in chat than the product accepts elsewhere would give two different answers to "how large a document can Hive read". It is comfortably above any realistic demo document, and if a real one is refused the operator raisesRAG_FILE_MAX_SIZEin.env, documented in.env.example. A refusal is immediate and names the limit, so the failure mode of a cap set too low is a legible error rather than the stall this replaces.Types: everything this deployment can turn into text. Derived by a rule, not by taste: Open WebUI's own
known_source_ext, plus the extensionsLoader._get_loadernames in its own branches (pdf, doc, docx, odt, ppt, pptx, xls, xlsx, csv, msg, rst, xml, html, htm, md, epub), plus txt. 69 entries. A narrower list would refuse a file the product could have read, which fails in front of a user and is worse than no cap..exe,.zip,.dll,.bin,.so,.msi,.apk,.jar,.isoand.dmgare all outside it, and a test asserts that.An allowlist is the only type control upstream offers, and it is needed because
_get_loader's finalelsefalls back toTextLoaderfor any unrecognised extension: without one, an executable is "processed" into mojibake, stored, and served back on request.Two coercions that carry weight
rag.file.max_sizepersists as anint. Upstream parses the variable withint(), so a first boot seeds a number. A string happens to work in both consumers by coincidence, and a reconciled value whose type differs from the seeded one is howui.enable_login_formwent wrong before.rag.file.allowed_extensionspersists as alist. This one is load bearing. Upstream evaluatesfile_extension not in allowed_file_extensions, so persisting the raw comma string would silently turn a membership test into a substring test: extensiondfwould be admitted becausepdfcontains it, and so would every other extension that is a substring of an allowed one.What this does on failure
RAG_FILE_MAX_SIZE("25MB","twenty") raises at startup naming the variable. The container fails to boot rather than booting with no cap and looking configured.",") raises the same way, because an empty list is falsy in upstream'sif process and allowed_file_extensionsand would turn the check off while the configuration says it is on.uploadFilethrows thedetailstring,MessageInputsurfaces it as a toast and removes the chip.Deliberately not in this pull request
Per-file progress on the composer chip. The reported stall was an unbounded upload, and a 25 MB cap bounds it: an oversized file is now refused before the request is made rather than hanging. A progress indicator is a separate frontend change with its own design questions and belongs in its own pull request. Saying so here rather than quietly shipping half the issue.
Tests
scripts/test_owui_rag_env_config.py, run bymake test-scripts, which is a required CI check. Eight new cases. Seven were verified red before the implementation existed, individually, with the failures recorded:The eighth,
test_unset_upload_limits_leave_the_persisted_values_alone, passed before the change and is reported as what it is: an invariant guard, not a red-to-green test. It could not go red beforehand because the keys did not exist at all. It can now: coercing a blank value to0or[]fails it, which is the regression it exists to catch (issue #797).make test-scriptsexits 0.python3 scripts/test_owui_rag_env_config.pyprintsok.Not verified end to end, and why
No 30 MB file was uploaded against a running stack carrying this change, and I am not claiming otherwise. The chat stack cannot be started locally: this checkout's
.envhasSUPABASE_URL,SUPABASE_ANON_KEYandSUPABASE_SERVICE_ROLE_KEYempty, and #1254 covers the wider breakage. The deployed box cannot be used either, because the change is not on it until this merges, and mutating its persisted config by hand to simulate the result would be a production change made outside the deploy path during a period when the owner may walk the demo at any time.What was verified, on the real substrate:
hive-open-webui-1container, both the 400 and the 413 branch, at their real line numbers, matching the vendored copy this repository reasons about./api/configpublishingrag.file.max_sizeasfile.max_sizein that same container, which is the mechanism the client-side refusal depends on.69e9be9b2, confirmed withgit -C ~/hive rev-parse HEADrather than by trusting a green deploy.The gap between that and an end-to-end upload is: this change writes two config rows, and the rows are then read by code that was verified to exist and to enforce. The first deploy after merge closes it, and a 30 MB attachment plus an
.exeagainst the deployed chat is the acceptance test.No UI code is touched, so the visual proof rule does not apply to this diff; the user-visible behaviour change comes entirely from Open WebUI's own existing error paths.
Buglog entry
{"date":"2026-08-29","title":"Chat upload had no size or type limit: a 30 MB file stalled with no error and an .exe was accepted","error_message":"POST /api/v1/files/?process=true never returns for a 28.6 MB attachment; no progress, no timeout, no error. evil.exe uploads with 200.","root_cause":"Open WebUI enforces rag.file.max_size and rag.file.allowed_extensions in upload_file_handler, but both were unset. RAG_FILE_MAX_SIZE was in docker-compose.yml with an empty default and a comment claiming it fed the client-side guard, while the key was missing from hive_rag_env_config.py's RAG_CONFIG_ENV, so the persisted first-boot row outranked the environment and no value could ever reach a booted box. RAG_ALLOWED_FILE_EXTENSIONS was absent entirely.","fix":"Reconcile rag.file.max_size and rag.file.allowed_extensions through hive_rag_env_config.py, with an int coercion for the size and a list coercion for the allowlist (a persisted comma string turns upstream's not-in membership test into a substring test). Compose defaults 25 MB, matching RAG_MAX_UPLOAD_BYTES 26214400, and an allowlist derived from known_source_ext plus the loader's own document branches. A malformed size or an empty allowlist fails startup rather than booting unenforced.","tags":["open-webui","uploads","persistent-config","silent-failure","issue-1405","issue-722"]}