From f3909ce52422f416d184c5ce70a9e432943a2d95 Mon Sep 17 00:00:00 2001 From: Leo Vasanko Date: Mon, 7 Sep 2026 15:23:45 +0000 Subject: [PATCH] =?UTF-8?q?Frontend:=20**.=20wildcard=20support=20?= =?UTF-8?q?=E2=80=94=20prefill,=20typing=20shortcut,=20lockout=20matcher,?= =?UTF-8?q?=20sort=20order?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/admin/AdminDialogs.vue | 83 ++++++++++++++++++----------- frontend/src/utils/helpers.js | 18 ++++--- 2 files changed, 63 insertions(+), 38 deletions(-) diff --git a/frontend/src/admin/AdminDialogs.vue b/frontend/src/admin/AdminDialogs.vue index 3844c19..9a5a92d 100644 --- a/frontend/src/admin/AdminDialogs.vue +++ b/frontend/src/admin/AdminDialogs.vue @@ -39,10 +39,10 @@ const isValidationInvalid = computed(() => { // A single origins list holds two kinds of entries: sites on the rp-id // domain form the in-domain sign-in allow-list; entries on other domain // names are related origins (WebAuthn ROR). Classification is automatic -// from the hostname. A plain '*' is invalid (wildcards must sit under -// the rp-id) and never a related origin. +// from the hostname. A bare '*' or '**' is invalid (wildcards must sit +// under the rp-id) and never a related origin. function isRelatedEntry(origin) { - if (origin.trim().startsWith('*.')) return false // wildcards are never related + if (isWildcardEntry(origin)) return false // wildcards are never related const h = originHostname(origin) return !!(h && dialogRpId.value && !isWithinDomain(origin, dialogRpId.value)) } @@ -83,9 +83,10 @@ function copyText(value, label) { // always allowed), their current page origin must stay allowed to run // passkey ceremonies — otherwise saving locks them out. Mirrors the // backend check (Passkey.validate_origin): an in-domain origin matches a -// row exactly (scheme+host+port) or a wildcard row ('*.base' covers the -// base domain and its subdomains over https — any scheme and port under -// localhost); a related row matches only on exact equality (https://host). +// row exactly (scheme+host+port) or a wildcard row — '**.base' covers +// the apex and subdomains at any depth, '*.base' exactly one subdomain +// level — over https, except under localhost (any scheme and port); +// a related row matches only on exact equality (https://host). const lockoutWarning = computed(() => { const d = props.dialog?.data if (props.dialog?.type !== 'domain-edit' || d?.isNew || d?.auth_host) return null @@ -95,7 +96,7 @@ const lockoutWarning = computed(() => { }) function pageOriginAllowed(rows, rpId) { - const toUrl = key => (key.startsWith('*.') || key.includes('://')) ? key : 'https://' + key + const toUrl = key => (isWildcardEntry(key) || key.includes('://')) ? key : 'https://' + key const inDomain = [] const related = [] for (const row of rows) { @@ -111,9 +112,12 @@ function pageOriginAllowed(rows, rpId) { if (hostname === rpId || hostname.endsWith('.' + rpId)) { if (inDomain.includes(origin)) return true return inDomain.some(e => { - if (!e.startsWith('*.')) return false - const base = e.slice(2).replace(/\.+$/, '') - if (hostname !== base && !hostname.endsWith('.' + base)) return false + const base = wildcardBase(e) + if (!base) return false + const matched = e.startsWith('**.') + ? hostname === base || hostname.endsWith('.' + base) + : hostname.endsWith('.' + base) && !hostname.slice(0, -base.length - 1).includes('.') + if (!matched) return false // Under localhost a wildcard matches any scheme and port return base === 'localhost' || base.endsWith('.localhost') || origin.startsWith('https://') }) @@ -158,12 +162,28 @@ function isWellFormedDomain(value) { } } +// Wildcard entries follow the shell-glob convention: '*.base' covers +// exactly one subdomain level, '**.base' the apex and any depth. +const isWildcardEntry = value => { + const v = value.trim() + return v.startsWith('*.') || v.startsWith('**.') +} + +// Base domain of a wildcard entry (lowercased); null when the value is +// not a wildcard pattern or has no base. +function wildcardBase(value) { + const v = value.trim() + if (v.startsWith('**.')) return v.slice(3).replace(/\.+$/, '').toLowerCase() || null + if (v.startsWith('*.')) return v.slice(2).replace(/\.+$/, '').toLowerCase() || null + return null +} + function originHostname(origin) { const v = origin.trim() - if (!v || v === '*') return null // a plain '*' is not a valid entry - if (v.startsWith('*.')) { - const base = v.slice(2).replace(/\.+$/, '').toLowerCase() - return isWellFormedDomain(base) ? base : null + if (!v || v === '*' || v === '**') return null // a bare '*' or '**' is not a valid entry + if (isWildcardEntry(v)) { + const base = wildcardBase(v) + return base && isWellFormedDomain(base) ? base : null } try { const url = v.startsWith('http') ? new URL(v) : new URL('https://' + v) @@ -207,17 +227,17 @@ async function validateOriginConnectivity(i) { } } -// A sole '*' expands to '*.' immediately, keeping the cursor where -// it was (before the inserted rp-id). +// A sole '*' or '**' expands to '**.' immediately, keeping the +// cursor where it was (before the inserted rp-id). function onOriginInput(i, e) { const d = props.dialog?.data if (!d) return const el = e.target const oldKey = entryKey(d.origins[i]) let value = el.value - if (value === '*' && dialogRpId.value) { + if ((value === '*' || value === '**') && dialogRpId.value) { const pos = el.selectionStart - value = '*.' + dialogRpId.value + value = '**.' + dialogRpId.value el.value = value el.setSelectionRange(pos, pos) } @@ -239,7 +259,7 @@ function validateOrigin(i) { d.originValidation[i] = 'invalid' return } - if (value.trim().startsWith('*.')) { + if (isWildcardEntry(value)) { // Wildcards have no concrete site to probe, and are only allowed // within the domain (related origins are individual hosts) d.originValidation[i] = isWithinDomain(value, dialogRpId.value) ? null : 'invalid' @@ -277,13 +297,13 @@ async function testWellKnown() { } watch(() => relatedEntries.value.map(asHttpsOrigin).join('|'), testWellKnown, { immediate: true }) -// Prefill a new domain's list with the real '*.' row once its -// rp-id is known ('*.x' = the domain and all its subdomains over https, -// any scheme and port under localhost). The row follows rp-id edits while -// it is still the untouched prefilled row; once the admin edits it, it is -// left alone. Seeding waits for a complete-looking rp-id (letters after -// the final dot) so mid-typing states like 'something.' don't prefill a -// broken '*.something'. +// Prefill a new domain's list with the real '**.' row once its +// rp-id is known ('**.x' = the domain apex and all its subdomains over +// https, any scheme and port under localhost). The row follows rp-id +// edits while it is still the untouched prefilled row; once the admin +// edits it, it is left alone. Seeding waits for a complete-looking rp-id +// (letters after the final dot) so mid-typing states like 'something.' +// don't prefill a broken '**.something'. function looksCompleteDomain(value) { const host = (value || '').trim().replace(/\.$/, '') return host === 'localhost' || /\.[a-z]{2,}$/i.test(host) @@ -294,7 +314,7 @@ watch(dialogRpId, rp => { const d = props.dialog?.data if (props.dialog?.type !== 'domain-edit' || !d?.isNew) return if (!looksCompleteDomain(rp) || !isWellFormedDomain(rp)) return - const seed = '*.' + rp.trim().replace(/\.$/, '') + const seed = '**.' + rp.trim().replace(/\.$/, '') if (!d.origins.length) { d.origins.push(seed) d.originValidation.push(null) @@ -338,9 +358,10 @@ function setAuthHost(i) { if (!d) return let key = entryKey(d.origins[i]) let added = false - if (key.startsWith('*.')) { + const wbase = wildcardBase(key) + if (wbase) { // A wildcard cannot be the auth host — create a concrete auth. entry - key = 'auth.' + key.slice(2) + key = 'auth.' + wbase if (!d.origins.some(o => entryKey(o) === key)) { d.origins.push(key) d.originValidation.push(null) @@ -488,12 +509,12 @@ function onRemoveOrigin(i) { -

Some entries are invalid — a plain '*' is not allowed, and wildcards only within the domain.

+

Some entries are invalid — a bare '*' or '**' is not allowed, and wildcards only within the domain.

Some sites are unreachable — make sure they are routed to this instance.

Some sites are reachable but do not serve this domain.

- Only the listed sites may sign in with this domain's passkeys — *.{{ dialog.data.rp_id }} allows the whole domain over https (any scheme and port under localhost); use a full origin like http://localhost:8080 for other exceptions. + Only the listed sites may sign in with this domain's passkeys — **.{{ dialog.data.rp_id }} allows the whole domain (apex and any subdomain), *.{{ dialog.data.rp_id }} only a single subdomain level, both over https (any scheme and port under localhost); use a full origin like http://localhost:8080 for other exceptions. Entries on other domain names become related origins (WebAuthn ROR), marked 🔗. The 🔑 site hosts the account and admin interface (set via ⋮). An empty list allows nothing of this domain (related origins still work).

Saving would lock you out: {{ lockoutWarning }} could no longer run sign-in ceremonies for this domain. Keep it listed, or mark an auth host.

diff --git a/frontend/src/utils/helpers.js b/frontend/src/utils/helpers.js index bad486a..dc62ee9 100644 --- a/frontend/src/utils/helpers.js +++ b/frontend/src/utils/helpers.js @@ -50,8 +50,9 @@ export const hostIP = ip => { // Hierarchical origin comparison: split off scheme/port, compare hostnames // label by label from the TLD down, parents before their subdomains and a -// wildcard label after all concrete labels at the same level. Entries on -// the same host tie-break by scheme (https first) and numeric port. +// wildcard label ('**' any depth, '*' one level — in that order) after all +// concrete labels at the same level. Entries on the same host tie-break by +// scheme (https first) and numeric port. function originParts(key) { let s = key.toLowerCase().replace(/\/+$/, '') let scheme = '' @@ -71,8 +72,11 @@ export function compareOrigins(a, b) { if (la === undefined) return -1 if (lb === undefined) return 1 if (la === lb) continue - if (la === '*') return 1 - if (lb === '*') return -1 + const wa = la === '*' || la === '**' + const wb = lb === '*' || lb === '**' + if (wa && wb) return la === '**' ? -1 : 1 + if (wa) return 1 + if (wb) return -1 const c = la.localeCompare(lb) if (c) return c } @@ -86,10 +90,10 @@ export function compareOrigins(a, b) { } // An origins-table entry outside the rp-id domain is a related origin -// (WebAuthn ROR). Wildcards are never related — they are only valid -// under the rp-id. +// (WebAuthn ROR). Wildcards ('*.' or '**.') are never related — they are +// only valid under the rp-id. function isRelatedKey(rpId, key) { - if (key.startsWith('*.')) return false + if (key.startsWith('*.') || key.startsWith('**.')) return false try { const hostname = new URL(key.includes('://') ? key : 'https://' + key).hostname return !!hostname && hostname !== rpId && !hostname.endsWith('.' + rpId)