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
This commit is contained in:
Classic298 2026-08-17 08:52:16 +02:00 committed by GitHub
parent 954613944b
commit 3df485582d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 11 additions and 3 deletions

View file

@ -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(

View file

@ -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,

View file

@ -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,