Ein Token darf jetzt mehrere Ordner -- statt einem oder allen

Das Fotostudio schreibt nach vehicles/ UND items/, der Token war aber auf
"vehicles" gefesselt. Damit blieb nur die Wahl zwischen "einer davon" und
"darf ueberall": das eine lehnt die Haelfte der Uploads mit 403 ab, das andere
erlaubt auch sicherungen/.

Gemessen und dabei aufgefallen: config.upload.lua setzt kein clothingPath,
also gilt die Vorgabe items/{model}.png -- und die durfte dieser Token nicht.
Kleidungs-Uploads aus dem Studio sind also stillschweigend an 403 gescheitert.

Die Spalte bleibt ein Textfeld, mehrere Werte stehen durch Komma darin. Damit
gilt JEDER bestehende Token unveraendert weiter: ein Wert ohne Komma ist eine
Liste mit einem Eintrag. Kein Migrationsschritt, kein Sonderfall.

Geprueft mit acht Faellen, darunter zwei, die scheitern MUESSEN: items/ unter
der Fessel "vehicles" (403) und "vehiclesXYZ/a.png" unter "vehicles" -- das
angehaengte / war schon vorher da und bleibt, sonst wuerde ein Praefix seinen
eigenen Namensvetter durchlassen.

Die 403-Meldung nennt jetzt alle erlaubten Ordner. Vorher haette sie bei
mehreren Fesseln "vehicles,items/" geschrieben -- einen Ordner, den es nicht
gibt. Wer abgewiesen wird, soll lesen koennen, wohin er darf.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-26 13:57:17 +02:00
co-authored by Claude Opus 5
parent c9e5bb3c31
commit 06285a3a53
5 changed files with 75 additions and 14 deletions
+37 -3
View File
@@ -147,10 +147,44 @@ export function tokenFromHeader(header: string | undefined): Token | null {
} }
/** Darf dieser Token auf diesen Pfad schreiben? */ /** Darf dieser Token auf diesen Pfad schreiben? */
/**
* Die Fesseln eines Tokens als Liste.
*
* MEHRERE STATT EINER, seit dem 23.08.2026. Das Fotostudio schreibt nach
* `vehicles/` UND `items/`, und mit einer einzigen Fessel blieb nur die Wahl
* zwischen "einer davon" und "darf ueberall". Beides war falsch: das eine
* lehnt die Haelfte der Uploads mit 403 ab, das andere erlaubt auch
* `sicherungen/`.
*
* Die Spalte bleibt, was sie war -- ein Textfeld. Mehrere Werte stehen durch
* Komma getrennt darin. Damit gilt jeder BESTEHENDE Token unveraendert
* weiter: ein Wert ohne Komma ist eine Liste mit einem Eintrag.
*/
export function tokenPrefixes(token: Pick<Token, 'prefix'>): string[] {
return (token.prefix ?? '')
.split(',')
.map((p) => p.trim().replace(/^\/+|\/+$/g, ''))
.filter(Boolean)
}
/** Die Fesseln als Satzstueck fuer eine Fehlermeldung.
*
* Aus der rohen Spalte wuerde sonst `"vehicles,items/"` -- ein Ordner, den es
* nicht gibt. Wer eine 403 bekommt, soll lesen koennen, wohin er DARF. */
export function fesselText(token: Pick<Token, 'prefix'>): string {
const f = tokenPrefixes(token)
if (f.length === 0) return 'ueberall'
if (f.length === 1) return `"${f[0]}/"`
return f.map((p) => `"${p}/"`).join(' oder ')
}
export function tokenAllows(token: Token, path: string): boolean { export function tokenAllows(token: Token, path: string): boolean {
if (!token.prefix) return true const fesseln = tokenPrefixes(token)
const prefix = token.prefix.replace(/\/+$/, '') + '/' // Keine Fessel heisst ueberall -- so war es, so bleibt es.
return path.startsWith(prefix) if (fesseln.length === 0) return true
// Das `/` gehoert dazu: ohne es wuerde "vehicles" auch "vehiclesXYZ/a.png"
// durchlassen. Stand schon vorher so da und bleibt.
return fesseln.some((p) => path.startsWith(p + '/'))
} }
// -------------------------------------------------------------------- Start // -------------------------------------------------------------------- Start
+24 -2
View File
@@ -2913,6 +2913,28 @@ dashRoutes.get('/tokens', (c) => {
}) })
}) })
/** Aus der Eingabe eine geprueste Fesselliste machen.
*
* Mehrere Ordner werden durch Komma getrennt. JEDER wird einzeln nach den
* Pfadregeln geprueft -- eine Liste, bei der ein Eintrag durchrutscht, ist
* keine Pruefung. Doppelte fallen weg, damit die Meldung spaeter nicht
* denselben Ordner zweimal nennt.
*
* Leer bleibt leer, und das heisst weiterhin "darf ueberall".
*/
function fesselnPruefen(roh: string): string {
const teile = roh
.split(',')
.map((t) => t.trim().replace(/^\/+|\/+$/g, ''))
.filter(Boolean)
const raus: string[] = []
for (const t of teile) {
const sauber = checkPath(t)
if (!raus.includes(sauber)) raus.push(sauber)
}
return raus.join(',')
}
dashRoutes.post('/tokens', async (c) => { dashRoutes.post('/tokens', async (c) => {
const body = await jsonBody<{ const body = await jsonBody<{
name?: string name?: string
@@ -2996,7 +3018,7 @@ dashRoutes.post('/tokens', async (c) => {
let cleanPrefix = '' let cleanPrefix = ''
if (prefix?.trim()) { if (prefix?.trim()) {
try { try {
cleanPrefix = checkPath(prefix.trim().replace(/\/+$/, '')) cleanPrefix = fesselnPruefen(prefix)
} catch (err) { } catch (err) {
if (err instanceof PathError) return c.json({ error: err.message }, 400) if (err instanceof PathError) return c.json({ error: err.message }, 400)
throw err throw err
@@ -3082,7 +3104,7 @@ dashRoutes.patch('/tokens/:id', async (c) => {
const roh = body.prefix.trim().replace(/^\/+|\/+$/g, '') const roh = body.prefix.trim().replace(/^\/+|\/+$/g, '')
if (roh) { if (roh) {
try { try {
feld.prefix = checkPath(roh) feld.prefix = fesselnPruefen(roh)
} catch (err) { } catch (err) {
if (err instanceof PathError) return c.json({ error: `Praefix: ${err.message}` }, 400) if (err instanceof PathError) return c.json({ error: `Praefix: ${err.message}` }, 400)
throw err throw err
+2 -2
View File
@@ -30,7 +30,7 @@ import type { Context, Next } from 'hono'
import { config } from '../config.js' import { config } from '../config.js'
import { meldeUpload } from '../meldung.js' import { meldeUpload } from '../meldung.js'
import { db, logEvent, now, type Media, type Token } from '../db.js' import { db, logEvent, now, type Media, type Token } from '../db.js'
import { tokenAllows, tokenFromHeader } from '../auth.js' import { fesselText, tokenAllows, tokenFromHeader } from '../auth.js'
import { kontingentUeberschritten } from './upload.js' import { kontingentUeberschritten } from './upload.js'
import { import {
checkPath, checkPath,
@@ -252,7 +252,7 @@ async function ablegen(
antwort: c.json( antwort: c.json(
{ {
status: 'error', status: 'error',
message: `dieser Token darf nur unter "${token.prefix}/" schreiben`, message: `dieser Token darf nur unter ${fesselText(token)} schreiben`,
}, },
403, 403,
), ),
+2 -2
View File
@@ -24,7 +24,7 @@ import type { Context, Next } from 'hono'
import { config } from '../config.js' import { config } from '../config.js'
import { meldeUpload } from '../meldung.js' import { meldeUpload } from '../meldung.js'
import { db, logEvent, now, type Media, type Token } from '../db.js' import { db, logEvent, now, type Media, type Token } from '../db.js'
import { tokenAllows, tokenFromHeader } from '../auth.js' import { fesselText, tokenAllows, tokenFromHeader } from '../auth.js'
import { import {
PathError, PathError,
checkPath, checkPath,
@@ -236,7 +236,7 @@ uploadRoutes.post('/upload', async (c) => {
if (!tokenAllows(token, path)) { if (!tokenAllows(token, path)) {
return c.json( return c.json(
{ error: `dieser Token darf nur unter "${token.prefix}/" schreiben` }, { error: `dieser Token darf nur unter ${fesselText(token)} schreiben` },
403, 403,
) )
} }
+10 -5
View File
@@ -98,10 +98,10 @@ export function Tokens() {
onChange={(e) => setName(e.target.value)} onChange={(e) => setName(e.target.value)}
/> />
<Feld <Feld
label="Präfix (optional)" label="Ordner (optional)"
value={prefix} value={prefix}
placeholder="vehicles" placeholder="vehicles, items"
hinweis="Der Token darf dann nur darunter schreiben." hinweis="Nur unter diesen Ordnern darf der Token schreiben. Mehrere durch Komma. Leer = überall."
onChange={(e) => setPrefix(e.target.value)} onChange={(e) => setPrefix(e.target.value)}
/> />
</div> </div>
@@ -237,7 +237,7 @@ export function Tokens() {
<thead> <thead>
<tr className="text-left text-xs text-schrift-leise"> <tr className="text-left text-xs text-schrift-leise">
<th className="pb-2 font-medium">Name</th> <th className="pb-2 font-medium">Name</th>
<th className="pb-2 font-medium">Präfix</th> <th className="pb-2 font-medium">Ordner</th>
<th className="pb-2 font-medium">Rechte</th> <th className="pb-2 font-medium">Rechte</th>
<th className="pb-2 font-medium">Grenzen</th> <th className="pb-2 font-medium">Grenzen</th>
{/* Was mit dem Token wirklich abgelegt wurde. {/* Was mit dem Token wirklich abgelegt wurde.
@@ -266,7 +266,12 @@ export function Tokens() {
)} )}
</td> </td>
<td className="py-2 font-mono text-schrift-leise"> <td className="py-2 font-mono text-schrift-leise">
{t.prefix ? `${t.prefix}/` : '— (überall)'} {t.prefix
? t.prefix
.split(',')
.map((p) => `${p.trim()}/`)
.join(' · ')
: '— (überall)'}
</td> </td>
<td className="py-2"> <td className="py-2">
{t.can_delete ? ( {t.can_delete ? (