Skip to content

Commit a1c92bd

Browse files
committed
Escape */ sequences in generated JSDoc to prevent comment injection
OpenAPI spec fields (descriptions, titles, examples, patterns, etc.) are untrusted input that may contain `*/` sequences. When embedded in JSDoc block comments, these sequences prematurely close the comment, producing invalid TypeScript output. Add `escapeJSDocContent()` to SchemaFormatters that replaces `*/` with `*\/` and expose it as a template utility. Apply escaping consistently across all JSDoc-emitting templates (data-contract-jsdoc, object-field-jsdoc, route-docs, api) and enum field descriptions. Closes #1321
1 parent 4685dd1 commit a1c92bd

11 files changed

Lines changed: 944 additions & 66 deletions

File tree

.changeset/late-bottles-reply.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"swagger-typescript-api": patch
3+
---
4+
5+
Escape `*/` sequences in generated JSDoc content to prevent comment injection from OpenAPI fields.

src/code-gen-process.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -235,6 +235,8 @@ export class CodeGenProcess {
235235
Ts: this.config.Ts,
236236
formatDescription:
237237
this.schemaParserFabric.schemaFormatters.formatDescription,
238+
escapeJSDocContent:
239+
this.schemaParserFabric.schemaFormatters.escapeJSDocContent,
238240
internalCase: camelCase,
239241
classNameCase: pascalCase,
240242
pascalCase: pascalCase,

src/schema-parser/schema-formatters.ts

Lines changed: 26 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -30,10 +30,17 @@ export class SchemaFormatters {
3030
};
3131
}
3232

33+
const escapedContent = parsedSchema.content.map((item) => ({
34+
...item,
35+
description: item.description
36+
? this.escapeJSDocContent(item.description)
37+
: "",
38+
}));
39+
3340
return {
3441
...parsedSchema,
3542
$content: parsedSchema.content,
36-
content: this.config.Ts.EnumFieldsWrapper(parsedSchema.content),
43+
content: this.config.Ts.EnumFieldsWrapper(escapedContent),
3744
};
3845
},
3946
[SCHEMA_TYPES.OBJECT]: (parsedSchema) => {
@@ -103,20 +110,31 @@ export class SchemaFormatters {
103110
return formatterFn?.(parsedSchema) || parsedSchema;
104111
};
105112

106-
formatDescription = (description: string | undefined, inline?: boolean) => {
113+
// OpenAPI fields are untrusted input that may contain `*/` which would
114+
// prematurely close JSDoc block comments in generated TypeScript output.
115+
// Note: only `undefined` maps to empty string; `null` is preserved as "null"
116+
// because `@default null` is a valid JSDoc annotation for nullable fields.
117+
escapeJSDocContent = (content: unknown): string => {
118+
if (content === undefined) return "";
119+
const str = typeof content === "string" ? content : String(content);
120+
return str.replace(/\*\//g, "*\\/");
121+
};
122+
123+
formatDescription = (description: string | undefined, inline?: boolean): string => {
107124
if (!description) return "";
108125

109-
const hasMultipleLines = description.includes("\n");
126+
const escapedDescription = this.escapeJSDocContent(description);
127+
const hasMultipleLines = escapedDescription.includes("\n");
110128

111-
if (!hasMultipleLines) return description;
129+
if (!hasMultipleLines) return escapedDescription;
112130

113131
if (inline) {
114-
return compact(description.split(/\n/g).map((part) => part.trim())).join(
115-
" ",
116-
);
132+
return compact(
133+
escapedDescription.split(/\n/g).map((part) => part.trim()),
134+
).join(" ");
117135
}
118136

119-
return description.replace(/\n$/g, "");
137+
return escapedDescription.replace(/\n$/g, "");
120138
};
121139

122140
formatObjectContent = (content) => {

templates/base/data-contract-jsdoc.ejs

Lines changed: 16 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,27 +1,27 @@
11
<%
22
const { data, utils } = it;
3-
const { formatDescription, require, _ } = utils;
3+
const { formatDescription, escapeJSDocContent, require, _ } = utils;
44
55
const stringify = (value) => _.isObject(value) ? JSON.stringify(value) : _.isString(value) ? `"${value}"` : value;
66
77
const jsDocLines = _.compact([
8-
data.title,
8+
data.title && formatDescription(data.title),
99
data.description && formatDescription(data.description),
1010
!_.isUndefined(data.deprecated) && data.deprecated && '@deprecated',
11-
!_.isUndefined(data.format) && `@format ${data.format}`,
12-
!_.isUndefined(data.minimum) && `@min ${data.minimum}`,
13-
!_.isUndefined(data.multipleOf) && `@multipleOf ${data.multipleOf}`,
14-
!_.isUndefined(data.exclusiveMinimum) && `@exclusiveMin ${data.exclusiveMinimum}`,
15-
!_.isUndefined(data.maximum) && `@max ${data.maximum}`,
16-
!_.isUndefined(data.minLength) && `@minLength ${data.minLength}`,
17-
!_.isUndefined(data.maxLength) && `@maxLength ${data.maxLength}`,
18-
!_.isUndefined(data.exclusiveMaximum) && `@exclusiveMax ${data.exclusiveMaximum}`,
19-
!_.isUndefined(data.maxItems) && `@maxItems ${data.maxItems}`,
20-
!_.isUndefined(data.minItems) && `@minItems ${data.minItems}`,
21-
!_.isUndefined(data.uniqueItems) && `@uniqueItems ${data.uniqueItems}`,
22-
!_.isUndefined(data.default) && `@default ${stringify(data.default)}`,
23-
!_.isUndefined(data.pattern) && `@pattern ${data.pattern}`,
24-
!_.isUndefined(data.example) && `@example ${stringify(data.example)}`
11+
!_.isUndefined(data.format) && `@format ${escapeJSDocContent(data.format)}`,
12+
!_.isUndefined(data.minimum) && `@min ${escapeJSDocContent(data.minimum)}`,
13+
!_.isUndefined(data.multipleOf) && `@multipleOf ${escapeJSDocContent(data.multipleOf)}`,
14+
!_.isUndefined(data.exclusiveMinimum) && `@exclusiveMin ${escapeJSDocContent(data.exclusiveMinimum)}`,
15+
!_.isUndefined(data.maximum) && `@max ${escapeJSDocContent(data.maximum)}`,
16+
!_.isUndefined(data.minLength) && `@minLength ${escapeJSDocContent(data.minLength)}`,
17+
!_.isUndefined(data.maxLength) && `@maxLength ${escapeJSDocContent(data.maxLength)}`,
18+
!_.isUndefined(data.exclusiveMaximum) && `@exclusiveMax ${escapeJSDocContent(data.exclusiveMaximum)}`,
19+
!_.isUndefined(data.maxItems) && `@maxItems ${escapeJSDocContent(data.maxItems)}`,
20+
!_.isUndefined(data.minItems) && `@minItems ${escapeJSDocContent(data.minItems)}`,
21+
!_.isUndefined(data.uniqueItems) && `@uniqueItems ${escapeJSDocContent(data.uniqueItems)}`,
22+
!_.isUndefined(data.default) && `@default ${escapeJSDocContent(stringify(data.default))}`,
23+
!_.isUndefined(data.pattern) && `@pattern ${escapeJSDocContent(data.pattern)}`,
24+
!_.isUndefined(data.example) && `@example ${escapeJSDocContent(stringify(data.example))}`
2525
]).join('\n').split('\n');
2626
%>
2727
<% if (jsDocLines.every(_.isEmpty)) { %>

templates/base/object-field-jsdoc.ejs

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,18 @@
11
<%
22
const { field, utils } = it;
3-
const { formatDescription, require, _ } = utils;
3+
const { formatDescription, escapeJSDocContent, require, _ } = utils;
44
55
const comments = _.uniq(
66
_.compact([
7-
field.title,
8-
field.description,
7+
field.title && formatDescription(field.title),
8+
field.description && formatDescription(field.description),
99
field.deprecated && ` * @deprecated`,
10-
!_.isUndefined(field.format) && `@format ${field.format}`,
11-
!_.isUndefined(field.minimum) && `@min ${field.minimum}`,
12-
!_.isUndefined(field.maximum) && `@max ${field.maximum}`,
13-
!_.isUndefined(field.pattern) && `@pattern ${field.pattern}`,
10+
!_.isUndefined(field.format) && `@format ${escapeJSDocContent(field.format)}`,
11+
!_.isUndefined(field.minimum) && `@min ${escapeJSDocContent(field.minimum)}`,
12+
!_.isUndefined(field.maximum) && `@max ${escapeJSDocContent(field.maximum)}`,
13+
!_.isUndefined(field.pattern) && `@pattern ${escapeJSDocContent(field.pattern)}`,
1414
!_.isUndefined(field.example) &&
15-
`@example ${_.isObject(field.example) ? JSON.stringify(field.example) : field.example}`,
15+
`@example ${escapeJSDocContent(_.isObject(field.example) ? JSON.stringify(field.example) : field.example)}`,
1616
]).reduce((acc, comment) => [...acc, ...comment.split(/\n/g)], []),
1717
);
1818
%>

templates/base/route-docs.ejs

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,24 @@
11
<%
22
const { config, route, utils } = it;
3-
const { _, formatDescription, fmtToJSDocLine, pascalCase, require } = utils;
3+
const { _, formatDescription, escapeJSDocContent, fmtToJSDocLine, pascalCase, require } = utils;
44
const { raw, request, routeName } = route;
55
66
const jsDocDescription = raw.description ?
77
` * @description ${formatDescription(raw.description, true)}` :
88
fmtToJSDocLine('No description', { eol: false });
99
const jsDocLines = _.compact([
10-
_.size(raw.tags) && ` * @tags ${raw.tags.join(", ")}`,
11-
` * @name ${pascalCase(routeName.usage)}`,
12-
raw.summary && ` * @summary ${raw.summary}`,
13-
` * @request ${_.upperCase(request.method)}:${raw.route}`,
10+
_.size(raw.tags) && ` * @tags ${raw.tags.map((tag) => formatDescription(tag, true)).join(", ")}`,
11+
` * @name ${escapeJSDocContent(pascalCase(routeName.usage))}`,
12+
raw.summary && ` * @summary ${formatDescription(raw.summary, true)}`,
13+
` * @request ${escapeJSDocContent(_.upperCase(request.method))}:${escapeJSDocContent(raw.route)}`,
1414
raw.deprecated && ` * @deprecated`,
15-
routeName.duplicate && ` * @originalName ${routeName.original}`,
15+
routeName.duplicate && ` * @originalName ${escapeJSDocContent(routeName.original)}`,
1616
routeName.duplicate && ` * @duplicate`,
1717
request.security && ` * @secure`,
1818
...(config.generateResponses && raw.responsesTypes.length
1919
? raw.responsesTypes.map(
2020
({ type, status, description, isSuccess }) =>
21-
` * @response \`${status}\` \`${_.replace(_.replace(type, /\/\*/g, "\\*"), /\*\//g, "*\\")}\` ${description}`,
21+
` * @response \`${escapeJSDocContent(status)}\` \`${escapeJSDocContent(type)}\` ${formatDescription(description, true)}`,
2222
)
2323
: []),
2424
]).map(str => str.trimEnd()).join("\n");

templates/default/api.ejs

Lines changed: 11 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,24 @@
11
<%
22
const { apiConfig, routes, utils, config } = it;
33
const { info, servers, externalDocs } = apiConfig;
4-
const { _, require, formatDescription } = utils;
4+
const { _, require, formatDescription, escapeJSDocContent } = utils;
55
66
const server = (servers && servers[0]) || { url: "" };
77
88
const descriptionLines = _.compact([
9-
`@title ${info.title || "No title"}`,
10-
info.version && `@version ${info.version}`,
9+
`@title ${escapeJSDocContent(info.title || "No title")}`,
10+
info.version && `@version ${escapeJSDocContent(info.version)}`,
1111
info.license && `@license ${_.compact([
12-
info.license.name,
13-
info.license.url && `(${info.license.url})`,
12+
info.license.name && escapeJSDocContent(info.license.name),
13+
info.license.url && `(${escapeJSDocContent(info.license.url)})`,
1414
]).join(" ")}`,
15-
info.termsOfService && `@termsOfService ${info.termsOfService}`,
16-
server.url && `@baseUrl ${server.url}`,
17-
externalDocs.url && `@externalDocs ${externalDocs.url}`,
15+
info.termsOfService && `@termsOfService ${escapeJSDocContent(info.termsOfService)}`,
16+
server.url && `@baseUrl ${escapeJSDocContent(server.url)}`,
17+
externalDocs.url && `@externalDocs ${escapeJSDocContent(externalDocs.url)}`,
1818
info.contact && `@contact ${_.compact([
19-
info.contact.name,
20-
info.contact.email && `<${info.contact.email}>`,
21-
info.contact.url && `(${info.contact.url})`,
19+
info.contact.name && escapeJSDocContent(info.contact.name),
20+
info.contact.email && `<${escapeJSDocContent(info.contact.email)}>`,
21+
info.contact.url && `(${escapeJSDocContent(info.contact.url)})`,
2222
]).join(" ")}`,
2323
info.description && " ",
2424
info.description && _.replace(formatDescription(info.description), /\n/g, "\n * "),

0 commit comments

Comments
 (0)