From 9155370ae825165566629880490d9b9287870968 Mon Sep 17 00:00:00 2001 From: ScreenTinker Date: Mon, 10 Aug 2026 20:27:03 -0500 Subject: [PATCH] SSO: TXT only for domain proof, drop the CNAME form MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The CNAME alternative pointed at `.verify.screentinker.com`. Making that work means operating a wildcard DNS zone that answers for every token ever issued — which this project does not have, so half the published instructions described a check that could never pass. Documenting a verification path that cannot succeed is worse than offering one form. TXT needs nothing outside the customer's own zone, and the dedicated `_`-prefixed name keeps it away from the apex where SPF and DMARC live. A wildcard `*.example.com` cannot be mistaken for a proof either way: it answers with its own value, never the token, so it lands in "exists but does not match". Also simplifies check() — one lookup, no Promise.allSettled, and NXDOMAIN is reported as "not published yet" rather than as an error. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Bvjey4FNam49MN7ybjcq6A --- README.md | 12 ++++--- frontend/js/i18n/en.js | 3 +- frontend/js/views/settings.js | 2 -- server/lib/domain-verify.js | 66 +++++++++++++++-------------------- server/test/oidc-sso.test.js | 4 ++- 5 files changed, 40 insertions(+), 47 deletions(-) diff --git a/README.md b/README.md index a331c18..3fc7d94 100644 --- a/README.md +++ b/README.md @@ -420,16 +420,20 @@ deny a public domain to everyone else. A claimed domain **routes nobody and authenticates nobody until DNS proves the organization controls it.** Typing a domain into a form reserves the name and nothing more. -Publish either record — whichever the domain's DNS will accept — then press **Verify**: +Publish this record, then press **Verify**: ``` -_screentinker-verify.example.com. IN TXT "st-verify=" -_screentinker-verify.example.com. IN CNAME .verify.screentinker.com. +_screentinker-verify.example.com. IN TXT "st-verify=" ``` The token is unique per domain, so publishing one proof cannot be replayed to claim a second. A dedicated `_`-prefixed name is used rather than the apex, where a careless edit would sit alongside -SPF and DMARC and break mail. +SPF and DMARC and break mail — and where a wildcard `*.example.com` could not be confused for a +proof, since a wildcard answers with its own value and never with the token. + +TXT is the only accepted form. A CNAME alternative would have to point at a wildcard zone this +project operates, answering for every token ever issued; documenting one without running it would +describe a check that can never pass. **An unverified claim lapses after 8 hours**, and lapsing rotates the token. This is what stops squatting: a tenant cannot type a company's domain and hold it against the real owner, and a record diff --git a/frontend/js/i18n/en.js b/frontend/js/i18n/en.js index ad9fe56..f16f6d6 100644 --- a/frontend/js/i18n/en.js +++ b/frontend/js/i18n/en.js @@ -159,8 +159,7 @@ export default { 'sso.verifying': 'Checking DNS…', 'sso.verify_failed': 'Could not verify that domain.', 'sso.domain_verified_toast': '{domain} is verified.', - 'sso.dns_instructions': 'Publish ONE of these records in this domain\u2019s DNS, then click Verify. Claims expire after 8 hours.', - 'sso.dns_or_cname': 'or, if your DNS will not take a TXT record there:', + 'sso.dns_instructions': 'Publish this TXT record in this domain\u2019s DNS, then click Verify. Claims expire after 8 hours.', 'sso.callback_label': 'Redirect URI — add this to your provider', 'sso.f_name': 'Display name', 'sso.f_issuer': 'Issuer URL', diff --git a/frontend/js/views/settings.js b/frontend/js/views/settings.js index e92ef60..08f79a3 100644 --- a/frontend/js/views/settings.js +++ b/frontend/js/views/settings.js @@ -743,8 +743,6 @@ export async function render(container) { ${d.verified ? '' : `
${esc(t('sso.dns_instructions'))}
${esc(d.record_name)} TXT ${esc(d.txt_value)} -
${esc(t('sso.dns_or_cname'))}
- ${esc(d.record_name)} CNAME ${esc(d.cname_value)} ${d.last_error ? `
${esc(d.last_error)}
` : ''}`}
`).join('')} diff --git a/server/lib/domain-verify.js b/server/lib/domain-verify.js index c26a34d..70a9fdd 100644 --- a/server/lib/domain-verify.js +++ b/server/lib/domain-verify.js @@ -13,15 +13,19 @@ * sense that matters here. It is also the mechanism every other vendor uses, so the instructions * are already familiar to the person who has to follow them. * - * TWO RECORD FORMS, both at the same name, because organizations differ in what their DNS lets - * them add — some providers refuse TXT at a subdomain, some refuse CNAME anywhere useful: + * ONE RECORD FORM — a TXT record at a dedicated name: * - * _screentinker-verify.example.com. IN TXT "st-verify=" - * _screentinker-verify.example.com. IN CNAME .verify.screentinker.com. + * _screentinker-verify.example.com. IN TXT "st-verify=" + * + * A CNAME alternative was drafted and dropped. It would have pointed at + * `.verify.screentinker.com`, which requires operating a wildcard DNS zone that answers for + * every token ever issued — infrastructure this project does not have, so the instructions would + * have described a check that could never pass. TXT needs nothing but the customer's own zone. * * A dedicated `_`-prefixed name is used rather than the apex on purpose: an apex TXT record sits * alongside SPF and DMARC, where a careless edit breaks mail, and it is the one record set an - * administrator is most reluctant to touch. + * administrator is most reluctant to touch. It also means a wildcard `*.example.com` cannot be + * mistaken for a proof — a wildcard answers with ITS value, never with our token. */ const dns = require('dns').promises; @@ -29,7 +33,6 @@ const crypto = require('crypto'); const RECORD_PREFIX = '_screentinker-verify'; const TXT_PREFIX = 'st-verify='; -const CNAME_SUFFIX = '.verify.screentinker.com'; // A DNS answer that never arrives must not hold an HTTP request open. The resolver's own retries // sit under this, so it is a ceiling on the whole lookup rather than on one query. @@ -68,7 +71,6 @@ function instructions(domain, token) { return { record_name: recordName(domain), txt_value: `${TXT_PREFIX}${token}`, - cname_value: `${token}${CNAME_SUFFIX}`, }; } @@ -94,45 +96,33 @@ function withTimeout(promise, ms) { async function check(domain, token) { const name = recordName(domain); const wantTxt = `${TXT_PREFIX}${token}`; - const wantCname = `${token}${CNAME_SUFFIX}`; - const results = await Promise.allSettled([ - withTimeout(dns.resolveTxt(name), LOOKUP_TIMEOUT_MS), - withTimeout(dns.resolveCname(name), LOOKUP_TIMEOUT_MS), - ]); - - const [txtRes, cnameRes] = results; - - if (txtRes.status === 'fulfilled') { - // resolveTxt returns arrays of string chunks — a long value is split, so join before comparing. - for (const chunks of txtRes.value) { - if (chunks.join('').trim() === wantTxt) return { ok: true, via: 'TXT' }; - } - } - if (cnameRes.status === 'fulfilled') { - for (const target of cnameRes.value) { - // DNS names are case-insensitive and may or may not carry the root dot. - if (target.replace(/\.$/, '').toLowerCase() === wantCname.toLowerCase()) return { ok: true, via: 'CNAME' }; - } + let records; + try { + records = await withTimeout(dns.resolveTxt(name), LOOKUP_TIMEOUT_MS); + } catch (e) { + // NXDOMAIN and "no such record" are the ORDINARY answers here — an admin checking before the + // record has propagated — so they are "not found yet", not an error to be alarmed by. + if (/timed out/i.test(e.message)) return { ok: false, error: 'the DNS lookup timed out — try again shortly' }; + return { ok: false, error: `no ${RECORD_PREFIX} record found for ${domain} yet (DNS can take a few minutes)` }; } - // Nothing matched. Say which of the two failure shapes it is, because the fixes differ: a record - // that is absent needs publishing, a record that is present but wrong needs correcting. - const found = []; - if (txtRes.status === 'fulfilled') found.push(...txtRes.value.map((c) => `TXT ${c.join('')}`)); - if (cnameRes.status === 'fulfilled') found.push(...cnameRes.value.map((c) => `CNAME ${c}`)); - - if (found.length) { - return { ok: false, error: `${name} exists but does not match. Found: ${found.join('; ')}` }; + // resolveTxt returns arrays of string chunks — a value over 255 bytes is split, so join first. + for (const chunks of records) { + if (chunks.join('').trim() === wantTxt) return { ok: true, via: 'TXT' }; } - const timedOut = results.some((r) => r.status === 'rejected' && /timed out/i.test(r.reason && r.reason.message)); - if (timedOut) return { ok: false, error: 'the DNS lookup timed out — try again shortly' }; - + // Present but wrong is a different problem from absent, and the fixes differ: one needs + // correcting, the other needs publishing. A wildcard record lands here too, which is right — + // it answers with its own value, and that is not a proof of anything. + if (records.length) { + const found = records.map((c) => c.join('')).join('; '); + return { ok: false, error: `${name} exists but does not match. Found: ${found}` }; + } return { ok: false, error: `no ${RECORD_PREFIX} record found for ${domain} yet (DNS can take a few minutes)` }; } module.exports = { check, instructions, newToken, recordName, isClaimExpired, - CLAIM_TTL_S, RECORD_PREFIX, TXT_PREFIX, CNAME_SUFFIX, + CLAIM_TTL_S, RECORD_PREFIX, TXT_PREFIX, }; diff --git a/server/test/oidc-sso.test.js b/server/test/oidc-sso.test.js index 0cfb2be..512aae0 100644 --- a/server/test/oidc-sso.test.js +++ b/server/test/oidc-sso.test.js @@ -588,7 +588,9 @@ test('the DNS record is per-domain and per-claim, so an old record proves nothin const two = domainVerify.instructions('acme.test', b); assert.equal(one.record_name, '_screentinker-verify.acme.test'); assert.notEqual(one.txt_value, two.txt_value, 'reissuing changes what must be published'); - assert.notEqual(one.cname_value, two.cname_value); + // TXT is the only accepted form: a CNAME alternative would need a wildcard zone this project + // does not operate, so offering one would document a check that could never pass. + assert.equal(one.cname_value, undefined, 'no CNAME form is advertised'); // The record lives at a dedicated name, never the apex, where it would sit beside SPF and DMARC. assert.ok(!domainVerify.instructions('acme.test', a).record_name.startsWith('acme.test')); });