diff --git a/js/CHANGELOG.md b/js/CHANGELOG.md index 93af9cad0..2dac1a9eb 100644 --- a/js/CHANGELOG.md +++ b/js/CHANGELOG.md @@ -1,5 +1,18 @@ # CHANGELOG +## Unreleased + +- Fixed: every decode entry point on `Sails` (v1) and `SailsProgram` (v2) now + validates the reply prefix. In v2 `decodeResult` now calls `_assertMatchingHeader` + like the other decode methods. In v1 — where `decodePayload` (function and + constructor), `decodeResult`, and `event.decode` all previously skipped the + prefix check — each now verifies the `(service, function)` / `(service, event)` / + constructor name prefix against the method's identity, and surfaces a single + consistent `Invalid prefix for …` error on both mismatched and truncated bytes. +- Documented the `sails-js/types`, `sails-js/parser`, and `sails-js/util` subpath + exports in the README, which re-export types from the internal `sails-js-types`, + `sails-js-parser-idl-v2`, and `sails-js-util` packages. + ## 0.5.1 - Fixed: generation correct TransactionBuilder constructor calls (https://github.com/gear-tech/sails/pull/1070). diff --git a/js/README.md b/js/README.md index e06e809b0..9688245d2 100644 --- a/js/README.md +++ b/js/README.md @@ -209,6 +209,26 @@ Use `sails.services.ServiceName.functions.FunctionName.encodePayload` method of const payload = sails.services.ServiceName.functions.FunctionName.encodePayload(arg1, arg2); ``` +### Subpath exports + +In addition to the root `sails-js` entry, the package exposes a few subpath exports: + +```javascript +// Shared TypeScript interfaces for parsed IDL types +// (e.g. ISailsTypeDef, ISailsPrimitiveDef, ISailsStructDef, ISailsEnumDef, ...) +import type { ISailsTypeDef, ISailsPrimitiveDef } from 'sails-js/types'; + +// Utility helpers (getScaleCodecDef, getPayloadMethod, ...) +import { getScaleCodecDef } from 'sails-js/util'; + +// IDL v2 parser (only relevant if you're using `SailsProgram` / IDL v2 — +// see READMEV2.md). For v1 IDLs, use the standalone `sails-js-parser` +// package imported at the top of this README. +import { SailsIdlParser } from 'sails-js/parser'; +``` + +The `sails-js/types` subpath is useful for tooling that walks IDL type graphs (form renderers, custom decoders, IDE plugins) — the accessor interfaces (`isVec`/`asVec`, `isStruct`/`asStruct`, ...) are defined there and avoid the need for `any` casts on `TypeDef`/`PrimitiveDef` instances from `sails-js-parser`. + ## Transaction builder diff --git a/js/READMEV2.md b/js/READMEV2.md index b156fd35e..02beaaa31 100644 --- a/js/READMEV2.md +++ b/js/READMEV2.md @@ -192,6 +192,24 @@ Use `program.services.ServiceName.functions.FunctionName.encodePayload` method o const payload = program.services.ServiceName.functions.FunctionName.encodePayload(arg1, arg2); ``` +### Subpath exports + +`sails-js` re-exports a few utility modules via subpath exports. This lets you import parser/type/utility symbols without depending on internal workspace packages directly: + +```javascript +// v2 parser (equivalent to the internal `sails-js-parser-idl-v2` package) +import { SailsIdlParser, SailsMessageHeader, InterfaceId, normalizeIdl } from 'sails-js/parser'; + +// Shared TypeScript interfaces describing parsed IDL types +// (e.g. ISailsTypeDef, ISailsPrimitiveDef, ISailsStructDef, ISailsEnumDef, ...) +import type { ISailsTypeDef, ISailsPrimitiveDef } from 'sails-js/types'; + +// Utility helpers (getScaleCodecDef, getPayloadMethod, ...) +import { getScaleCodecDef } from 'sails-js/util'; +``` + +The `sails-js/types` subpath is particularly useful for tooling that walks IDL type graphs (form renderers, custom decoders, IDE plugins) — the accessor interfaces (`isVec`/`asVec`, `isStruct`/`asStruct`, ...) are defined there and avoid the need for `any` casts on `TypeDef`/`PrimitiveDef` instances. + ## Transaction builder diff --git a/js/src/sails-idl-v2.ts b/js/src/sails-idl-v2.ts index bc4a889bb..5700f59fa 100644 --- a/js/src/sails-idl-v2.ts +++ b/js/src/sails-idl-v2.ts @@ -487,6 +487,7 @@ export class SailsService implements ISailsService { return result as T; }, decodeResult: (result: HexString) => { + _assertMatchingHeader(result, header, `${service.name}.${func.name} result`); const payload = this.registry.createType(`([u8; 16], ${returnType})`, result); return payload[1].toJSON() as T; }, diff --git a/js/src/sails.ts b/js/src/sails.ts index e1b45d75c..469c6d2cb 100644 --- a/js/src/sails.ts +++ b/js/src/sails.ts @@ -4,7 +4,7 @@ import { u8aToHex } from '@polkadot/util'; import { getScaleCodecDef } from 'sails-js-util'; import { ZERO_ADDRESS } from './consts.js'; -import { getFnNamePrefix, getServiceNamePrefix } from './prefix.js'; +import { getCtorNamePrefix, getFnNamePrefix, getServiceNamePrefix } from './prefix.js'; import { QueryBuilder } from './query-builder.js'; import { TransactionBuilder } from './transaction-builder.js'; import { ISailsIdlParser, ISailsProgram, ISailsService, ISailsTypeDef } from './types.js'; @@ -77,6 +77,52 @@ interface ISailsCtorFuncParams { readonly docs?: string; } +// Cap echoed names in error messages so attacker-controlled bytes can't flood logs +// with unbounded strings or smuggle control characters (e.g., ANSI escape sequences). +const _MAX_ECHOED_NAME_LEN = 64; +const _safeEchoedName = (name: string): string => { + const truncated = name.length > _MAX_ECHOED_NAME_LEN ? `${name.slice(0, _MAX_ECHOED_NAME_LEN)}...` : name; + // Strip anything outside printable ASCII. + return truncated.replaceAll(/[^ -~]/g, '?'); +}; + +const _toHex = (bytes: Uint8Array | HexString | string): HexString => + typeof bytes === 'string' ? (bytes as HexString) : u8aToHex(bytes); + +const _assertMatchingServicePrefix = ( + bytes: Uint8Array | HexString, + expectedService: string, + expectedFn: string, + target: string, +) => { + const hex = _toHex(bytes); + let actualService: string; + let actualFn: string; + try { + actualService = getServiceNamePrefix(hex); + actualFn = getFnNamePrefix(hex); + } catch { + throw new Error(`Invalid prefix for ${target}: cannot read service/function name`); + } + if (actualService !== expectedService || actualFn !== expectedFn) { + throw new Error( + `Invalid prefix for ${target}: got ${_safeEchoedName(actualService)}.${_safeEchoedName(actualFn)}`, + ); + } +}; + +const _assertMatchingCtorPrefix = (bytes: Uint8Array | string, expectedName: string, target: string) => { + let actual: string; + try { + actual = getCtorNamePrefix(_toHex(bytes)); + } catch { + throw new Error(`Invalid prefix for ${target}: cannot read constructor name`); + } + if (actual !== expectedName) { + throw new Error(`Invalid prefix for ${target}: got ${_safeEchoedName(actual)}`); + } +}; + export class Sails { private _parser: ISailsIdlParser; private _program: ISailsProgram; @@ -240,6 +286,7 @@ export class Sails { return payload.toHex(); }, decodePayload: (bytes: HexString) => { + _assertMatchingServicePrefix(bytes, service.name, func.name, `${service.name}.${func.name}`); const payload = this.registry.createType(`(String, String, ${params.map((p) => p.type).join(', ')})`, bytes); const result = {} as Record; for (const [i, param] of params.entries()) { @@ -248,6 +295,7 @@ export class Sails { return result as T; }, decodeResult: (result: HexString) => { + _assertMatchingServicePrefix(result, service.name, func.name, `${service.name}.${func.name} result`); const payload = this.registry.createType(`(String, String, ${returnType})`, result); return payload[2].toJSON() as T; }, @@ -283,6 +331,7 @@ export class Sails { return true; }, decode: (payload: HexString) => { + _assertMatchingServicePrefix(payload, service.name, event.name, `${service.name}.${event.name}`); const data = this.registry.createType(`(String, String, ${typeStr})`, payload); return data[2].toJSON(); }, @@ -366,6 +415,7 @@ export class Sails { return payload.toHex(); }, decodePayload: (bytes: Uint8Array | string) => { + _assertMatchingCtorPrefix(bytes, func.name, `constructor "${func.name}"`); const payload = this.registry.createType(`(String, ${params.map((p) => p.type).join(', ')})`, bytes); const result = {} as Record; for (const [i, param] of params.entries()) { diff --git a/js/test/encode-decode.test.ts b/js/test/encode-decode.test.ts index a3e5447cc..fae0f8cf0 100644 --- a/js/test/encode-decode.test.ts +++ b/js/test/encode-decode.test.ts @@ -38,4 +38,74 @@ describe('Encode/Decode', () => { expect(getServiceNamePrefix(walkEncoded)).toBe('Dog'); expect(getFunctionNamePrefix(walkEncoded)).toBe('Walk'); }); + + test('decodeResult validates service/function prefix', () => { + const add = sails.services.Counter.functions.Add; + const validReply = sails.registry.createType('(String, String, u32)', ['Counter', 'Add', 99]).toHex(); + expect(add.decodeResult(validReply)).toBe(99); + + const mismatchedReply = sails.registry + .createType('(String, String, u32)', ['WrongService', 'Add', 99]) + .toHex(); + expect(() => add.decodeResult(mismatchedReply)).toThrow('Invalid prefix for Counter.Add result'); + }); + + test('decodeResult throws a clear error on truncated reply bytes', () => { + const add = sails.services.Counter.functions.Add; + // Empty bytes / too short to even contain the compact-prefixed service name — + // the prefix helpers throw from the SCALE codec; decodeResult should surface + // a single, consistent "Invalid prefix" error instead. + expect(() => add.decodeResult('0x')).toThrow(/Invalid prefix for Counter\.Add result/); + expect(() => add.decodeResult('0x01')).toThrow(/Invalid prefix for Counter\.Add result/); + }); + + test('decodePayload validates service/function prefix', () => { + const add = sails.services.Counter.functions.Add; + const mismatched = sails.registry.createType('(String, String)', ['WrongService', 'Add']).toHex(); + expect(() => add.decodePayload(mismatched)).toThrow(/Invalid prefix for Counter\.Add/); + expect(() => add.decodePayload('0x')).toThrow(/Invalid prefix for Counter\.Add/); + }); + + test('ctor decodePayload validates constructor prefix', () => { + const { New } = sails.ctors; + const mismatched = sails.registry.createType('String', 'Default').toHex(); + expect(() => New.decodePayload(mismatched)).toThrow(/Invalid prefix for constructor "New"/); + expect(() => New.decodePayload('0x')).toThrow(/Invalid prefix for constructor "New"/); + }); + + test('event decode validates service/event prefix', () => { + const added = sails.services.Counter.events.Added; + const mismatched = sails.registry.createType('(String, String)', ['Counter', 'WrongEvent']).toHex(); + expect(() => added.decode(mismatched)).toThrow(/Invalid prefix for Counter\.Added/); + }); + + test('decodePayload accepts Uint8Array inputs (back-compat with as any callers)', () => { + const walk = sails.services.Dog.functions.Walk; + // Round-trip via Uint8Array rather than HexString — this flow was valid before + // the prefix guards were added (registry.createType accepts Uint8Array). + const walkHex = walk.encodePayload(10, 10); + const walkBytes = Buffer.from(walkHex.slice(2), 'hex'); + const decoded = walk.decodePayload(walkBytes as unknown as `0x${string}`); + expect(decoded).toEqual({ dx: 10, dy: 10 }); + }); + + test('error messages sanitize non-printable prefix content', () => { + const add = sails.services.Counter.functions.Add; + // Craft a prefix with embedded control characters (ESC + BEL). Our error + // should replace them with "?" rather than pipe them into logs verbatim. + const esc = String.fromCodePoint(0x1B); + const bel = String.fromCodePoint(0x07); + const mismatched = sails.registry + .createType('(String, String)', [`Counter${esc}[31m`, `Add${bel}`]) + .toHex(); + try { + add.decodePayload(mismatched); + throw new Error('expected decodePayload to throw'); + } catch (error) { + const msg = (error as Error).message; + expect(msg).toContain('Invalid prefix for Counter.Add'); + expect(msg).not.toContain(esc); + expect(msg).not.toContain(bel); + } + }); }); diff --git a/js/test/idl-v2-parser-type-resolver.test.ts b/js/test/idl-v2-parser-type-resolver.test.ts index 6cd6fba93..ba6fbf2d2 100644 --- a/js/test/idl-v2-parser-type-resolver.test.ts +++ b/js/test/idl-v2-parser-type-resolver.test.ts @@ -1,4 +1,5 @@ import { SailsIdlParser } from 'sails-js-parser-idl-v2'; +import { hexToU8a } from '@polkadot/util'; import { SailsProgram } from '..'; @@ -341,3 +342,58 @@ describe('type-resolver-v2 generics', () => { ).toEqual({ three: { p1: arrayTupleU8, p2: [['a', 'b', 'c', 'd'], null] } }); }); }); + +describe('v2 decodeResult header validation', () => { + const idl = ` + service Counter { + functions { + @entry-id: 0 + Add(value: u32) -> u32; + @entry-id: 1 + Sub(value: u32) -> u32; + } + } + + program CounterProgram { + services { + Counter, + } + constructors { + Default(); + } + } + `; + + test('decodes result when header matches the expected method', () => { + const program = new SailsProgram(parser.parse(idl)); + const add = program.services.Counter.functions.Add; + // Extract Add's valid 16-byte header from a request payload, then build a reply + // with the same header followed by a u32 return value. + const addHeader = hexToU8a(add.encodePayload(42)).slice(0, 16); + const reply = program.registry.createType('([u8; 16], u32)', [addHeader, 99]).toHex(); + expect(add.decodeResult(reply)).toBe(99); + }); + + test('throws when result bytes have no valid Sails header', () => { + const program = new SailsProgram(parser.parse(idl)); + const add = program.services.Counter.functions.Add; + // 16 zero bytes (no magic "GM") + a u32 — should fail header assertion. + const bogusResult = program.registry + .createType('([u8; 16], u32)', [new Uint8Array(16), 99]) + .toHex(); + expect(() => add.decodeResult(bogusResult)).toThrow(/Invalid Sails header/); + }); + + test("throws when header belongs to a different method's entry_id", () => { + const program = new SailsProgram(parser.parse(idl)); + const add = program.services.Counter.functions.Add; + const sub = program.services.Counter.functions.Sub; + // Take Sub's (valid) 16-byte header from an encoded request payload. + // encodePayload(42) returns hex of ([u8; 16], u32); the first 16 bytes are Sub's header. + const subEncodedBytes = hexToU8a(sub.encodePayload(42)); + const subHeader = subEncodedBytes.slice(0, 16); + // Use that header as the prefix for an "Add result" — interface_id matches but entry_id differs. + const mismatchedResult = program.registry.createType('([u8; 16], u32)', [subHeader, 99]).toHex(); + expect(() => add.decodeResult(mismatchedResult)).toThrow(/Header mismatch/); + }); +});