From f0a4dd9668b8e1ef6cc2ccfb7503ea7522b391cf Mon Sep 17 00:00:00 2001 From: Classic298 <27028174+Classic298@users.noreply.github.com> Date: Tue, 28 Jul 2026 19:44:31 +0200 Subject: [PATCH] fix: reject skill IDs that are not URL path safe A skill ID goes straight into the path of every mutating skill endpoint (/api/v1/skills/id/{id}/...), but create only replaced spaces with hyphens. An ID containing a "/" was stored verbatim as the primary key, so the route never matched, the request fell through to the SPA static mount and the client got 405 Method Not Allowed. The skill could not be opened, edited, toggled or deleted, by admins either, and since skill.name is UNIQUE it could not be recreated under a corrected ID. Percent-encoding does not help: uvicorn decodes the path before Starlette routes it, so the only remaining fix was a direct database write. Create now rejects any ID outside [a-z0-9_-] with 400 instead of silently storing an unreachable one. Two frontend paths that fed unsanitized IDs into it are fixed as well: the manual "Skill ID" field, which was bound with no sanitization at all and is the path that reproduces on every version, and the markdown import, which put the raw frontmatter name into the ID before opening the editor in clone mode, where the reactive slugify is disabled. Existing rows with an unreachable ID are not repaired here; rewriting a primary key would also have to re-point the access grants keyed on it. Fixes #27655 --- backend/open_webui/routers/skills.py | 8 ++++++++ src/lib/components/workspace/Skills.svelte | 4 ++-- src/lib/components/workspace/Skills/SkillEditor.svelte | 2 +- 3 files changed, 11 insertions(+), 3 deletions(-) diff --git a/backend/open_webui/routers/skills.py b/backend/open_webui/routers/skills.py index 4fefa2cb7a..540ef7ea2f 100644 --- a/backend/open_webui/routers/skills.py +++ b/backend/open_webui/routers/skills.py @@ -1,4 +1,5 @@ import logging +import re from typing import Optional from fastapi import APIRouter, Depends, HTTPException, Request, status @@ -175,6 +176,13 @@ async def create_new_skill( form_data.id = form_data.id.lower().replace(' ', '-') + # The id goes into /id/{id}/... paths, so anything outside the slug charset is unreachable once stored. + if not re.fullmatch(r'[a-z0-9_-]+', form_data.id): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=ERROR_MESSAGES.DEFAULT('Invalid skill ID'), + ) + existing = await Skills.get_skill_by_id(form_data.id, db=db) if existing is not None: raise HTTPException( diff --git a/src/lib/components/workspace/Skills.svelte b/src/lib/components/workspace/Skills.svelte index fdc6f2a465..1395b56350 100644 --- a/src/lib/components/workspace/Skills.svelte +++ b/src/lib/components/workspace/Skills.svelte @@ -21,7 +21,7 @@ deleteSkillById, toggleSkillById } from '$lib/apis/skills'; - import { capitalizeFirstLetter, parseFrontmatter, formatSkillName } from '$lib/utils'; + import { capitalizeFirstLetter, parseFrontmatter, formatSkillName, slugify } from '$lib/utils'; import TagInput from '$lib/components/common/Tags/TagInput.svelte'; import Tooltip from '../common/Tooltip.svelte'; @@ -306,7 +306,7 @@ const displayName = formatSkillName(rawName); sessionStorage.skill = JSON.stringify({ name: displayName, - id: fm.name || '', + id: slugify(rawName), description: fm.description || '', content: mdContent, is_active: true, diff --git a/src/lib/components/workspace/Skills/SkillEditor.svelte b/src/lib/components/workspace/Skills/SkillEditor.svelte index 894107bc19..06495ac2f5 100644 --- a/src/lib/components/workspace/Skills/SkillEditor.svelte +++ b/src/lib/components/workspace/Skills/SkillEditor.svelte @@ -39,7 +39,6 @@ const fm = parseFrontmatter(content); if (fm.name && !name) { name = formatSkillName(fm.name); - id = fm.name; } if (fm.description && !description) { description = fm.description; @@ -52,6 +51,7 @@ return; } loading = true; + if (!edit) id = slugify(id); await onSubmit({ id,