Only allow safe characters in permission IDs

This commit is contained in:
Leo Vasanko 2025-08-30 19:10:00 -06:00
parent d045e1c520
commit 2b03fa74cd
3 changed files with 65 additions and 30 deletions

View File

@ -17,6 +17,16 @@ const userLink = ref(null) // latest generated registration link
const userLinkExpires = ref(null)
const authStore = useAuthStore()
const addingOrgForPermission = ref(null)
const PERMISSION_ID_PATTERN = '^[A-Za-z0-9:._~-]+$'
const showCreatePermission = ref(false)
const newPermId = ref('')
const newPermName = ref('')
const editingPermId = ref(null)
const renameIdValue = ref('')
const safeIdRegex = /[^A-Za-z0-9:._~-]/g
function sanitizeNewId() { if (newPermId.value) newPermId.value = newPermId.value.replace(safeIdRegex, '') }
function sanitizeRenameId() { if (renameIdValue.value) renameIdValue.value = renameIdValue.value.replace(safeIdRegex, '') }
function handleGlobalClick(e) {
if (!addingOrgForPermission.value) return
@ -88,22 +98,22 @@ async function renamePermissionDisplay(p) {
}
}
async function renamePermissionId(p) {
const newId = prompt('New permission id', p.id)
if (!newId || newId === p.id) return
function startRenamePermissionId(p) {
editingPermId.value = p.id
renameIdValue.value = p.id
}
function cancelRenameId() { editingPermId.value = null; renameIdValue.value = '' }
async function submitRenamePermissionId(p) {
const newId = renameIdValue.value.trim()
if (!newId || newId === p.id) { cancelRenameId(); return }
try {
const body = { old_id: p.id, new_id: newId, display_name: p.display_name }
const res = await fetch('/auth/admin/permission/rename', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body)
})
let data
try { data = await res.json() } catch(_) { data = {} }
const res = await fetch('/auth/admin/permission/rename', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) })
let data; try { data = await res.json() } catch(_) { data = {} }
if (!res.ok || data.detail) throw new Error(data.detail || data.error || `Failed (${res.status})`)
await refreshPermissionsContext()
await refreshPermissionsContext(); cancelRenameId()
} catch (e) {
alert((e && e.message) ? e.message : 'Failed to rename permission id')
alert(e?.message || 'Failed to rename permission id')
}
}
@ -336,20 +346,15 @@ async function deleteRole(role) {
}
// Permission actions
async function createPermission() {
const id = prompt('Permission ID (e.g., auth/example):')
if (!id) return
const name = prompt('Permission display name:')
if (!name) return
const res = await fetch('/auth/admin/permissions', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ id, display_name: name })
})
const data = await res.json()
if (data.detail) return alert(data.detail)
await loadPermissions()
async function submitCreatePermission() {
const id = newPermId.value.trim()
const name = newPermName.value.trim()
if (!id || !name) return
const res = await fetch('/auth/admin/permissions', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ id, display_name: name }) })
const data = await res.json(); if (data.detail) { alert(data.detail); return }
await loadPermissions(); newPermId.value=''; newPermName.value=''; showCreatePermission.value=false
}
function cancelCreatePermission() { newPermId.value=''; newPermName.value=''; showCreatePermission.value=false }
async function updatePermission(p) {
const name = prompt('Permission display name:', p.display_name)
@ -623,7 +628,13 @@ async function toggleRolePermission(role, permId, checked) {
<div v-if="!selectedUser && !selectedOrg && (info.is_global_admin || info.is_org_admin)" class="card">
<h2>All Permissions</h2>
<div class="actions">
<button @click="createPermission">+ Create Permission</button>
<button v-if="!showCreatePermission" @click="showCreatePermission = true">+ Create Permission</button>
<form v-else class="inline-form" @submit.prevent="submitCreatePermission">
<input v-model="newPermId" @input="sanitizeNewId" required :pattern="PERMISSION_ID_PATTERN" placeholder="permission id" title="Allowed: A-Za-z0-9:._~-" />
<input v-model="newPermName" required placeholder="display name" />
<button type="submit">Save</button>
<button type="button" @click="cancelCreatePermission">Cancel</button>
</form>
</div>
<div class="permission-grid">
<div class="perm-grid-head">Permission</div>
@ -672,9 +683,16 @@ async function toggleRolePermission(role, permId, checked) {
</div>
<div class="perm-cell perm-users center">{{ permissionSummary[p.id]?.userCount || 0 }}</div>
<div class="perm-cell perm-actions center">
<button @click="renamePermissionDisplay(p)" class="icon-btn" aria-label="Change display name" title="Change display name"></button>
<button @click="renamePermissionId(p)" class="icon-btn" aria-label="Change id" title="Change id">🆔</button>
<button @click="deletePermission(p)" class="icon-btn delete-icon" aria-label="Delete permission" title="Delete permission"></button>
<template v-if="editingPermId !== p.id">
<button @click="renamePermissionDisplay(p)" class="icon-btn" aria-label="Change display name" title="Change display name"></button>
<button @click="startRenamePermissionId(p)" class="icon-btn" aria-label="Change id" title="Change id">🆔</button>
<button @click="deletePermission(p)" class="icon-btn delete-icon" aria-label="Delete permission" title="Delete permission"></button>
</template>
<form v-else class="inline-id-form" @submit.prevent="submitRenamePermissionId(p)">
<input v-model="renameIdValue" @input="sanitizeRenameId" required :pattern="PERMISSION_ID_PATTERN" class="id-input" title="Allowed: A-Za-z0-9:._~-" />
<button type="submit" class="icon-btn" aria-label="Save"></button>
<button type="button" class="icon-btn" @click="cancelRenameId" aria-label="Cancel"></button>
</form>
</div>
</template>
</div>

View File

@ -19,7 +19,7 @@ from .. import aaguid
from ..authsession import delete_credential, expires, get_reset, get_session
from ..globals import db
from ..globals import passkey as global_passkey
from ..util import tokens
from ..util import querysafe, tokens
from ..util.tokens import session_key
from . import authz, session
@ -260,6 +260,7 @@ def register_api_routes(app: FastAPI):
ctx, is_global_admin, is_org_admin = await _get_ctx_and_admin_flags(auth)
if not (is_global_admin or (is_org_admin and ctx.org.uuid == org_uuid)):
raise ValueError("Insufficient permissions")
querysafe.assert_safe(permission_id, field="permission_id")
await db.instance.add_permission_to_organization(str(org_uuid), permission_id)
return {"status": "ok"}
@ -270,6 +271,7 @@ def register_api_routes(app: FastAPI):
ctx, is_global_admin, is_org_admin = await _get_ctx_and_admin_flags(auth)
if not (is_global_admin or (is_org_admin and ctx.org.uuid == org_uuid)):
raise ValueError("Insufficient permissions")
querysafe.assert_safe(permission_id, field="permission_id")
await db.instance.remove_permission_from_organization(
str(org_uuid), permission_id
)
@ -506,6 +508,7 @@ def register_api_routes(app: FastAPI):
display_name = payload.get("display_name")
if not perm_id or not display_name:
raise ValueError("id and display_name are required")
querysafe.assert_safe(perm_id, field="id")
await db.instance.create_permission(
PermDC(id=perm_id, display_name=display_name)
)
@ -522,6 +525,7 @@ def register_api_routes(app: FastAPI):
if not display_name:
raise ValueError("display_name is required")
querysafe.assert_safe(permission_id, field="permission_id")
await db.instance.update_permission(
PermDC(id=permission_id, display_name=display_name)
)
@ -541,6 +545,8 @@ def register_api_routes(app: FastAPI):
display_name = payload.get("display_name")
if not old_id or not new_id:
raise ValueError("old_id and new_id required")
querysafe.assert_safe(old_id, field="old_id")
querysafe.assert_safe(new_id, field="new_id")
if display_name is None:
# Fetch old to retain display name
perm = await db.instance.get_permission(old_id)
@ -557,6 +563,7 @@ def register_api_routes(app: FastAPI):
_, is_global_admin, _ = await _get_ctx_and_admin_flags(auth)
if not is_global_admin:
raise ValueError("Global admin required")
querysafe.assert_safe(permission_id, field="permission_id")
await db.instance.delete_permission(permission_id)
return {"status": "ok"}

10
passkey/util/querysafe.py Normal file
View File

@ -0,0 +1,10 @@
import re
_SAFE_RE = re.compile(r"^[A-Za-z0-9:._~-]+$")
def assert_safe(value: str, *, field: str = "value") -> None:
if not isinstance(value, str) or not value or not _SAFE_RE.match(value):
raise ValueError(f"{field} must match ^[A-Za-z0-9:._~-]+$")
__all__ = ["assert_safe"]