From 3df485582df932f1df28714db65373093fd6734d Mon Sep 17 00:00:00 2001 From: Classic298 <27028174+Classic298@users.noreply.github.com> Date: Mon, 17 Aug 2026 08:52:16 +0200 Subject: [PATCH] fix: reject skill IDs that are not URL path safe (#27660) 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 7d2daabf8e..9c268d8dcc 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 @@ -180,6 +181,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,