The history diff, delete, and version-restore routes authorize the URL
prompt_id but then act on a caller-supplied history/version id without
checking it belongs to that prompt (IDOR). Filter by prompt_id in
compute_diff and delete_history_entry, and reject a cross-prompt version_id
in update_prompt_version.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The SVG-XSS hardening introduced in f5f4b5895 correctly rejects
data:image/svg+xml URIs on new input, but also caused a
pydantic_core.ValidationError when reading pre-existing models from
the database that had SVG data URIs stored as their profile images.
This ValidationError propagated unhandled through _to_model_model and
get_all_models, crashing the entire /api/models endpoint with HTTP 500
and leaving users with no models available in the UI.
Fix:
- Wrap validate_profile_image_url() in a try/except ValueError inside
ModelMeta.check_profile_image_url. Legacy entries are cleared to None
with a warning log instead of raising — the /model/profile/image API
endpoint already falls back to /static/favicon.png when the value is
empty.
- Default ModelMeta.profile_image_url to None instead of hardcoding
/static/favicon.png, since the serving endpoint handles the fallback.
- Add a per-model try/except in ModelsTable.get_all_models so that any
future unexpected validation failure on a single record skips that
model with an error log rather than aborting the entire list.
insert_chat_files() stored any caller-supplied file_id with no ownership
check, so a user could attach another user's file to their own chat and
then read it through the shared-chat access path in has_access_to_file().
Filter file_ids to those the caller owns, is admin for, or can read.
Also repairs an UnboundLocalError introduced in 260ead64d: the existing
duplicate-check referenced `session` before it was assigned (db=session),
so the function threw on every call and no chat_file rows were persisted.
ModelMeta.profile_image_url now runs validate_profile_image_url, rejecting SVG/script data URIs (matching UserUpdateForm and ChannelWebhookForm). The /model/profile/image endpoint enforces the PROFILE_IMAGE_ALLOWED_MIME_TYPES allowlist and sets X-Content-Type-Options: nosniff, so an SVG data URI can no longer be served inline on-origin. Closes the fourth profile-image XSS sink missed by the user and webhook fixes.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Both _get_access_grants and _to_prompt_model referenced an undefined
local variable 'session' instead of the 'db' parameter passed to each
method. Because these helpers are called outside of any
'async with get_async_db_context()' block, 'session' did not exist in
their scope, causing a NameError on every prompt fetch.
The NameError was silently swallowed by the broad 'except Exception'
clause in get_prompt_by_id, which returned None — causing the frontend
[id]/+page.svelte to immediately redirect back to /workspace/prompts
rather than rendering the prompt editor.
Also adds the missing 'logging' import and module-level 'log' logger,
which was referenced (but never imported) in insert_new_prompt,
update_prompt_version, and delete_prompt_by_id.
The bulk-clear-chat-tags endpoint's only frontend wrapper,
deleteTagsById in src/lib/apis/chats/index.ts, is dead: nothing imports
or calls it, the path is referenced nowhere else, and the route handler
has no internal caller. Removes the route handler, the dead wrapper, and
the now-orphaned Chats.delete_all_tags_by_id_and_user_id model method
(its sole caller was this route). The shared
Chats.delete_orphan_tags_for_user method is untouched.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix: prevent mass-assignment user_id spoofing in POST /api/v1/evaluations/feedback
Two independent gaps in backend/open_webui/models/feedbacks.py let an
authenticated caller forge the `user_id` (and `id`, `version`) on a new
feedback record submitted to POST /api/v1/evaluations/feedback:
1. `FeedbackForm` declared `model_config = ConfigDict(extra='allow')`,
so Pydantic preserved any extra fields supplied in the request body —
including `user_id`, `id`, `version`. The form is the public input
boundary for the endpoint and should not accept unknown fields.
2. In `insert_new_feedback`, the dict literal placed
`**form_data.model_dump()` AFTER `'id': id`, `'user_id': user_id`,
`'version': 0`. Python dict-literal duplicate-key resolution is
last-wins, so any of those fields present in `form_data` overwrote
the server-derived values.
Combined effect: a regular user could POST a feedback record with an
arbitrary `user_id`, attributing the rating to any other user. The Elo
leaderboard at backend/open_webui/routers/evaluations.py computes model
rankings from these records, and the admin export
(GET /api/v1/evaluations/feedbacks/export) and admin list
(GET /api/v1/evaluations/feedbacks/all) display the spoofed attribution.
Two fixes, defense-in-depth:
- FeedbackForm: switch `extra='allow'` to `extra='ignore'` so Pydantic
drops unknown fields at parse time. Sub-models (RatingData / MetaData /
SnapshotData) intentionally keep `extra='allow'` because their contents
are deliberately schema-flexible — the spoofing surface was the form,
not the sub-payloads.
- insert_new_feedback: spread `form_data.model_dump()` first, then
overlay server-controlled fields (`id`, `user_id`, `version`,
`created_at`, `updated_at`) so the explicit keys win on duplicate-key
resolution regardless of what reaches the function. Matches the secure
pattern already used in backend/open_webui/models/functions.py:120.
Reported by yantongggg in GHSA-rjmp-vjf2-qf4g. Same root-cause class as
the prior published GHSA-hr43-rjmr-7wmm (folder mass-assignment, fixed
in v0.9.0); that fix did not generalize across the codebase, this fix
closes the feedback variant.
Co-authored-by: yantongggg <yantongggg@users.noreply.github.com>
* chore: trim comments
---------
Co-authored-by: yantongggg <yantongggg@users.noreply.github.com>
Non-admin GET /api/v1/prompts/tags went through get_prompts_by_user_id,
which loaded every active prompt with its full content/data/meta plus
owner records and all access grants, then ran one has_access query per
prompt that wasn't owned by the caller - all so the endpoint could
collapse the result to a sorted tag list. With 600 prompts this took
several seconds while the admin path (a single SELECT) returned in <1s.
Add Prompts.get_tags_by_user_id which selects only the tags column and
applies the same EXISTS-based access filter used by /list. Also tighten
the admin get_tags to project just the tags column instead of full rows.
The endpoint is now one DB query (plus one for groups), no row hydration,
no N+1.
Co-authored-by: Claude <noreply@anthropic.com>
get_prompts_by_user_id used to fetch every active prompt (with users +
all access grants), then call AccessGrants.has_access() once per prompt
that the user did not own. With 600+ prompts this issued ~600 extra
round-trips per request and explained the multi-second delay reported in
the GET /api/v1/prompts and /api/v1/prompts/tags endpoints for non-admin
users.
Push the access check into a single SQL query via the existing
AccessGrants.has_permission_filter (EXISTS subquery), so only accessible
rows come back from the DB. Users and access grants for the surviving
rows are still batch-fetched, no N+1 anywhere on this path.
Co-authored-by: Claude <noreply@anthropic.com>
The chat table has no computed columns (no DEFAULT, SERIAL/IDENTITY,
or TRIGGER that populate server-side values on UPDATE), and every
column modified by update_chat_by_id is set explicitly from Python
values earlier in the function. db.refresh therefore issues a SELECT
that replaces those just-written Python values with the round-tripped
database representation of the same values, which is a no-op for
functional purposes but pulls the entire chat.chat JSON blob back over
the network and through the driver's JSON decoder.
On large, active chats where chat.chat can reach tens of megabytes,
skipping the refresh measurably reduces latency and eliminates one
~JSON-sized transient allocation per write.