Skip to content

Commit 2f52d0e

Browse files
authored
Enhance identifier and string literal sanitization in BindingGenerator (#1345)
* Add tests for escaping line terminators and backslashes in string literals
1 parent 2bd8874 commit 2f52d0e

File tree

5 files changed

+183
-15
lines changed

5 files changed

+183
-15
lines changed

CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,9 @@ A breaking change will get clearly marked in this log.
66

77
## Unreleased
88

9+
### Fixed
10+
* 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)).
11+
912
## [v14.6.1](https://github.com/stellar/js-stellar-sdk/compare/v14.6.0...v14.6.1)
1013

1114
### Fixed

src/bindings/client.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -99,7 +99,7 @@ export class Client extends ContractClient {
9999
private generateInterfaceMethod(func: xdr.ScSpecFunctionV0): string {
100100
const name = sanitizeIdentifier(func.name().toString());
101101
const inputs = func.inputs().map((input: any) => ({
102-
name: input.name().toString(),
102+
name: sanitizeIdentifier(input.name().toString()),
103103
type: parseTypeFromTypeDef(input.type(), true),
104104
}));
105105
const outputType =
@@ -113,7 +113,7 @@ export class Client extends ContractClient {
113113
}
114114

115115
private generateFromJSONMethod(func: xdr.ScSpecFunctionV0): string {
116-
const name = func.name().toString();
116+
const name = sanitizeIdentifier(func.name().toString());
117117
const outputType =
118118
func.outputs().length > 0
119119
? parseTypeFromTypeDef(func.outputs()[0])
@@ -135,7 +135,7 @@ export class Client extends ContractClient {
135135
}`;
136136
}
137137
const inputs = constructorFunc.inputs().map((input) => ({
138-
name: input.name().toString(),
138+
name: sanitizeIdentifier(input.name().toString()),
139139
type: parseTypeFromTypeDef(input.type(), true),
140140
}));
141141

src/bindings/types.ts

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import {
44
parseTypeFromTypeDef,
55
generateTypeImports,
66
sanitizeIdentifier,
7+
escapeStringLiteral,
78
formatJSDocComment,
89
formatImports,
910
isTupleStruct,
@@ -137,7 +138,7 @@ export class TypeGenerator {
137138
const fields = struct
138139
.fields()
139140
.map((field) => {
140-
const fieldName = field.name().toString();
141+
const fieldName = sanitizeIdentifier(field.name().toString());
141142
const fieldType = parseTypeFromTypeDef(field.type());
142143
const fieldDoc = formatJSDocComment(field.doc().toString(), 2);
143144

@@ -166,9 +167,9 @@ ${fields}
166167
const caseTypes = cases
167168
.map((c) => {
168169
if (c.types.length > 0) {
169-
return `${formatJSDocComment(c.doc, 2)} { tag: "${c.name}"; values: readonly [${c.types.join(", ")}] }`;
170+
return `${formatJSDocComment(c.doc, 2)} { tag: "${escapeStringLiteral(c.name)}"; values: readonly [${c.types.join(", ")}] }`;
170171
}
171-
return `${formatJSDocComment(c.doc, 2)} { tag: "${c.name}"; values: void }`;
172+
return `${formatJSDocComment(c.doc, 2)} { tag: "${escapeStringLiteral(c.name)}"; values: void }`;
172173
})
173174
.join(" |\n");
174175

@@ -189,7 +190,7 @@ ${caseTypes};`;
189190
const members = enumEntry
190191
.cases()
191192
.map((enumCase) => {
192-
const caseName = enumCase.name().toString();
193+
const caseName = sanitizeIdentifier(enumCase.name().toString());
193194
const caseValue = enumCase.value();
194195
const caseDoc = enumCase.doc().toString() || `Enum Case: ${caseName}`;
195196

@@ -217,7 +218,7 @@ ${members}
217218

218219
const members = cases
219220
.map((c) => {
220-
return `${formatJSDocComment(c.doc, 2)} ${c.value} : { message: "${c.name}" }`;
221+
return `${formatJSDocComment(c.doc, 2)} ${c.value} : { message: "${escapeStringLiteral(c.name)}" }`;
221222
})
222223
.join(",\n");
223224

src/bindings/utils.ts

Lines changed: 26 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -63,17 +63,36 @@ export function isNameReserved(name: string): boolean {
6363
* @returns The sanitized identifier
6464
*/
6565
export function sanitizeIdentifier(identifier: string): string {
66-
if (isNameReserved(identifier)) {
67-
// Append underscore to reserved
68-
return identifier + "_";
66+
// Strip any characters outside the ASCII identifier-safe set [a-zA-Z0-9_$]
67+
const sanitized = identifier.replace(/[^a-zA-Z0-9_$]/g, "_");
68+
69+
if (isNameReserved(sanitized)) {
70+
return sanitized + "_";
71+
}
72+
73+
if (/^\d/.test(sanitized)) {
74+
return "_" + sanitized;
6975
}
7076

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

76-
return identifier;
82+
return sanitized;
83+
}
84+
85+
/**
86+
* Escape a string for safe interpolation inside a double-quoted JavaScript string literal.
87+
*/
88+
export function escapeStringLiteral(str: string): string {
89+
return str
90+
.replace(/\\/g, "\\\\")
91+
.replace(/"/g, '\\"')
92+
.replace(/\n/g, "\\n")
93+
.replace(/\r/g, "\\r")
94+
.replace(/\u2028/g, "\\u2028")
95+
.replace(/\u2029/g, "\\u2029");
7796
}
7897

7998
/**

test/integration/bindings.test.ts

Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1024,6 +1024,151 @@ describe("BindingGenerator", () => {
10241024
});
10251025
});
10261026

1027+
describe("generate - identifier and string literal sanitization", () => {
1028+
it("escapes double quotes in error enum case names", () => {
1029+
const errorSpec = xdr.ScSpecEntry.scSpecEntryUdtErrorEnumV0(
1030+
new xdr.ScSpecUdtErrorEnumV0({
1031+
doc: "",
1032+
lib: "",
1033+
name: "Errors",
1034+
cases: [
1035+
new xdr.ScSpecUdtErrorEnumCaseV0({
1036+
doc: "",
1037+
name: 'has "quotes" inside',
1038+
value: 0,
1039+
}),
1040+
],
1041+
}),
1042+
);
1043+
const spec = new contract.Spec([errorSpec.toXDR("base64")]);
1044+
const result = BindingGenerator.fromSpec(spec).generate(defaultOptions);
1045+
1046+
expect(result.types).toContain("export const Errors");
1047+
// Double quotes should be escaped in the string literal
1048+
expect(result.types).toContain('has \\"quotes\\" inside');
1049+
});
1050+
1051+
it("strips non-identifier characters from struct field names", () => {
1052+
const structSpec = createStructSpec("MyStruct", [
1053+
{
1054+
name: "field;name{bad}",
1055+
type: xdr.ScSpecTypeDef.scSpecTypeU32(),
1056+
},
1057+
]);
1058+
const spec = new contract.Spec([structSpec.toXDR("base64")]);
1059+
const result = BindingGenerator.fromSpec(spec).generate(defaultOptions);
1060+
1061+
// Special characters should be replaced with underscores
1062+
expect(result.types).toContain("field_name_bad_: number");
1063+
expect(result.types).toContain("export interface MyStruct");
1064+
});
1065+
1066+
it("escapes special characters in union case tag strings", () => {
1067+
const unionSpec = xdr.ScSpecEntry.scSpecEntryUdtUnionV0(
1068+
new xdr.ScSpecUdtUnionV0({
1069+
doc: "",
1070+
lib: "",
1071+
name: "MyUnion",
1072+
cases: [
1073+
xdr.ScSpecUdtUnionCaseV0.scSpecUdtUnionCaseVoidV0(
1074+
new xdr.ScSpecUdtUnionCaseVoidV0({
1075+
doc: "",
1076+
name: 'case"with"quotes',
1077+
}),
1078+
),
1079+
],
1080+
}),
1081+
);
1082+
const spec = new contract.Spec([unionSpec.toXDR("base64")]);
1083+
const result = BindingGenerator.fromSpec(spec).generate(defaultOptions);
1084+
1085+
expect(result.types).toContain('case\\"with\\"quotes');
1086+
expect(result.types).toContain("export type MyUnion");
1087+
});
1088+
1089+
it("strips non-identifier characters from enum case names", () => {
1090+
const enumSpec = xdr.ScSpecEntry.scSpecEntryUdtEnumV0(
1091+
new xdr.ScSpecUdtEnumV0({
1092+
doc: "",
1093+
lib: "",
1094+
name: "MyEnum",
1095+
cases: [
1096+
new xdr.ScSpecUdtEnumCaseV0({
1097+
doc: "",
1098+
name: "Case = 0; extra",
1099+
value: 0,
1100+
}),
1101+
],
1102+
}),
1103+
);
1104+
const spec = new contract.Spec([enumSpec.toXDR("base64")]);
1105+
const result = BindingGenerator.fromSpec(spec).generate(defaultOptions);
1106+
1107+
expect(result.types).toContain("Case___0__extra = 0");
1108+
expect(result.types).toContain("export enum MyEnum");
1109+
});
1110+
1111+
it("escapes JS line terminators U+2028 and U+2029 in string literals", () => {
1112+
const errorSpec = xdr.ScSpecEntry.scSpecEntryUdtErrorEnumV0(
1113+
new xdr.ScSpecUdtErrorEnumV0({
1114+
doc: "",
1115+
lib: "",
1116+
name: "Errors",
1117+
cases: [
1118+
new xdr.ScSpecUdtErrorEnumCaseV0({
1119+
doc: "",
1120+
name: "line\u2028sep\u2029end",
1121+
value: 0,
1122+
}),
1123+
],
1124+
}),
1125+
);
1126+
const spec = new contract.Spec([errorSpec.toXDR("base64")]);
1127+
const result = BindingGenerator.fromSpec(spec).generate(defaultOptions);
1128+
1129+
expect(result.types).toContain("\\u2028");
1130+
expect(result.types).toContain("\\u2029");
1131+
// Raw line terminators should not appear in the output
1132+
expect(result.types).not.toContain("\u2028");
1133+
expect(result.types).not.toContain("\u2029");
1134+
});
1135+
1136+
it("escapes backslashes in string literals", () => {
1137+
const errorSpec = xdr.ScSpecEntry.scSpecEntryUdtErrorEnumV0(
1138+
new xdr.ScSpecUdtErrorEnumV0({
1139+
doc: "",
1140+
lib: "",
1141+
name: "Errors",
1142+
cases: [
1143+
new xdr.ScSpecUdtErrorEnumCaseV0({
1144+
doc: "",
1145+
name: "path\\to\\file",
1146+
value: 0,
1147+
}),
1148+
],
1149+
}),
1150+
);
1151+
const spec = new contract.Spec([errorSpec.toXDR("base64")]);
1152+
const result = BindingGenerator.fromSpec(spec).generate(defaultOptions);
1153+
1154+
// Backslashes should be double-escaped
1155+
expect(result.types).toContain("path\\\\to\\\\file");
1156+
});
1157+
1158+
it("falls back to _unnamed for identifiers with only special characters", () => {
1159+
const structSpec = createStructSpec("MyStruct", [
1160+
{
1161+
name: '";{}',
1162+
type: xdr.ScSpecTypeDef.scSpecTypeU32(),
1163+
},
1164+
]);
1165+
const spec = new contract.Spec([structSpec.toXDR("base64")]);
1166+
const result = BindingGenerator.fromSpec(spec).generate(defaultOptions);
1167+
1168+
expect(result.types).toContain("_unnamed: number");
1169+
});
1170+
});
1171+
10271172
describe("generate - full contract scenario", () => {
10281173
it("generates complete bindings for token-like contract", () => {
10291174
const specs = [

0 commit comments

Comments
 (0)