fix(import): enforce account authorization for Export callbacks

This commit is contained in:
Nicholas Dudfield
2026-09-16 20:01:35 +07:00
parent 65b39982f0
commit 9ed50bb71c
3 changed files with 106 additions and 3 deletions

View File

@@ -2542,6 +2542,92 @@ struct Export_test : public beast::unit_test::suite
}
}
void
testExportCallbackAccountAuthorization(FeatureBitset features)
{
using namespace jtx;
for (std::string const mode : {"master", "regular", "multisign"})
{
testcase("Export callback source-account authorization: " + mode);
auto const xpopCtx = xpop::TestXPOPContext::create(3);
Account const alice{"alice"};
Account const carol{"carol"};
Account const dave{"dave"};
Env env{*this, xpopCtx.makeEnvConfig(21337), features};
env.fund(XRP(10000), alice, carol, dave);
env.close();
auto const callback =
buildExportCallbackXPOP(env, xpopCtx, alice, carol, 31337, 2);
auto const latchKey =
keylet::exportLatch(alice.id(), callback.originTxn);
BEAST_EXPECT(env.current()->exists(latchKey));
if (mode == "regular")
{
env(regkey(alice, carol));
env(fset(alice, asfDisableMaster));
}
else if (mode == "multisign")
{
env(signers(alice, 2, {{carol, 1}, {dave, 1}}));
env(fset(alice, asfDisableMaster));
}
env.close();
auto const before =
env.current()->read(keylet::account(alice.id()));
auto const balance = before->getFieldAmount(sfBalance).xrp();
auto const sequence = before->getFieldU32(sfSequence);
auto const feeDrops = env.current()->fees().base * 10;
auto const reject = [&](auto const& signing, TER const result) {
env(import::import(alice, callback.xpopJson),
signing,
fee(feeDrops),
ter(result));
auto const after =
env.current()->read(keylet::account(alice.id()));
BEAST_EXPECT(after->getFieldAmount(sfBalance).xrp() == balance);
BEAST_EXPECT(after->getFieldU32(sfSequence) == sequence);
BEAST_EXPECT(env.current()->exists(latchKey));
if (callback.vlInfo)
BEAST_EXPECT(
importVLSequence(env, callback.vlInfo->second) == 0);
};
reject(sig(dave), tefBAD_AUTH);
if (mode == "master")
{
reject(msig(carol), tefNOT_MULTI_SIGNING);
env(import::import(alice, callback.xpopJson),
sig(alice),
fee(feeDrops),
ter(tesSUCCESS));
}
else if (mode == "regular")
{
reject(sig(alice), tefMASTER_DISABLED);
env(import::import(alice, callback.xpopJson),
sig(carol),
fee(feeDrops),
ter(tesSUCCESS));
}
else
{
reject(sig(alice), tefMASTER_DISABLED);
reject(msig(carol), tefBAD_QUORUM);
env(import::import(alice, callback.xpopJson),
msig(carol, dave),
fee(feeDrops),
ter(tesSUCCESS));
}
auto const after = env.current()->read(keylet::account(alice.id()));
BEAST_EXPECT(
after->getFieldAmount(sfBalance).xrp() == balance - feeDrops);
BEAST_EXPECT(after->getFieldU32(sfSequence) == sequence + 1);
BEAST_EXPECT(!env.current()->exists(latchKey));
}
}
void
testCanceledExportAcceptsMatchingImport(FeatureBitset features)
{
@@ -2691,6 +2777,7 @@ struct Export_test : public beast::unit_test::suite
// Round-trip test
testExportImportRoundTrip(allWithExport);
testExportCallbackAccountAuthorization(allWithExport);
testCanceledExportAcceptsMatchingImport(allWithExport);
testExportImportRejectsStaleImportVL(allWithExport);
}

View File

@@ -283,6 +283,12 @@ transaction's `sfAccount`, which Export admission already bound to the exporter
and latch owner. Possession of an XPOP alone never authorizes another account's
callback.
The source account's enabled master key, current regular key, or configured
multisigning quorum authorizes the outer Import and its fee/sequence effects.
An unrelated signing key must fail before balance, sequence, latch, or Import
validator-list state changes. The target committee's signatures and possession
of its valid XPOP do not substitute for source-account authorization.
The callback may omit the burn-to-mint `sfOperationLimit` and outer/inner
signing-key-equality checks because the validator-multisigned target and Export
latch replace those bindings. It still requires a fully canonical target

View File

@@ -23,6 +23,7 @@
#include <xrpld/app/misc/HashRouter.h>
#include <xrpld/app/misc/LoadFeeTrack.h>
#include <xrpld/app/tx/apply.h>
#include <xrpld/app/tx/detail/Import.h>
#include <xrpld/app/tx/detail/NFTokenUtils.h>
#include <xrpld/app/tx/detail/SetHook.h>
#include <xrpld/app/tx/detail/SignerEntries.h>
@@ -908,11 +909,20 @@ Transactor::checkSign(PreclaimContext const& ctx)
ctx.tx.getFieldU32(sfNetworkID) == 65535)
return tesSUCCESS;
// pass ttIMPORTs, their signatures are checked at the preflight against the
// internal xpop txn
// Burn-to-mint Imports retain their XPOP-based signing authorization.
// Export callbacks instead authorize the outer source-account transaction
// normally: target committee signatures do not authorize its fee/sequence.
if (ctx.view.rules().enabled(featureImport) &&
ctx.tx.getTxnType() == ttIMPORT)
return tesSUCCESS;
{
if (!ctx.view.rules().enabled(featureExport))
return tesSUCCESS;
auto const [inner, meta] = Import::getInnerTxn(ctx.tx, ctx.j);
if (!inner || !meta)
return temMALFORMED;
if (!inner->isFieldPresent(sfTicketSequence))
return tesSUCCESS;
}
// pass ttMANIFEST_SETs, their signatures are checked in preflight against
// the manifest's internal key logic