Frontend: **. wildcard support — prefill, typing shortcut, lockout matcher, sort order
This commit is contained in:
@@ -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>
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user