Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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 CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@ A breaking change will get clearly marked in this log.

## Unreleased

### 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)).

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

### Fixed
Expand Down
6 changes: 3 additions & 3 deletions src/bindings/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,7 @@ export class Client extends ContractClient {
private generateInterfaceMethod(func: xdr.ScSpecFunctionV0): string {
const name = sanitizeIdentifier(func.name().toString());
const inputs = func.inputs().map((input: any) => ({
name: input.name().toString(),
name: sanitizeIdentifier(input.name().toString()),
type: parseTypeFromTypeDef(input.type(), true),
}));
Comment thread
Ryang-21 marked this conversation as resolved.
const outputType =
Expand All @@ -113,7 +113,7 @@ export class Client extends ContractClient {
}

private generateFromJSONMethod(func: xdr.ScSpecFunctionV0): string {
const name = func.name().toString();
const name = sanitizeIdentifier(func.name().toString());
const outputType =
func.outputs().length > 0
? parseTypeFromTypeDef(func.outputs()[0])
Expand All @@ -135,7 +135,7 @@ export class Client extends ContractClient {
}`;
}
const inputs = constructorFunc.inputs().map((input) => ({
name: input.name().toString(),
name: sanitizeIdentifier(input.name().toString()),
type: parseTypeFromTypeDef(input.type(), true),
}));

Expand Down
11 changes: 6 additions & 5 deletions src/bindings/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
parseTypeFromTypeDef,
generateTypeImports,
sanitizeIdentifier,
escapeStringLiteral,
formatJSDocComment,
formatImports,
isTupleStruct,
Expand Down Expand Up @@ -137,7 +138,7 @@ export class TypeGenerator {
const fields = struct
.fields()
.map((field) => {
const fieldName = field.name().toString();
const fieldName = sanitizeIdentifier(field.name().toString());
const fieldType = parseTypeFromTypeDef(field.type());
const fieldDoc = formatJSDocComment(field.doc().toString(), 2);

Expand Down Expand Up @@ -166,9 +167,9 @@ ${fields}
const caseTypes = cases
.map((c) => {
if (c.types.length > 0) {
return `${formatJSDocComment(c.doc, 2)} { tag: "${c.name}"; values: readonly [${c.types.join(", ")}] }`;
return `${formatJSDocComment(c.doc, 2)} { tag: "${escapeStringLiteral(c.name)}"; values: readonly [${c.types.join(", ")}] }`;
}
return `${formatJSDocComment(c.doc, 2)} { tag: "${c.name}"; values: void }`;
return `${formatJSDocComment(c.doc, 2)} { tag: "${escapeStringLiteral(c.name)}"; values: void }`;
})
.join(" |\n");

Expand All @@ -189,7 +190,7 @@ ${caseTypes};`;
const members = enumEntry
.cases()
.map((enumCase) => {
const caseName = enumCase.name().toString();
const caseName = sanitizeIdentifier(enumCase.name().toString());
const caseValue = enumCase.value();
const caseDoc = enumCase.doc().toString() || `Enum Case: ${caseName}`;

Expand Down Expand Up @@ -217,7 +218,7 @@ ${members}

const members = cases
.map((c) => {
return `${formatJSDocComment(c.doc, 2)} ${c.value} : { message: "${c.name}" }`;
return `${formatJSDocComment(c.doc, 2)} ${c.value} : { message: "${escapeStringLiteral(c.name)}" }`;
})
.join(",\n");

Expand Down
31 changes: 24 additions & 7 deletions src/bindings/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,17 +63,34 @@ export function isNameReserved(name: string): boolean {
* @returns The sanitized identifier
*/
export function sanitizeIdentifier(identifier: string): string {
if (isNameReserved(identifier)) {
// Append underscore to reserved
return identifier + "_";
// Strip any characters that are not valid in JS/TS identifiers
Copy link

Copilot AI Mar 4, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The comment says this strips characters that are not valid JS/TS identifier characters, but the implementation only allows ASCII [a-zA-Z0-9_$] and replaces everything else (including many valid Unicode identifier characters) with _. Either adjust the comment to reflect the ASCII-only behavior, or consider a Unicode-aware identifier check if preserving valid Unicode identifiers is desired.

Suggested change
// Strip any characters that are not valid in JS/TS identifiers
// Strip any characters that are not allowed by this ASCII-based identifier pattern

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Comment seems vaid.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The Soroban rust sdk only supports ascii identifiers so I think we should only cater to that encoding

const sanitized = identifier.replace(/[^a-zA-Z0-9_$]/g, "_");

if (isNameReserved(sanitized)) {
return sanitized + "_";
}

if (/^\d/.test(sanitized)) {
return "_" + sanitized;
}

if (/^\d/.test(identifier)) {
// Prefix leading digit with underscore
return "_" + identifier;
// If the identifier was entirely special characters, provide a fallback
if (sanitized === "" || /^_+$/.test(sanitized)) {
return "_unnamed";
}

return identifier;
return sanitized;
}

/**
* Escape a string for safe interpolation inside a double-quoted JavaScript string literal.
*/
export function escapeStringLiteral(str: string): string {
return str
.replace(/\\/g, "\\\\")
.replace(/"/g, '\\"')
.replace(/\n/g, "\\n")
.replace(/\r/g, "\\r");
Comment thread
Ryang-21 marked this conversation as resolved.
Outdated
}

/**
Expand Down
98 changes: 98 additions & 0 deletions test/integration/bindings.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1024,6 +1024,104 @@ describe("BindingGenerator", () => {
});
});

describe("generate - identifier and string literal sanitization", () => {
it("escapes double quotes in error enum case names", () => {
const errorSpec = xdr.ScSpecEntry.scSpecEntryUdtErrorEnumV0(
new xdr.ScSpecUdtErrorEnumV0({
doc: "",
lib: "",
name: "Errors",
cases: [
new xdr.ScSpecUdtErrorEnumCaseV0({
doc: "",
name: 'has "quotes" inside',
value: 0,
}),
],
}),
);
const spec = new contract.Spec([errorSpec.toXDR("base64")]);
const result = BindingGenerator.fromSpec(spec).generate(defaultOptions);

expect(result.types).toContain("export const Errors");
// Double quotes should be escaped in the string literal
expect(result.types).toContain('has \\"quotes\\" inside');
});

it("strips non-identifier characters from struct field names", () => {
const structSpec = createStructSpec("MyStruct", [
{
name: "field;name{bad}",
type: xdr.ScSpecTypeDef.scSpecTypeU32(),
},
]);
const spec = new contract.Spec([structSpec.toXDR("base64")]);
const result = BindingGenerator.fromSpec(spec).generate(defaultOptions);

// Special characters should be replaced with underscores
expect(result.types).toContain("field_name_bad_: number");
expect(result.types).toContain("export interface MyStruct");
});

it("escapes special characters in union case tag strings", () => {
const unionSpec = xdr.ScSpecEntry.scSpecEntryUdtUnionV0(
new xdr.ScSpecUdtUnionV0({
doc: "",
lib: "",
name: "MyUnion",
cases: [
xdr.ScSpecUdtUnionCaseV0.scSpecUdtUnionCaseVoidV0(
new xdr.ScSpecUdtUnionCaseVoidV0({
doc: "",
name: 'case"with"quotes',
}),
),
],
}),
);
const spec = new contract.Spec([unionSpec.toXDR("base64")]);
const result = BindingGenerator.fromSpec(spec).generate(defaultOptions);

expect(result.types).toContain('case\\"with\\"quotes');
expect(result.types).toContain("export type MyUnion");
});

it("strips non-identifier characters from enum case names", () => {
const enumSpec = xdr.ScSpecEntry.scSpecEntryUdtEnumV0(
new xdr.ScSpecUdtEnumV0({
doc: "",
lib: "",
name: "MyEnum",
cases: [
new xdr.ScSpecUdtEnumCaseV0({
doc: "",
name: "Case = 0; extra",
value: 0,
}),
],
}),
);
const spec = new contract.Spec([enumSpec.toXDR("base64")]);
const result = BindingGenerator.fromSpec(spec).generate(defaultOptions);

expect(result.types).toContain("Case___0__extra = 0");
expect(result.types).toContain("export enum MyEnum");
});

it("falls back to _unnamed for identifiers with only special characters", () => {
const structSpec = createStructSpec("MyStruct", [
{
name: '";{}',
type: xdr.ScSpecTypeDef.scSpecTypeU32(),
},
]);
const spec = new contract.Spec([structSpec.toXDR("base64")]);
const result = BindingGenerator.fromSpec(spec).generate(defaultOptions);

expect(result.types).toContain("_unnamed: number");
});
});

describe("generate - full contract scenario", () => {
it("generates complete bindings for token-like contract", () => {
const specs = [
Expand Down
Loading