Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -68,17 +68,32 @@ describe('verify org domain route', () => {
user: { id: 'user-1', name: 'Admin', email: 'admin@acme.dev' },
})
mockIsEnterprise.mockResolvedValue(true)
mockCheckDomainTxtRecord.mockResolvedValue(true)
mockCheckDomainTxtRecord.mockResolvedValue('present')
})

it('422s when the TXT record is not found', async () => {
queueAdminWithPendingRow()
mockCheckDomainTxtRecord.mockResolvedValue(false)
mockCheckDomainTxtRecord.mockResolvedValue('absent')
const res = await POST(createMockRequest('POST'), routeContext)
expect(res.status).toBe(422)
expect(mockRecordAudit).not.toHaveBeenCalled()
})

/**
* A failed lookup says nothing about the admin's DNS, so it must not be reported
* as a missing record — that sends them hunting through their zone for our fault.
*/
it('503s (not 422) when the DNS lookup itself could not complete', async () => {
queueAdminWithPendingRow()
mockCheckDomainTxtRecord.mockResolvedValue('unavailable')
const res = await POST(createMockRequest('POST'), routeContext)
expect(res.status).toBe(503)
expect(await res.json()).toMatchObject({
error: expect.stringContaining("couldn't complete the DNS lookup"),
})
expect(mockRecordAudit).not.toHaveBeenCalled()
})

it('verifies the domain and records an audit event', async () => {
queueAdminWithPendingRow()
queueTableRows(ssoDomain, []) // verified-elsewhere check → none
Expand Down Expand Up @@ -143,12 +158,29 @@ describe('verify org domain route', () => {
expect(grantWhere).toBeDefined()
})

it('does not grant trust when the conditional update matched no row', async () => {
/**
* A provider can hold a verified domain while its own trust flag is off, after
* an update whose grant was refused reverted the config and cleared it. Re-running
* verification is the obvious recovery, so an already-verified domain must still
* re-grant instead of returning success having done nothing.
*/
it('re-grants trust when the domain is already verified', async () => {
queueAdminWithPendingRow()
queueTableRows(ssoDomain, [])
dbChainMockFns.returning.mockResolvedValueOnce([]) // conditional update matched nothing
queueTableRows(ssoDomain, [{ ...PENDING_ROW, status: 'verified' }]) // re-read: verified
const res = await POST(createMockRequest('POST'), routeContext)
expect(res.status).toBe(200)
expect(dbChainMockFns.set).toHaveBeenCalledWith({ domainVerified: true })
})

it('does not grant trust when the challenge is genuinely stale', async () => {
queueAdminWithPendingRow()
queueTableRows(ssoDomain, [])
dbChainMockFns.returning.mockResolvedValueOnce([]) // lost the race
queueTableRows(ssoDomain, [{ ...PENDING_ROW, status: 'verified' }])
await POST(createMockRequest('POST'), routeContext)
dbChainMockFns.returning.mockResolvedValueOnce([]) // conditional update matched nothing
queueTableRows(ssoDomain, []) // re-read: row deleted or re-tokenized
const res = await POST(createMockRequest('POST'), routeContext)
expect(res.status).toBe(409)
expect(dbChainMockFns.set).not.toHaveBeenCalledWith({ domainVerified: true })
})

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -70,8 +70,20 @@ export const POST = withRouteHandler(
return NextResponse.json({ success: true, data: { domain: toDomainResponse(row) } })
}

const recordPresent = await checkDomainTxtRecord(row.domain, row.verificationToken)
if (!recordPresent) {
const lookup = await checkDomainTxtRecord(row.domain, row.verificationToken)
// 503, not 422: we learned nothing about their record, so this must not read
// as a missing one. SERVFAIL can mean either a fault of ours or a broken zone
// of theirs, so the message states what we know rather than assigning blame.
if (lookup === 'unavailable') {
return NextResponse.json(
{
error:
"We couldn't complete the DNS lookup, so we can't tell yet whether your record is published. Try again in a few minutes — if it keeps failing, check that your domain's nameservers are responding.",
},
{ status: 503 }
)
}
if (lookup === 'absent') {
return NextResponse.json(
{
error:
Expand Down Expand Up @@ -101,6 +113,17 @@ export const POST = withRouteHandler(
// instead of mapping an undefined row or trusting a superseded challenge. A
// concurrent cross-org verification trips the partial unique index; surface
// that as a 409 rather than an unhandled 500.
/**
* Providers this proof covers. Normalized the way migration 0268 stored these
* rows (lower, trimmed, leading `*.` dropped) and identical to the expression
* the deletion path revokes with, so granting and revoking can never diverge.
*/
const providersOnDomain = (verifiedDomain: string) =>
and(
eq(ssoProvider.organizationId, organizationId),
sql`lower(regexp_replace(btrim(${ssoProvider.domain}), '^\\*\\.', '')) = ${verifiedDomain}`
)

let updated: (typeof row)[]
try {
updated = await db.transaction(async (tx) => {
Expand All @@ -118,18 +141,12 @@ export const POST = withRouteHandler(

// Restore trust this proof covers, mirroring the revocation on delete.
// Without it a delete-then-reverify leaves the provider untrusted, and
// since that flag gates sign-in the org sits in a silent SSO outage. The
// comparison matches the revoking one exactly so the two stay symmetric.
// since that flag gates sign-in the org sits in a silent SSO outage.
if (flipped.length > 0) {
await tx
.update(ssoProvider)
.set({ domainVerified: true })
.where(
and(
eq(ssoProvider.organizationId, organizationId),
sql`lower(regexp_replace(btrim(${ssoProvider.domain}), '^\\*\\.', '')) = ${flipped[0].domain}`
)
)
.where(providersOnDomain(flipped[0].domain))
}

return flipped
Expand All @@ -155,6 +172,13 @@ export const POST = withRouteHandler(
.where(and(eq(ssoDomain.id, domainId), eq(ssoDomain.organizationId, organizationId)))
.limit(1)
if (current?.status === 'verified') {
// Re-grant rather than returning early: a provider can hold a verified
// domain with its own flag off, after an update whose grant was refused
// reverted the config. The proof is present, which authorizes this.
await db
.update(ssoProvider)
.set({ domainVerified: true })
.where(providersOnDomain(current.domain))
Comment thread
greptile-apps[bot] marked this conversation as resolved.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-grant skips proof row lock

Medium Severity

The idempotent re-grant runs a standalone sso_provider update after a re-read, without wrapping the grant in a transaction or locking the matching verified sso_domain row. If the domain proof is deleted between that read and the update, providers can end up with domainVerified: true while no verified proof row remains.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit f5a3de6. Configure here.

return NextResponse.json({ success: true, data: { domain: toDomainResponse(current) } })
}
return NextResponse.json(
Expand Down
10 changes: 9 additions & 1 deletion apps/sim/ee/sso/components/verified-domains-section.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,9 @@ import {
useVerifyOrganizationDomain,
} from '@/ee/sso/hooks/domains'

/** Ties the "Add a domain" label to its input, so clicking the label focuses it. */
const ADD_DOMAIN_FIELD_ID = 'sso-add-domain'

interface VerifiedDomainsSectionProps {
organizationId: string
}
Expand Down Expand Up @@ -65,16 +68,19 @@ function DomainRow({ organizationId, domain, onRemove }: DomainRowProps) {
<SettingRow
label='Host / name'
description='Some DNS providers append your zone automatically. If yours does, enter this host with the trailing zone removed.'
htmlFor={`${domain.id}-challenge-host`}
>
<ChipCopyInput
id={`${domain.id}-challenge-host`}
value={domain.challengeHost}
copyLabel='Copy host'
inputClassName='font-mono'
/>
</SettingRow>

<SettingRow label='Value'>
<SettingRow label='Value' htmlFor={`${domain.id}-challenge-value`}>
<ChipCopyInput
id={`${domain.id}-challenge-value`}
value={domain.txtRecordValue}
copyLabel='Copy value'
inputClassName='font-mono'
Expand Down Expand Up @@ -139,9 +145,11 @@ export function VerifiedDomainsSection({ organizationId }: VerifiedDomainsSectio
<SettingRow
label='Add a domain'
description='Verify a domain your organization owns before configuring SSO for it. Verifying proves you control the domain, so no one else can point it at their identity provider.'
htmlFor={ADD_DOMAIN_FIELD_ID}
>
<div className='flex items-center gap-2'>
<ChipInput
id={ADD_DOMAIN_FIELD_ID}
value={newDomain}
onChange={(event) => setNewDomain(event.target.value)}
onKeyDown={(event) => {
Expand Down
28 changes: 16 additions & 12 deletions apps/sim/lib/auth/sso/domain-verification.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -107,13 +107,13 @@ describe('domain-verification helpers', () => {

it('verifies when the exact value is published', async () => {
mockResolveTxt.mockResolvedValue([[EXPECTED]])
await expect(checkDomainTxtRecord('acme.com', TOKEN)).resolves.toBe(true)
await expect(checkDomainTxtRecord('acme.com', TOKEN)).resolves.toBe('present')
})

it('joins a value split across 255-char chunks before comparing', async () => {
const midpoint = Math.floor(EXPECTED.length / 2)
mockResolveTxt.mockResolvedValue([[EXPECTED.slice(0, midpoint), EXPECTED.slice(midpoint)]])
await expect(checkDomainTxtRecord('acme.com', TOKEN)).resolves.toBe(true)
await expect(checkDomainTxtRecord('acme.com', TOKEN)).resolves.toBe('present')
})

it('finds the match among unrelated TXT records on the same host', async () => {
Expand All @@ -122,37 +122,41 @@ describe('domain-verification helpers', () => {
['facebook-domain-verification=abc123'],
[EXPECTED],
])
await expect(checkDomainTxtRecord('acme.com', TOKEN)).resolves.toBe(true)
await expect(checkDomainTxtRecord('acme.com', TOKEN)).resolves.toBe('present')
})

it('tolerates padding a DNS panel added around the value', async () => {
mockResolveTxt.mockResolvedValue([[` ${EXPECTED} `]])
await expect(checkDomainTxtRecord('acme.com', TOKEN)).resolves.toBe(true)
await expect(checkDomainTxtRecord('acme.com', TOKEN)).resolves.toBe('present')
})

it('rejects a near-miss value (no partial or prefix match)', async () => {
mockResolveTxt.mockResolvedValue([[`${EXPECTED}extra`], [EXPECTED.slice(0, -1)]])
await expect(checkDomainTxtRecord('acme.com', TOKEN)).resolves.toBe(false)
await expect(checkDomainTxtRecord('acme.com', TOKEN)).resolves.toBe('absent')
})

it('rejects another org token published on the same host', async () => {
mockResolveTxt.mockResolvedValue([[buildTxtRecordValue('someone-elses-token')]])
await expect(checkDomainTxtRecord('acme.com', TOKEN)).resolves.toBe(false)
await expect(checkDomainTxtRecord('acme.com', TOKEN)).resolves.toBe('absent')
})

it('returns false (never throws) when the record is absent', async () => {
it('reports absent (never throws) when the record is not published', async () => {
mockResolveTxt.mockRejectedValue(Object.assign(new Error('no data'), { code: 'ENODATA' }))
await expect(checkDomainTxtRecord('acme.com', TOKEN)).resolves.toBe(false)
await expect(checkDomainTxtRecord('acme.com', TOKEN)).resolves.toBe('absent')
})

it('returns false (never throws) when resolution fails for an infrastructure reason', async () => {
/**
* Distinct from `absent`: our resolver failed, so we learned nothing about the
* admin's DNS and must not tell them their record is missing.
*/
it('reports unavailable when resolution fails for an infrastructure reason', async () => {
mockResolveTxt.mockRejectedValue(Object.assign(new Error('timeout'), { code: 'ETIMEOUT' }))
await expect(checkDomainTxtRecord('acme.com', TOKEN)).resolves.toBe(false)
await expect(checkDomainTxtRecord('acme.com', TOKEN)).resolves.toBe('unavailable')
})

it('returns false when the host has no TXT records at all', async () => {
it('reports absent when the host has no TXT records at all', async () => {
mockResolveTxt.mockResolvedValue([])
await expect(checkDomainTxtRecord('acme.com', TOKEN)).resolves.toBe(false)
await expect(checkDomainTxtRecord('acme.com', TOKEN)).resolves.toBe('absent')
})
})
})
48 changes: 28 additions & 20 deletions apps/sim/lib/auth/sso/domain-verification.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,13 +98,25 @@ export function generateVerificationToken(): string {
}

/**
* Resolves the challenge host's TXT records against public nameservers and
* returns true when the expected `sim-domain-verification=<token>` value is
* present. Never throws — resolution failures (NXDOMAIN, timeout, missing
* record) resolve to `false` so a not-yet-propagated record simply reads as
* unverified.
* Outcome of a TXT challenge lookup.
*
* `absent` and `unavailable` are kept apart because they place the fault on
* opposite sides: the first means the admin's record is not published yet, the
* second means our own resolver path failed and we learned nothing about their
* DNS. Collapsing both to "not found" tells an admin to fix a record that may
* already be correct.
*/
export async function checkDomainTxtRecord(domain: string, token: string): Promise<boolean> {
export type DomainTxtLookup = 'present' | 'absent' | 'unavailable'

/**
* Resolves the challenge host's TXT records against public nameservers. Never
* throws: a missing record resolves to `absent`, and an infrastructure failure
* (blocked egress, timeout, SERVFAIL) to `unavailable`.
*/
export async function checkDomainTxtRecord(
domain: string,
token: string
): Promise<DomainTxtLookup> {
const host = buildChallengeHost(domain)
const expected = buildTxtRecordValue(token)

Expand All @@ -115,24 +127,20 @@ export async function checkDomainTxtRecord(domain: string, token: string): Promi
// would otherwise fail an exact match forever with no way for the admin to
// tell why. Concatenation happens first, so trimming cannot corrupt a
// legitimate chunk boundary.
return records.some((chunks) => chunks.join('').trim() === expected)
return records.some((chunks) => chunks.join('').trim() === expected) ? 'present' : 'absent'
} catch (error) {
const code = (error as NodeJS.ErrnoException)?.code
if (code && RECORD_ABSENT_DNS_CODES.has(code)) {
logger.debug('TXT verification record not published yet', { host, code })
} else {
// Not a missing record — our resolver path itself is failing (blocked
// egress, timeout, SERVFAIL). Log at ERROR, not warn: the default minimum
// level in production is ERROR, so anything below it is dropped and the
// fault stays invisible while the admin is told their record "isn't
// published yet". This is a genuine infrastructure fault, so ERROR is also
// the honest severity.
logger.error('TXT verification lookup failed for an infrastructure reason', {
host,
code,
error: getErrorMessage(error),
})
return 'absent'
}
return false
// Our resolver path itself is failing. Log at ERROR: production's minimum
// level drops anything lower, so a warn would keep the fault invisible.
logger.error('TXT verification lookup failed for an infrastructure reason', {
host,
code,
error: getErrorMessage(error),
})
return 'unavailable'
}
}
Loading