Skip to content
Open
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
13 changes: 13 additions & 0 deletions js/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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).
Expand Down
20 changes: 20 additions & 0 deletions js/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Comment thread
ukint-vs marked this conversation as resolved.

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

Expand Down
18 changes: 18 additions & 0 deletions js/READMEV2.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
1 change: 1 addition & 0 deletions js/src/sails-idl-v2.ts
Original file line number Diff line number Diff line change
Expand Up @@ -487,6 +487,7 @@ export class SailsService implements ISailsService {
return result as T;
},
decodeResult: <T = any>(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;
},
Expand Down
52 changes: 51 additions & 1 deletion js/src/sails.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -240,6 +286,7 @@ export class Sails {
return payload.toHex();
},
decodePayload: <T = any>(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<string, any>;
for (const [i, param] of params.entries()) {
Expand All @@ -248,6 +295,7 @@ export class Sails {
return result as T;
},
decodeResult: <T = any>(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;
},
Expand Down Expand Up @@ -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();
},
Expand Down Expand Up @@ -366,6 +415,7 @@ export class Sails {
return payload.toHex();
},
decodePayload: <T = any>(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<string, any>;
for (const [i, param] of params.entries()) {
Expand Down
70 changes: 70 additions & 0 deletions js/test/encode-decode.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
});
});
56 changes: 56 additions & 0 deletions js/test/idl-v2-parser-type-resolver.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { SailsIdlParser } from 'sails-js-parser-idl-v2';
import { hexToU8a } from '@polkadot/util';

import { SailsProgram } from '..';

Expand Down Expand Up @@ -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/);
});
});
Loading