diff --git a/frontend/src/admin/AdminApp.vue b/frontend/src/admin/AdminApp.vue index 0973862..f479c0a 100644 --- a/frontend/src/admin/AdminApp.vue +++ b/frontend/src/admin/AdminApp.vue @@ -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) {

All Permissions

- + +
+ + + + +
Permission
@@ -672,9 +683,16 @@ async function toggleRolePermission(role, permId, checked) {
{{ permissionSummary[p.id]?.userCount || 0 }}
- - - + +
+ + + +
diff --git a/passkey/fastapi/api.py b/passkey/fastapi/api.py index 52bcc37..257d987 100644 --- a/passkey/fastapi/api.py +++ b/passkey/fastapi/api.py @@ -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"} diff --git a/passkey/util/querysafe.py b/passkey/util/querysafe.py new file mode 100644 index 0000000..bf97585 --- /dev/null +++ b/passkey/util/querysafe.py @@ -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"]