Frontend: **. wildcard support — prefill, typing shortcut, lockout matcher, sort order

This commit is contained in:
2026-09-07 15:23:45 +00:00
parent 4738401f23
commit f3909ce524
2 changed files with 63 additions and 38 deletions
+52 -31
View File
@@ -39,10 +39,10 @@ const isValidationInvalid = computed(() => {
// A single origins list holds two kinds of entries: sites on the rp-id // 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 // domain form the in-domain sign-in allow-list; entries on other domain
// names are related origins (WebAuthn ROR). Classification is automatic // names are related origins (WebAuthn ROR). Classification is automatic
// from the hostname. A plain '*' is invalid (wildcards must sit under // from the hostname. A bare '*' or '**' is invalid (wildcards must sit
// the rp-id) and never a related origin. // under the rp-id) and never a related origin.
function isRelatedEntry(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) const h = originHostname(origin)
return !!(h && dialogRpId.value && !isWithinDomain(origin, dialogRpId.value)) 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 // always allowed), their current page origin must stay allowed to run
// passkey ceremonies — otherwise saving locks them out. Mirrors the // passkey ceremonies — otherwise saving locks them out. Mirrors the
// backend check (Passkey.validate_origin): an in-domain origin matches a // backend check (Passkey.validate_origin): an in-domain origin matches a
// row exactly (scheme+host+port) or a wildcard row ('*.base' covers the // row exactly (scheme+host+port) or a wildcard row — '**.base' covers
// base domain and its subdomains over https — any scheme and port under // the apex and subdomains at any depth, '*.base' exactly one subdomain
// localhost); a related row matches only on exact equality (https://host). // level — over https, except under localhost (any scheme and port);
// a related row matches only on exact equality (https://host).
const lockoutWarning = computed(() => { const lockoutWarning = computed(() => {
const d = props.dialog?.data const d = props.dialog?.data
if (props.dialog?.type !== 'domain-edit' || d?.isNew || d?.auth_host) return null if (props.dialog?.type !== 'domain-edit' || d?.isNew || d?.auth_host) return null
@@ -95,7 +96,7 @@ const lockoutWarning = computed(() => {
}) })
function pageOriginAllowed(rows, rpId) { 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 inDomain = []
const related = [] const related = []
for (const row of rows) { for (const row of rows) {
@@ -111,9 +112,12 @@ function pageOriginAllowed(rows, rpId) {
if (hostname === rpId || hostname.endsWith('.' + rpId)) { if (hostname === rpId || hostname.endsWith('.' + rpId)) {
if (inDomain.includes(origin)) return true if (inDomain.includes(origin)) return true
return inDomain.some(e => { return inDomain.some(e => {
if (!e.startsWith('*.')) return false const base = wildcardBase(e)
const base = e.slice(2).replace(/\.+$/, '') if (!base) return false
if (hostname !== base && !hostname.endsWith('.' + 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 // Under localhost a wildcard matches any scheme and port
return base === 'localhost' || base.endsWith('.localhost') || origin.startsWith('https://') 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) { function originHostname(origin) {
const v = origin.trim() const v = origin.trim()
if (!v || v === '*') return null // a plain '*' is not a valid entry if (!v || v === '*' || v === '**') return null // a bare '*' or '**' is not a valid entry
if (v.startsWith('*.')) { if (isWildcardEntry(v)) {
const base = v.slice(2).replace(/\.+$/, '').toLowerCase() const base = wildcardBase(v)
return isWellFormedDomain(base) ? base : null return base && isWellFormedDomain(base) ? base : null
} }
try { try {
const url = v.startsWith('http') ? new URL(v) : new URL('https://' + v) const url = v.startsWith('http') ? new URL(v) : new URL('https://' + v)
@@ -207,17 +227,17 @@ async function validateOriginConnectivity(i) {
} }
} }
// A sole '*' expands to '*.<rp-id>' immediately, keeping the cursor where // A sole '*' or '**' expands to '**.<rp-id>' immediately, keeping the
// it was (before the inserted rp-id). // cursor where it was (before the inserted rp-id).
function onOriginInput(i, e) { function onOriginInput(i, e) {
const d = props.dialog?.data const d = props.dialog?.data
if (!d) return if (!d) return
const el = e.target const el = e.target
const oldKey = entryKey(d.origins[i]) const oldKey = entryKey(d.origins[i])
let value = el.value let value = el.value
if (value === '*' && dialogRpId.value) { if ((value === '*' || value === '**') && dialogRpId.value) {
const pos = el.selectionStart const pos = el.selectionStart
value = '*.' + dialogRpId.value value = '**.' + dialogRpId.value
el.value = value el.value = value
el.setSelectionRange(pos, pos) el.setSelectionRange(pos, pos)
} }
@@ -239,7 +259,7 @@ function validateOrigin(i) {
d.originValidation[i] = 'invalid' d.originValidation[i] = 'invalid'
return return
} }
if (value.trim().startsWith('*.')) { if (isWildcardEntry(value)) {
// Wildcards have no concrete site to probe, and are only allowed // Wildcards have no concrete site to probe, and are only allowed
// within the domain (related origins are individual hosts) // within the domain (related origins are individual hosts)
d.originValidation[i] = isWithinDomain(value, dialogRpId.value) ? null : 'invalid' 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 }) watch(() => relatedEntries.value.map(asHttpsOrigin).join('|'), testWellKnown, { immediate: true })
// Prefill a new domain's list with the real '*.<rp-id>' row once its // Prefill a new domain's list with the real '**.<rp-id>' row once its
// rp-id is known ('*.x' = the domain and all its subdomains over https, // rp-id is known ('**.x' = the domain apex and all its subdomains over
// any scheme and port under localhost). The row follows rp-id edits while // https, any scheme and port under localhost). The row follows rp-id
// it is still the untouched prefilled row; once the admin edits it, it is // edits while it is still the untouched prefilled row; once the admin
// left alone. Seeding waits for a complete-looking rp-id (letters after // edits it, it is left alone. Seeding waits for a complete-looking rp-id
// the final dot) so mid-typing states like 'something.' don't prefill a // (letters after the final dot) so mid-typing states like 'something.'
// broken '*.something'. // don't prefill a broken '**.something'.
function looksCompleteDomain(value) { function looksCompleteDomain(value) {
const host = (value || '').trim().replace(/\.$/, '') const host = (value || '').trim().replace(/\.$/, '')
return host === 'localhost' || /\.[a-z]{2,}$/i.test(host) return host === 'localhost' || /\.[a-z]{2,}$/i.test(host)
@@ -294,7 +314,7 @@ watch(dialogRpId, rp => {
const d = props.dialog?.data const d = props.dialog?.data
if (props.dialog?.type !== 'domain-edit' || !d?.isNew) return if (props.dialog?.type !== 'domain-edit' || !d?.isNew) return
if (!looksCompleteDomain(rp) || !isWellFormedDomain(rp)) return if (!looksCompleteDomain(rp) || !isWellFormedDomain(rp)) return
const seed = '*.' + rp.trim().replace(/\.$/, '') const seed = '**.' + rp.trim().replace(/\.$/, '')
if (!d.origins.length) { if (!d.origins.length) {
d.origins.push(seed) d.origins.push(seed)
d.originValidation.push(null) d.originValidation.push(null)
@@ -338,9 +358,10 @@ function setAuthHost(i) {
if (!d) return if (!d) return
let key = entryKey(d.origins[i]) let key = entryKey(d.origins[i])
let added = false let added = false
if (key.startsWith('*.')) { const wbase = wildcardBase(key)
if (wbase) {
// A wildcard cannot be the auth host — create a concrete auth.<base> entry // A wildcard cannot be the auth host — create a concrete auth.<base> entry
key = 'auth.' + key.slice(2) key = 'auth.' + wbase
if (!d.origins.some(o => entryKey(o) === key)) { if (!d.origins.some(o => entryKey(o) === key)) {
d.origins.push(key) d.origins.push(key)
d.originValidation.push(null) d.originValidation.push(null)
@@ -488,12 +509,12 @@ function onRemoveOrigin(i) {
</div> </div>
</div> </div>
</div> </div>
<p v-if="dialog.data.originValidation.some(v => v === 'invalid')" class="small error">Some entries are invalid — a plain '*' is not allowed, and wildcards only within the domain.</p> <p v-if="dialog.data.originValidation.some(v => v === 'invalid')" class="small error">Some entries are invalid — a bare '*' or '**' is not allowed, and wildcards only within the domain.</p>
<p v-if="dialog.data.originValidation.some(v => v === 'unreachable')" class="small muted">Some sites are unreachable — make sure they are routed to this instance.</p> <p v-if="dialog.data.originValidation.some(v => v === 'unreachable')" class="small muted">Some sites are unreachable — make sure they are routed to this instance.</p>
<p v-else-if="dialog.data.originValidation.some(v => v === 'mismatch')" class="small muted">Some sites are reachable but do not serve this domain.</p> <p v-else-if="dialog.data.originValidation.some(v => v === 'mismatch')" class="small muted">Some sites are reachable but do not serve this domain.</p>
</div> </div>
<p class="small muted"> <p class="small muted">
Only the listed sites may sign in with this domain's passkeys — <strong>*.{{ dialog.data.rp_id }}</strong> allows the whole domain over https (any scheme and port under localhost); use a full origin like <strong>http://localhost:8080</strong> for other exceptions. Only the listed sites may sign in with this domain's passkeys — <strong>**.{{ dialog.data.rp_id }}</strong> allows the whole domain (apex and any subdomain), <strong>*.{{ dialog.data.rp_id }}</strong> only a single subdomain level, both over https (any scheme and port under localhost); use a full origin like <strong>http://localhost:8080</strong> 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). 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).
</p> </p>
<p v-if="lockoutWarning" class="small error">Saving would lock you out: {{ lockoutWarning }} could no longer run sign-in ceremonies for this domain. Keep it listed, or mark an auth host.</p> <p v-if="lockoutWarning" class="small error">Saving would lock you out: {{ lockoutWarning }} could no longer run sign-in ceremonies for this domain. Keep it listed, or mark an auth host.</p>
+11 -7
View File
@@ -50,8 +50,9 @@ export const hostIP = ip => {
// Hierarchical origin comparison: split off scheme/port, compare hostnames // Hierarchical origin comparison: split off scheme/port, compare hostnames
// label by label from the TLD down, parents before their subdomains and a // 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 // wildcard label ('**' any depth, '*' one level — in that order) after all
// the same host tie-break by scheme (https first) and numeric port. // concrete labels at the same level. Entries on the same host tie-break by
// scheme (https first) and numeric port.
function originParts(key) { function originParts(key) {
let s = key.toLowerCase().replace(/\/+$/, '') let s = key.toLowerCase().replace(/\/+$/, '')
let scheme = '' let scheme = ''
@@ -71,8 +72,11 @@ export function compareOrigins(a, b) {
if (la === undefined) return -1 if (la === undefined) return -1
if (lb === undefined) return 1 if (lb === undefined) return 1
if (la === lb) continue if (la === lb) continue
if (la === '*') return 1 const wa = la === '*' || la === '**'
if (lb === '*') return -1 const wb = lb === '*' || lb === '**'
if (wa && wb) return la === '**' ? -1 : 1
if (wa) return 1
if (wb) return -1
const c = la.localeCompare(lb) const c = la.localeCompare(lb)
if (c) return c 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 // An origins-table entry outside the rp-id domain is a related origin
// (WebAuthn ROR). Wildcards are never related — they are only valid // (WebAuthn ROR). Wildcards ('*.' or '**.') are never related — they are
// under the rp-id. // only valid under the rp-id.
function isRelatedKey(rpId, key) { function isRelatedKey(rpId, key) {
if (key.startsWith('*.')) return false if (key.startsWith('*.') || key.startsWith('**.')) return false
try { try {
const hostname = new URL(key.includes('://') ? key : 'https://' + key).hostname const hostname = new URL(key.includes('://') ? key : 'https://' + key).hostname
return !!hostname && hostname !== rpId && !hostname.endsWith('.' + rpId) return !!hostname && hostname !== rpId && !hostname.endsWith('.' + rpId)