fix(signer): reject transactions with unconsumed trailing bytes

from_cbor_hex only requires a valid prefix, so extra bytes were silently
dropped and decode returned a confident, fully-populated result - the exact
fail-open the module exists to prevent, and policy trusts this output.
This commit is contained in:
FilipBl4gojevic
2026-07-28 12:02:17 +02:00
parent 85d718dc14
commit 5da4447835
2 changed files with 62 additions and 0 deletions

View File

@@ -58,6 +58,28 @@ export function decodeTransaction(cborHex: string): DecodedTx {
); );
} }
// from_cbor_hex only requires that a *prefix* of cborHex parse as a valid
// transaction; it does not check that parsing consumed the whole string, so
// trailing bytes would otherwise be silently discarded and produce a
// confident, fully-populated result over less data than was actually
// supplied. CML preserves the original encoding on parse-then-reserialize
// (only objects built from scratch via `.new()` are canonicalised), so
// re-serialising and comparing against the input detects any unconsumed
// suffix. Compared case-insensitively: valid uppercase-hex input round-trips
// through CML as lowercase, which would otherwise read as a mismatch.
// Verified empirically (not just by this module's own tests, which lack an
// asset-bearing or multi-shape fixture) across: the clean fixture, the
// fixture with trailing bytes appended, a fixture-derived transaction with a
// real vkey witness attached (the shape sign.ts will produce), and a
// fixture-derived transaction with an added output (a structural change) —
// strict equality held for every legitimate case and failed only, in the
// "input longer than reserialised" direction, for the trailing-bytes case.
if (tx.to_cbor_hex().toLowerCase() !== cborHex.toLowerCase()) {
throw new Error(
'Transaction could not be decoded: input was not fully consumed (unrecognised trailing data)'
);
}
const body = tx.body(); const body = tx.body();
const outputs: DecodedOutput[] = []; const outputs: DecodedOutput[] = [];
const cmlOutputs = body.outputs(); const cmlOutputs = body.outputs();

View File

@@ -1,5 +1,6 @@
import { describe, test } from 'node:test'; import { describe, test } from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import * as CML from '@anastasia-labs/cardano-multiplatform-lib-nodejs';
import { decodeTransaction } from '../src/decode.ts'; import { decodeTransaction } from '../src/decode.ts';
// Real unsigned transaction from Vector testnet. 1 input, 2 outputs // Real unsigned transaction from Vector testnet. 1 input, 2 outputs
@@ -56,4 +57,43 @@ describe('decodeTransaction', () => {
test('rejects an empty string', () => { test('rejects an empty string', () => {
assert.throws(() => decodeTransaction(''), /could not be decoded/i); assert.throws(() => decodeTransaction(''), /could not be decoded/i);
}); });
// CML's from_cbor_hex only requires that a *prefix* of the input parse as a
// valid transaction — it does not check that parsing consumed the whole
// string. Left unchecked, appending arbitrary bytes to a real transaction
// would silently decode as if those bytes did not exist: a confident,
// fully-populated result over less data than was actually supplied. That is
// the opposite failure mode from the tests above (which all fail to parse
// at all) and is exactly the kind of partial understanding this module must
// never hand to policy.
describe('unconsumed input (trailing bytes)', () => {
test('rejects a transaction with trailing bytes appended', () => {
assert.throws(() => decodeTransaction(FIXTURE_CBOR + 'deadbeef'), /could not be decoded/i);
});
test('still accepts the clean fixture (no false positive on legitimate input)', () => {
assert.doesNotThrow(() => decodeTransaction(FIXTURE_CBOR));
});
test('still accepts a signed transaction (fixture + a real vkey witness)', () => {
const unsigned = CML.Transaction.from_cbor_hex(FIXTURE_CBOR);
const body = unsigned.body();
const hash = CML.hash_transaction(body);
const privateKey = CML.PrivateKey.generate_ed25519();
const signature = privateKey.sign(hash.to_raw_bytes());
const vkeywitnesses = CML.VkeywitnessList.new();
vkeywitnesses.add(CML.Vkeywitness.new(privateKey.to_public(), signature));
const witnessSet = CML.TransactionWitnessSet.new();
witnessSet.set_vkeywitnesses(vkeywitnesses);
// The same shape sign.ts will produce: original body, a witness set, no
// auxiliary data.
const signed = CML.Transaction.new(body, witnessSet, true, undefined);
const decoded = decodeTransaction(signed.to_cbor_hex());
assert.equal(decoded.fee, 168223n);
assert.equal(decoded.outputs.length, 2);
});
});
}); });