Skip to content

Commit e015939

Browse files
adamwclaudehughsimpson
authored
Fix code injection via unsanitized names in openapi-codegen (GHSA-gpcc-36pq-8qxr) (#5395)
## Summary The OpenAPI code generator (`tapir-openapi-codegen-core`, used by the sbt plugin and CLI) builds Scala source by string interpolation and emitted many names/values taken from the **input OpenAPI document** without encoding them. Because that document may be attacker-controlled (e.g. generating a client from a third-party spec), a crafted document could inject arbitrary Scala into the generated source — for example a property name turned into a case-class field with a code-running default (code execution at model construction / JSON decoding time), or a query-parameter name breaking out of its string literal. This is a **build/codegen-time** issue: it affects anyone generating code from an untrusted OpenAPI document. It is not exploitable against a running tapir server. Reported privately as **GHSA-gpcc-36pq-8qxr** (credit: @Gal3m, @mrostamipoor). ## Fix Two encoding disciplines, applied consistently across the generator: - **Identifier positions — reject unsafe names.** `safeVariableName` and a shared enum-member helper now reject backticks and control characters (a backtick-quoted Scala identifier cannot contain either, so these previously allowed a break-out). A new `NameValidation` pass rejects, at ingestion, schema names, `$ref` targets, object property names, and (when used for object names) tags that fall outside the OpenAPI-permitted `[A-Za-z0-9._-]` character set. - **String-literal positions — escape values.** Parameter/path/XML names, descriptions, discriminator property names and mapping values, default values, spec-extension values, OAuth URLs, apiKey names and media-type strings are now escaped via `JavaEscape.escapeString` (Circe, Zio and Jsoniter serde generators). - **Special cases.** `param.in` and apiKey `in` are validated against the allowed locations (emitted as raw method names); server URLs are validated (they feed both an identifier and a `uri"..."` interpolator); and a `*/` in a server description can no longer close the emitted block comment early. Values that legitimately contain special characters (URLs, descriptions, wire-level parameter names) are escaped rather than rejected, so valid specifications continue to generate. ## Tests Adds `InjectionSecuritySpec` covering the reported and adjacent vectors (schema-name, property-name and enum-value rejection; parameter-name escaping; `in`-location rejection). Full codegen-core suites pass on Scala 2.12 (81) and Scala 3 (87) with no regressions. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> Co-authored-by: hughsimpson <hsimpson@rzsoftware.com>
1 parent c2caf57 commit e015939

24 files changed

Lines changed: 700 additions & 89 deletions

openapi-codegen/core/src/main/scala/sttp/tapir/codegen/ClassDefinitionGenerator.scala

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import sttp.tapir.codegen.openapi.models.OpenapiModels.OpenapiDocument
99
import sttp.tapir.codegen.openapi.models.{DefaultValueRenderer, OpenapiSchemaType, RenderConfig}
1010
import sttp.tapir.codegen.openapi.models.OpenapiSchemaType._
1111
import sttp.tapir.codegen.util.NameHelpers.{indent, safeVariableName}
12-
import sttp.tapir.codegen.util.{DocUtils, VersionedHelpers}
12+
import sttp.tapir.codegen.util.{DocUtils, JavaEscape, NameValidation, VersionedHelpers}
1313
import sttp.tapir.codegen.xml.{XmlSerdeGenerator, XmlSerdeLib}
1414

1515
case class GeneratedClassDefinitions(
@@ -87,6 +87,7 @@ class ClassDefinitionGenerator {
8787
seperateFilesForModels: Boolean = false,
8888
alwaysGenerateParamSupport: Boolean = false
8989
): Option[GeneratedClassDefinitions] = {
90+
NameValidation.validateDocumentNames(doc, useHeadTagForObjectNames = false)
9091
val allSchemas: Map[String, OpenapiSchemaType] = doc.components.toSeq.flatMap(_.schemas).toMap
9192
val allOneOfSchemas = allSchemas.collect { case (name, oneOf: OpenapiSchemaOneOf) => name -> oneOf }.toSeq
9293
val (allClassyOneOfSchemas, allOtherOneOfSchemas) = allOneOfSchemas.partition(_._2.types.forall {
@@ -463,7 +464,7 @@ class ClassDefinitionGenerator {
463464
val discriminatorDefBody = discriminatorDefFields.filter { case (n, _) => obj.properties.map(_._1).toSet.contains(n) } match {
464465
case Nil => ""
465466
case fields =>
466-
val fs = fields.map { case (k, v) => s"""def ${safeVariableName(k)}: String = "$v"""" }.mkString("\n")
467+
val fs = fields.map { case (k, v) => s"""def ${safeVariableName(k)}: String = "${JavaEscape.escapeString(v)}"""" }.mkString("\n")
467468
s""" {
468469
|${indent(2)(fs)}
469470
|}""".stripMargin

openapi-codegen/core/src/main/scala/sttp/tapir/codegen/RootGenerator.scala

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,9 @@ import sttp.tapir.codegen.openapi.models.OpenapiModels.OpenapiDocument
77
import sttp.tapir.codegen.openapi.models.OpenapiSchemaType._
88
import sttp.tapir.codegen.openapi.models.SpecificationExtensionRenderer
99
import sttp.tapir.codegen.security.SecurityGenerator
10+
import sttp.tapir.codegen.util.JavaEscape
1011
import sttp.tapir.codegen.util.NameHelpers
12+
import sttp.tapir.codegen.util.NameValidation
1113
import sttp.tapir.codegen.validation.{ValidationDefns, ValidationGenerator}
1214
import sttp.tapir.codegen.xml.{XmlSerdeGenerator, XmlSerdeLib}
1315

@@ -37,6 +39,7 @@ object RootGenerator {
3739
alwaysGenerateParamSupport: Boolean
3840
): GenerationInfo = {
3941
val doc = unNormalisedDoc.resolveAllOfSchemas
42+
NameValidation.validateDocumentNames(doc, useHeadTagForObjectNames)
4043
val normalisedJsonLib = jsonSerdeLib.toLowerCase match {
4144
case "circe" => JsonSerdeLib.Circe
4245
case "jsoniter" => JsonSerdeLib.Jsoniter
@@ -253,8 +256,9 @@ object RootGenerator {
253256
.filterNot(expectedTypes.contains)
254257
.map {
255258
case ct @ mediaType(mainType, subType) =>
256-
s"""case class `${ct}CodecFormat`() extends CodecFormat {
257-
| override val mediaType: sttp.model.MediaType = sttp.model.MediaType.unsafeApply(mainType = "$mainType", subType = "$subType")
259+
s"""case class ${NameHelpers.codecFormatName(ct)}() extends CodecFormat {
260+
| override val mediaType: sttp.model.MediaType = sttp.model.MediaType.unsafeApply(mainType = "${JavaEscape
261+
.escapeString(mainType)}", subType = "${JavaEscape.escapeString(subType)}")
258262
|}""".stripMargin
259263
case ct => throw new NotImplementedError(s"Cannot handle content type '$ct'")
260264
}

openapi-codegen/core/src/main/scala/sttp/tapir/codegen/SchemaGenerator.scala

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import sttp.tapir.codegen.json.JsonSerdeLib.JsonSerdeLib
66
import sttp.tapir.codegen.openapi.models.OpenapiModels.OpenapiDocument
77
import sttp.tapir.codegen.openapi.models.OpenapiSchemaType
88
import sttp.tapir.codegen.openapi.models.OpenapiSchemaType._
9+
import sttp.tapir.codegen.util.JavaEscape
910
import sttp.tapir.codegen.util.NameHelpers.indent
1011

1112
import scala.collection.mutable
@@ -334,14 +335,15 @@ object SchemaGenerator {
334335
val fields = mapping
335336
.map { case (propValue, fullRef) =>
336337
val fullClassName = fullModelPrefix + fullRef
337-
s""""$propValue" -> sttp.tapir.SchemaType.SRef(sttp.tapir.Schema.SName("$fullClassName"))"""
338+
s""""${JavaEscape.escapeString(propValue)}" -> sttp.tapir.SchemaType.SRef(sttp.tapir.Schema.SName("${JavaEscape
339+
.escapeString(fullClassName)}"))"""
338340
}
339341
.mkString(",\n")
340342
s"""{
341343
| val derived = implicitly[sttp.tapir.generic.Derived[sttp.tapir.Schema[$name]]].value
342344
| derived.schemaType match {
343345
| case s: sttp.tapir.SchemaType.SCoproduct[_] => derived.copy(schemaType = s.addDiscriminatorField(
344-
| sttp.tapir.FieldName("$propertyName"),
346+
| sttp.tapir.FieldName("${JavaEscape.escapeString(propertyName)}"),
345347
| sttp.tapir.Schema.string,
346348
| Map(
347349
|${indent(8)(fields)}

openapi-codegen/core/src/main/scala/sttp/tapir/codegen/ServersGenerator.scala

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,20 @@ package sttp.tapir.codegen
22

33
import sttp.tapir.codegen.RootGenerator.indent
44
import sttp.tapir.codegen.openapi.models.OpenapiServer
5+
import sttp.tapir.codegen.util.JavaEscape
56
import sttp.tapir.codegen.util.NameHelpers.safeVariableName
67

78
object ServersGenerator {
89

10+
// The server URL is emitted both as a backtick-quoted identifier and inside a `uri"..."` interpolator (where a `$`
11+
// would itself interpolate). A legitimate server URL contains none of these characters, so reject any that does
12+
// rather than attempt to escape every context. See GHSA-gpcc-36pq-8qxr.
13+
private def validateServerUrl(url: String): Unit =
14+
if (url.exists(c => c == '"' || c == '`' || c == '\\' || c == '$' || c.isControl))
15+
throw new IllegalArgumentException(
16+
s"Unsafe server URL '$url': must not contain quotes, backticks, backslashes, '$$' or control characters (see GHSA-gpcc-36pq-8qxr)"
17+
)
18+
919
def genServerDefinitions(servers: Seq[OpenapiServer], isScala3: Boolean): Option[String] = if (servers.isEmpty) None
1020
else
1121
Some {
@@ -20,6 +30,7 @@ object ServersGenerator {
2030
|}""".stripMargin
2131
}
2232
private def genServerDefinition(server: OpenapiServer, isScala3: Boolean) = {
33+
validateServerUrl(server.url)
2334
if (server.variables.isEmpty) genServerUrlVal(server)
2435
else genServerDefinitionWithVariables(server, isScala3)
2536
}
@@ -31,7 +42,7 @@ object ServersGenerator {
3142
(
3243
safeVariableName(k),
3344
vs.`enum`.map(i => safeVariableName(i)),
34-
vs.default.map(v => if (vs.`enum`.isEmpty) '"' +: v :+ '"' else safeVariableName(v))
45+
vs.default.map(v => if (vs.`enum`.isEmpty) JavaEscape.quote(v) else safeVariableName(v))
3546
)
3647
}
3748
val enums = enumNames
@@ -79,6 +90,12 @@ object ServersGenerator {
7990
|}""".stripMargin
8091
}
8192
private def genDescription(description: Option[String]): String =
82-
description.map(d => s"/*\n${indent(2)(d)}\n*/\n").getOrElse("")
93+
// Neutralise the (untrusted) description before emitting it into a block comment: double every backslash so a
94+
// `*/` unicode escape cannot be reconstructed by Scala 2's unicode pre-scan (which runs before comment
95+
// lexing) into a `*/`, and textually break any literal `*/`. Either could otherwise close the comment early and
96+
// inject the code that follows. See GHSA-gpcc-36pq-8qxr.
97+
description
98+
.map(d => s"/*\n${indent(2)(d.replace("\\", "\\\\").replace("*/", "* /"))}\n*/\n")
99+
.getOrElse("")
83100

84101
}

openapi-codegen/core/src/main/scala/sttp/tapir/codegen/endpoints/EndpointGenerator.scala

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -308,8 +308,9 @@ class EndpointGenerator {
308308
}
309309

310310
private def tags(openapiTags: Option[Seq[String]]): String = {
311-
// .tags(List("A", "B"))
312-
openapiTags.map(_.distinct.mkString(".tags(List(\"", "\", \"", "\"))")).mkString
311+
// .tags(List("A", "B")) -- tag strings come from the untrusted document and are emitted into a string literal,
312+
// so escape each one (as the .name/.description sites do). See GHSA-gpcc-36pq-8qxr.
313+
openapiTags.map(_.distinct.map(JavaEscape.escapeString).mkString(".tags(List(\"", "\", \"", "\"))")).mkString
313314
}
314315

315316
private def attributes(atts: Map[String, Json]): Option[String] = if (atts.nonEmpty) Some {

openapi-codegen/core/src/main/scala/sttp/tapir/codegen/endpoints/EnumGenerator.scala

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,11 @@
11
package sttp.tapir.codegen.endpoints
22

3+
import sttp.tapir.codegen.util.NameHelpers
34
import sttp.tapir.codegen.util.NameHelpers.indent
45
import sttp.tapir.codegen.json.JsonSerdeLib
56
import sttp.tapir.codegen.openapi.models.OpenapiSchemaType.OpenapiSchemaEnum
67

78
object EnumGenerator {
8-
val legalEnumName = "([a-zA-Z][a-zA-Z0-9_]*)".r
99

1010
// Uses enumeratum for scala 2, but generates scala 3 enums instead where it can
1111
private[codegen] def generateEnum(
@@ -17,10 +17,9 @@ object EnumGenerator {
1717
jsonParamRefs: Set[String],
1818
alwaysGenerateParamSupport: Boolean
1919
): Seq[String] = {
20-
def maybeEscaped(s: String) = s match {
21-
case legalEnumName(l) => l
22-
case illegal => s"`$illegal`"
23-
}
20+
// Enum values come from the (untrusted) OpenAPI document and are emitted as Scala identifiers; the shared helper
21+
// backtick-quotes them and rejects values that cannot be safely quoted. See GHSA-gpcc-36pq-8qxr.
22+
def maybeEscaped(s: String) = NameHelpers.safeEnumMemberName(s)
2423
if (targetScala3) {
2524
val maybeCompanion =
2625
if (alwaysGenerateParamSupport || queryParamRefs.contains(name)) {

openapi-codegen/core/src/main/scala/sttp/tapir/codegen/endpoints/InAndOutComponents.scala

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ import sttp.tapir.codegen.openapi.models.OpenapiSchemaType.{
2424
}
2525
import sttp.tapir.codegen.util.ErrUtils.bail
2626
import sttp.tapir.codegen.util.Location
27-
import sttp.tapir.codegen.util.NameHelpers.indent
27+
import sttp.tapir.codegen.util.NameHelpers.{codecFormatName, indent, safeVariableName}
2828
import sttp.tapir.codegen.validation.ValidationDefns
2929
import sttp.tapir.codegen.xml.{XmlSerdeGenerator, XmlSerdeLib}
3030
import sttp.tapir.codegen.xml.XmlSerdeLib.XmlSerdeLib
@@ -170,11 +170,10 @@ object InAndOutComponents {
170170
isEager: Boolean,
171171
streamingImplementation: StreamingImplementation
172172
)(implicit location: Location): MappedContentType = {
173-
def codec(baseType: String, contentType: String) = baseType match {
174-
case "Array[Byte]" =>
175-
s"Codec.id[$baseType, `${contentType}CodecFormat`](`${contentType}CodecFormat`(), Schema.schemaForByteArray)"
176-
case "String" =>
177-
s"Codec.id[$baseType, `${contentType}CodecFormat`](`${contentType}CodecFormat`(), Schema.schemaForString)"
173+
def codec(baseType: String, contentType: String) = {
174+
val cf = codecFormatName(contentType)
175+
val schema = if (baseType == "Array[Byte]") "Schema.schemaForByteArray" else "Schema.schemaForString"
176+
s"Codec.id[$baseType, $cf]($cf(), $schema)"
178177
}
179178

180179
def eagerBody = contentType match {
@@ -193,7 +192,7 @@ object InAndOutComponents {
193192
case "application/xml" => "CodecFormat.Xml()"
194193
case "application/x-www-form-urlencoded" => "CodecFormat.XWwwFormUrlencoded()"
195194
case "application/zip" => "CodecFormat.Zip()"
196-
case o => s"`${o}CodecFormat`()"
195+
case o => s"${codecFormatName(o)}()"
197196
}
198197
if (isEager) MappedContentType(eagerBody, if (contentType.startsWith("text/")) "String" else "Array[Byte]")
199198
else {
@@ -236,7 +235,9 @@ object InAndOutComponents {
236235
val default = v.default
237236
.map(j => " = " + DefaultValueRenderer.render(Map.empty, v.`type`, optional, RenderConfig())(j))
238237
.getOrElse(if (optional) " = None" else "")
239-
s"$k: $t$default"
238+
// `k` is an inline-body property name straight from the (untrusted) document and is not covered by the
239+
// component-schema NameValidation pass, so quote/reject it here as the component-class path does. GHSA-gpcc-36pq-8qxr
240+
s"${safeVariableName(k)}: $t$default"
240241
}
241242
val inlineClassDefn =
242243
s"""case class $inlineClassName (

openapi-codegen/core/src/main/scala/sttp/tapir/codegen/endpoints/OutComponent.scala

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -142,7 +142,8 @@ object OutComponent {
142142
def callers(tpe: String, impl: Boolean) = declsByWrapperClassName
143143
.flatMap { case (_, seq) =>
144144
seq.map { case (_, t, _, ct) =>
145-
s"""$tpe `$ct`: () => $t${if (impl) s""" = () => throw new RuntimeException("Body for content type $ct not provided")"""
145+
s"""$tpe ${NameHelpers.safeVariableName(ct)}: () => $t${if (impl)
146+
s""" = () => throw new RuntimeException("Body for content type ${JavaEscape.escapeString(ct)} not provided")"""
146147
else ","}"""
147148
}
148149
}
@@ -152,7 +153,9 @@ object OutComponent {
152153

153154
val wrappers = declsByWrapperClassName
154155
.map { case (name, seq) =>
155-
val defns = seq.map { case (_, t, _, ct) => s"""override def `$ct`: () => $t = () => value""" }.sorted.mkString("\n")
156+
val defns =
157+
seq.map { case (_, t, _, ct) => s"""override def ${NameHelpers.safeVariableName(ct)}: () => $t = () => value""" }.sorted
158+
.mkString("\n")
156159
s"""case class ${name}(value: ${seq.head._2}) extends $traitName{
157160
|${indent(2)(defns)}
158161
|}""".stripMargin
@@ -179,8 +182,8 @@ object OutComponent {
179182
if (needsAliases)
180183
decls.zip(tpes).zip(seq.map(_.contentType)).map { case ((decl, _), ct) =>
181184
wrapBinType(
182-
s"$decl.map(${classNameByDecl(decl)}(_))(_.`$ct`())\n" +
183-
s".map(_.asInstanceOf[$traitName])(p => ${classNameByDecl(decl)}(p.`$ct`()))$d"
185+
s"$decl.map(${classNameByDecl(decl)}(_))(_.${NameHelpers.safeVariableName(ct)}())\n" +
186+
s".map(_.asInstanceOf[$traitName])(p => ${classNameByDecl(decl)}(p.${NameHelpers.safeVariableName(ct)}()))$d"
184187
)
185188
}
186189
else decls.map(_ + d)

openapi-codegen/core/src/main/scala/sttp/tapir/codegen/endpoints/ParamComponent.scala

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,16 @@ import sttp.tapir.codegen.validation.ValidationGenerator
1111

1212
object ParamComponent {
1313

14+
// Valid OpenAPI parameter locations. `param.in` is emitted as a raw method name (e.g. `query[...](...)`),
15+
// so an unvalidated value could inject arbitrary code; reject anything outside this allow-list.
16+
// See GHSA-gpcc-36pq-8qxr.
17+
private val validParamLocations = Set("query", "header", "path", "cookie")
18+
private def checkParamLocation(param: OpenapiParameter): Unit =
19+
if (!validParamLocations.contains(param.in))
20+
throw new IllegalArgumentException(
21+
s"Unsupported parameter location 'in': '${param.in}' (parameter '${param.name}') (see GHSA-gpcc-36pq-8qxr)"
22+
)
23+
1424
private def toOutType(baseType: String, isArray: Boolean, noOptionWrapper: Boolean) = (isArray, noOptionWrapper) match {
1525
case (true, true) => s"List[$baseType]"
1626
case (true, false) => s"Option[List[$baseType]]"
@@ -26,6 +36,7 @@ object ParamComponent {
2636
e: OpenapiSchemaEnum,
2737
isArray: Boolean
2838
): (String, Some[Seq[String]], String, String) = {
39+
checkParamLocation(param)
2940
val enumName = endpointName.capitalize + strippedToCamelCase(param.name).capitalize
3041
val enumParamRefs = if (param.in == "query" || param.in == "path") Set(enumName) else Set.empty[String]
3142
val enumDefn = EnumGenerator.generateEnum(
@@ -51,7 +62,7 @@ object ParamComponent {
5162
if (!isArray) "" else if (noOptionWrapper) s".map(_.values)($arrayType(_))" else s".map(_.map(_.values))(_.map($arrayType(_)))"
5263

5364
val desc = param.description.map(d => JavaEscape.escapeString(d)).fold("")(d => s""".description("$d")""")
54-
(s"""${param.in}[$req]("${param.name}")$mapToList$desc""", Some(enumDefn), outType, enumName)
65+
(s"""${param.in}[$req]("${JavaEscape.escapeString(param.name)}")$mapToList$desc""", Some(enumDefn), outType, enumName)
5566
}
5667
private[endpoints] def genParamDefn(
5768
endpointName: String,
@@ -62,15 +73,16 @@ object ParamComponent {
6273
generateValidators: Boolean
6374
)(implicit
6475
location: Location
65-
): (String, Option[Seq[String]], String) =
76+
): (String, Option[Seq[String]], String) = {
77+
checkParamLocation(param)
6678
param.schema match {
6779
case st: OpenapiSchemaSimpleType =>
6880
val (t, _) = mapSchemaSimpleTypeToType(st)
6981
val required = param.required.getOrElse(false)
7082
val req = if (required) t else s"Option[$t]"
7183
val desc = param.description.map(JavaEscape.escapeString).fold("")(d => s""".description("$d")""")
7284
val validation = if (generateValidators) ValidationGenerator.mkValidations(doc, st, required) else ""
73-
(s"""${param.in}[$req]("${param.name}")$validation$desc""", None, req)
85+
(s"""${param.in}[$req]("${JavaEscape.escapeString(param.name)}")$validation$desc""", None, req)
7486
case OpenapiSchemaArray(st: OpenapiSchemaSimpleType, _, _, _) =>
7587
val (t, _) = mapSchemaSimpleTypeToType(st)
7688
val arrayType = if (param.isExploded) "ExplodedValues" else "CommaSeparatedValues"
@@ -84,7 +96,7 @@ object ParamComponent {
8496

8597
val desc = param.description.map(JavaEscape.escapeString).fold("")(d => s""".description("$d")""")
8698
val outType = toOutType(t, true, noOptionWrapper)
87-
(s"""${param.in}[$req]("${param.name}")$mapToList$desc""", None, outType)
99+
(s"""${param.in}[$req]("${JavaEscape.escapeString(param.name)}")$mapToList$desc""", None, outType)
88100
case e @ OpenapiSchemaEnum(_, _, _) =>
89101
getEnumParamDefn(endpointName, targetScala3, jsonSerdeLib, param, e, isArray = false) match {
90102
case (a, b, c, _) => (a, b, c)
@@ -95,4 +107,5 @@ object ParamComponent {
95107
}
96108
case x => bail(s"Can't create non-simple params - found $x")
97109
}
110+
}
98111
}

openapi-codegen/core/src/main/scala/sttp/tapir/codegen/endpoints/SimpleTypes.scala

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package sttp.tapir.codegen.endpoints
22

33
import sttp.tapir.codegen.openapi.models.OpenapiSchemaType._
4+
import sttp.tapir.codegen.util.NameValidation
45

56
object SimpleTypes {
67

@@ -35,7 +36,13 @@ object SimpleTypes {
3536
case OpenapiSchemaAny(nb, t) =>
3637
(AnyType.toCirceTpe(t), nb)
3738
case OpenapiSchemaRef(t) =>
38-
(t.split('/').last, false)
39+
// The ref target is spliced raw as a type identifier here, and this is the single choke point through which
40+
// every $ref (in component schemas, parameters — method- and path-level, resolved or component-indirected —
41+
// request/response bodies, and response headers) becomes a type. Validate it here rather than relying on
42+
// every document location being enumerated by NameValidation. See GHSA-gpcc-36pq-8qxr.
43+
val name = t.split('/').last
44+
NameValidation.validateName("schema $ref", name)
45+
(name, false)
3946
case x => throw new NotImplementedError(s"Not all simple types supported! Found $x")
4047
}
4148
}

0 commit comments

Comments
 (0)