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
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -17,3 +17,6 @@ target
.soroban

config/tsconfig.tmp.json

.claude/
.copilot/
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ A breaking change will get clearly marked in this log.

### Fixed
* Sanitize identifiers and escape string literals in generated TypeScript bindings to prevent code injection via malicious contract spec names. `sanitizeIdentifier` now strips non-identifier characters, and a new `escapeStringLiteral` helper escapes quotes and newlines in string contexts ([#1345](https://github.com/stellar/js-stellar-sdk/pull/1345)).
* `AssembledTransaction.fromXDR()` and `fromJSON()` now validate that the deserialized transaction targets the expected contract, rejecting mismatched contract IDs and non-invokeContract operations. ([#1349](https://github.com/stellar/js-stellar-sdk/pull/1349)).

## [v14.6.1](https://github.com/stellar/js-stellar-sdk/compare/v14.6.0...v14.6.1)

Expand Down
92 changes: 80 additions & 12 deletions src/contract/assembled_transaction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -361,6 +361,66 @@ export class AssembledTransaction<T> {
});
}

/**
* Validate that a built transaction is a single invokeContract operation
* targeting the expected contract, and return the parsed InvokeContractArgs.
*/
private static validateInvokeContractOp(
built: Tx,
expectedContractId: string,
): xdr.InvokeContractArgs {
if (built.operations.length !== 1) {
throw new Error(
"Transaction envelope must contain exactly one operation.",
);
}

const operation = built.operations[0];

if (operation.type !== "invokeHostFunction") {
throw new Error(
"Transaction envelope does not contain an invokeHostFunction operation.",
);
}

const invokeOp = operation as Operation.InvokeHostFunction;

if (invokeOp.func.switch().name !== "hostFunctionTypeInvokeContract") {
throw new Error(
"Transaction envelope does not contain an invokeContract host function.",
);
}

const invokeContractArgs = invokeOp.func.value() as xdr.InvokeContractArgs;

let contractAddress: xdr.ScAddress;
let functionName: string;

try {
contractAddress = invokeContractArgs.contractAddress();
functionName = invokeContractArgs.functionName().toString("utf-8");
Comment thread
Shaptic marked this conversation as resolved.
} catch {
throw new Error(
"Could not extract contract address or method name from the transaction envelope.",
);
}

if (!contractAddress || !functionName) {
throw new Error(
"Could not extract contract address or method name from the transaction envelope.",
);
}

const xdrContractId = Address.fromScAddress(contractAddress).toString();
if (xdrContractId !== expectedContractId) {
throw new Error(
`Transaction envelope targets contract ${xdrContractId}, but this Client is configured for ${expectedContractId}.`,
);
}

return invokeContractArgs;
}

static fromJSON<T>(
options: Omit<AssembledTransactionOptions<T>, "args">,
{
Expand All @@ -378,6 +438,20 @@ export class AssembledTransaction<T> {
): AssembledTransaction<T> {
const txn = new AssembledTransaction(options);
txn.built = TransactionBuilder.fromXDR(tx, options.networkPassphrase) as Tx;

const invokeContractArgs = AssembledTransaction.validateInvokeContractOp(
txn.built,
options.contractId,
);

const xdrMethod = invokeContractArgs.functionName().toString("utf-8");
Comment thread
Shaptic marked this conversation as resolved.

if (xdrMethod !== options.method) {
throw new Error(
`Transaction envelope calls method '${xdrMethod}', but the provided method is '${options.method}'.`,
);
}

txn.simulationResult = {
auth: simulationResult.auth.map((a) =>
xdr.SorobanAuthorizationEntry.fromXDR(a, "base64"),
Expand Down Expand Up @@ -419,18 +493,12 @@ export class AssembledTransaction<T> {
envelope,
options.networkPassphrase,
) as Tx;
const operation = built.operations[0] as Operation.InvokeHostFunction;
if (!operation?.func?.value || typeof operation.func.value !== "function") {
throw new Error(
"Could not extract the method from the transaction envelope.",
);
}
const invokeContractArgs = operation.func.value() as xdr.InvokeContractArgs;
if (!invokeContractArgs?.functionName) {
throw new Error(
"Could not extract the method name from the transaction envelope.",
);
}

const invokeContractArgs = AssembledTransaction.validateInvokeContractOp(
built,
options.contractId,
);

const method = invokeContractArgs.functionName().toString("utf-8");
const txn = new AssembledTransaction({
...options,
Expand Down
192 changes: 190 additions & 2 deletions test/unit/server/soroban/assembled_transaction.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,18 @@ import { describe, it, beforeEach, afterEach, expect, vi } from "vitest";
import { serverUrl } from "../../../constants";
import { StellarSdk } from "../../../test-utils/stellar-sdk-import";

const { Account, Keypair, rpc, contract, SorobanDataBuilder, xdr, Address } =
StellarSdk;
const {
Account,
Keypair,
Operation,
TransactionBuilder,
TimeoutInfinite,
rpc,
contract,
SorobanDataBuilder,
xdr,
Address,
} = StellarSdk;
const { Server } = StellarSdk.rpc;

const restoreTxnData = StellarSdk.SorobanDataBuilder.fromXDR(
Expand Down Expand Up @@ -155,3 +165,181 @@ describe("AssembledTransaction", () => {
);
});
});

describe("Contract ID validation on deserialization", () => {
const networkPassphrase = "Standalone Network ; February 2017";
const keypair = Keypair.random();
const source = new Account(keypair.publicKey(), "0");

const victimContractId =
"CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM";
const attackerContractId =
"CC53XO53XO53XO53XO53XO53XO53XO53XO53XO53XO53XO53XO53WQD5";

const createSpec = (methodName: string) => {
const funcSpec = xdr.ScSpecEntry.scSpecEntryFunctionV0(
new xdr.ScSpecFunctionV0({
doc: "",
name: methodName,
inputs: [],
outputs: [xdr.ScSpecTypeDef.scSpecTypeU32()],
}),
);
return new contract.Spec([funcSpec.toXDR("base64")]);
};

function buildInvokeTx(targetContractId: string, methodName: string) {
return new TransactionBuilder(source, {
fee: "100",
networkPassphrase,
})
.setTimeout(TimeoutInfinite)
.addOperation(
Operation.invokeContractFunction({
contract: targetContractId,
function: methodName,
args: [],
}),
)
.build();
}

it("fromXDR() accepts a transaction targeting the configured contract", () => {
const tx = buildInvokeTx(victimContractId, "test");
const xdrBase64 = tx.toEnvelope().toXDR("base64");
const spec = createSpec("test");

const assembled = contract.AssembledTransaction.fromXDR(
{
contractId: victimContractId,
networkPassphrase,
rpcUrl: "https://example.com",
},
xdrBase64,
spec,
);
expect(assembled.built).toBeDefined();
});

it("fromXDR() rejects a transaction targeting a different contract", () => {
const tx = buildInvokeTx(attackerContractId, "drain");
const xdrBase64 = tx.toEnvelope().toXDR("base64");
const spec = createSpec("drain");

expect(() =>
contract.AssembledTransaction.fromXDR(
{
contractId: victimContractId,
networkPassphrase,
rpcUrl: "https://example.com",
},
xdrBase64,
spec,
),
).toThrow(
`Transaction envelope targets contract ${attackerContractId}, but this Client is configured for ${victimContractId}.`,
);
});

it("fromJSON() accepts a transaction targeting the configured contract", () => {
const tx = buildInvokeTx(victimContractId, "test");
const spec = createSpec("test");
const simulationResult = {
auth: [],
retval: xdr.ScVal.scvU32(0).toXDR("base64"),
};
const simulationTransactionData = new SorobanDataBuilder()
.build()
.toXDR("base64");

const json = JSON.stringify({
method: "test",
tx: tx.toEnvelope().toXDR("base64"),
simulationResult,
simulationTransactionData,
});

const { method, ...txData } = JSON.parse(json);
const assembled = contract.AssembledTransaction.fromJSON(
{
contractId: victimContractId,
networkPassphrase,
rpcUrl: "https://example.com",
method,
parseResultXdr: (result: any) => spec.funcResToNative(method, result),
},
txData,
);
expect(assembled.built).toBeDefined();
});

it("fromJSON() rejects a transaction targeting a different contract", () => {
const tx = buildInvokeTx(attackerContractId, "drain");
const simulationResult = {
auth: [],
retval: xdr.ScVal.scvU32(0).toXDR("base64"),
};
const simulationTransactionData = new SorobanDataBuilder()
.build()
.toXDR("base64");

const json = JSON.stringify({
method: "drain",
tx: tx.toEnvelope().toXDR("base64"),
simulationResult,
simulationTransactionData,
});

const { method, ...txData } = JSON.parse(json);

expect(() =>
contract.AssembledTransaction.fromJSON(
{
contractId: victimContractId,
networkPassphrase,
rpcUrl: "https://example.com",
method,
parseResultXdr: () => {},
},
txData,
),
).toThrow(
`Transaction envelope targets contract ${attackerContractId}, but this Client is configured for ${victimContractId}.`,
);
});

it("fromJSON() rejects a transaction with a spoofed method name", () => {
const tx = buildInvokeTx(victimContractId, "transfer");
const simulationResult = {
auth: [],
retval: xdr.ScVal.scvU32(0).toXDR("base64"),
};
const simulationTransactionData = new SorobanDataBuilder()
.build()
.toXDR("base64");

const json = JSON.stringify({
method: "safe_operation",
tx: tx.toEnvelope().toXDR("base64"),
simulationResult,
simulationTransactionData,
});

const { method, ...txData } = JSON.parse(json);

expect(() =>
contract.AssembledTransaction.fromJSON(
{
contractId: victimContractId,
networkPassphrase,
rpcUrl: "https://example.com",
method,
parseResultXdr: () => {},
},
txData,
),
).toThrow(
"Transaction envelope calls method 'transfer', but the provided method is 'safe_operation'.",
);
});
});
Loading