From bcc6fda50fa6380fbff7ba17cb7871b75f76996c Mon Sep 17 00:00:00 2001 From: Martin Weismann Date: Tue, 29 Sep 2026 20:11:17 +0200 Subject: [PATCH 1/2] Sanitize IDL text written into generated comments and strings Values from the IDL were copied verbatim into generated source files. A crafted IDL could close the surrounding comment or string literal and inject code into the generated bindings and implementation stubs. - Sanitize the copyright and license lines for each license header comment style (C-style, Pascal, Python, CMake), including C/C++ backslash line splicing and Java unicode escapes, and warn when a value is changed. - Reject method, class, function type, parameter, error, enum and enum option descriptions that could terminate the comment they are written into. Parameter descriptions are now validated; the method check previously validated the method description instead. - Escape function type descriptions written into Python string literals and sanitize descriptions written into Javadoc comments. - Add unit tests for the sanitizer and the description validation. --- Source/buildbindingjava.go | 8 +- Source/buildbindingpython.go | 2 +- Source/componentdefinition.go | 30 ++++++- Source/componentdefinition_test.go | 138 +++++++++++++++++++++++++++++ Source/languagewriter.go | 64 ++++++++++++- Source/languagewriter_test.go | 134 ++++++++++++++++++++++++++++ 6 files changed, 366 insertions(+), 10 deletions(-) create mode 100644 Source/componentdefinition_test.go create mode 100644 Source/languagewriter_test.go diff --git a/Source/buildbindingjava.go b/Source/buildbindingjava.go index 8c18d798..57df26a2 100644 --- a/Source/buildbindingjava.go +++ b/Source/buildbindingjava.go @@ -536,7 +536,7 @@ func writeJavaClassMethodImplementation(method ComponentDefinitionMethod, w Lang initCallParameters = callFunctionParameters w.Writeln(" /**") - w.Writeln(" * " + method.MethodDescription) + w.Writeln(" * %s", sanitizeCommentText(method.MethodDescription, "*/")) w.Writeln(" *") OutFieldCount := 0 @@ -563,10 +563,10 @@ func writeJavaClassMethodImplementation(method ComponentDefinitionMethod, w Lang } if (param.ParamPass == "out" || param.ParamPass == "return") { if OutFieldCount == 1 { - w.Writeln(" * @return %s", param.ParamDescription) + w.Writeln(" * @return %s", sanitizeCommentText(param.ParamDescription, "*/")) } } else { - w.Writeln(" * @param %s %s", MakeFirstLowerCase(param.ParamName), param.ParamDescription) + w.Writeln(" * @param %s %s", MakeFirstLowerCase(param.ParamName), sanitizeCommentText(param.ParamDescription, "*/")) } switch param.ParamPass { @@ -835,7 +835,7 @@ func writeJavaClassMethodImplementation(method ComponentDefinitionMethod, w Lang w.Writeln(" public static class %sResult {", method.MethodName) for _,ReturnParam := range(ReturnTuple) { w.Writeln(" /**") - w.Writeln(" * " + ReturnParam.ParamDescription) + w.Writeln(" * %s", sanitizeCommentText(ReturnParam.ParamDescription, "*/")) w.Writeln(" */") w.Writeln(" public %s %s;", ReturnParam.ParamType, ReturnParam.ParamName) w.Writeln("") diff --git a/Source/buildbindingpython.go b/Source/buildbindingpython.go index 2ab25ef8..874efe23 100644 --- a/Source/buildbindingpython.go +++ b/Source/buildbindingpython.go @@ -307,7 +307,7 @@ func buildDynamicPythonImplementation(componentdefinition ComponentDefinition, w return ReservedKeywordExit(pythonBindingFile, "Function type definition uses a reserved keyword : %s", _func.FunctionName) } w.Writeln("'''Definition of %s", _func.FunctionName) - w.Writeln(" %s", _func.FunctionDescription) + w.Writeln(" %s", sanitizeCommentText(_func.FunctionDescription, "'''")) w.Writeln("'''") arguments := "ctypes.c_void_p" for j := 0; j 0 && !descriptionIsValid(function.FunctionDescription) { return fmt.Errorf ("invalid function description \"%s\" in functiontype \"%s\"", function.FunctionDescription, function.FunctionName); } + for _, param := range function.Params { + if len(param.ParamDescription) > 0 && !descriptionIsValid(param.ParamDescription) { + return fmt.Errorf("invalid description for parameter \"%s\" in functiontype \"%s\"", param.ParamName, function.FunctionName) + } + } functionLowerNameList[strings.ToLower(function.FunctionName)] = true (*functionNameList)[function.FunctionName] = true @@ -748,7 +759,7 @@ func (component *ComponentDefinition) checkMethod(method ComponentDefinitionMeth if !nameIsValidIdentifier(param.ParamName) { return fmt.Errorf("invalid param name \"%s\" in method \"%s.%s\"", param.ParamName, className, method.MethodName); } - if !descriptionIsValid(method.MethodDescription) { + if len(param.ParamDescription) > 0 && !descriptionIsValid(param.ParamDescription) { return fmt.Errorf("invalid description for parameter \"%s.%s(... %s ...)\"", className, method.MethodName, param.ParamName); } if (paramNameList[strings.ToLower(param.ParamName)]) { @@ -884,11 +895,16 @@ func nameIsValidIdentifier(name string) bool { func descriptionIsValid(description string) bool { var IsValidMethodDescription = regexp.MustCompile("^[a-zA-Z][a-zA-Z0-9_\\\\/+\\-:,.=!?()';&#@%$* |]*$").MatchString if (description != "") { - return IsValidMethodDescription(description); + return IsValidMethodDescription(description) && commentTextIsSafe(description); } return false; } +// commentTextIsSafe returns false if s could terminate a C-style or Pascal comment it is written into +func commentTextIsSafe(s string) bool { + return sanitizeCommentText(s, "*/") == s && sanitizeCommentText(s, "*)") == s +} + func threadSafetyOptionIsValid(threadSafetyOption string) bool { switch threadSafetyOption { case "none", "strict", "soft": @@ -1025,6 +1041,14 @@ func (component *ComponentDefinition) checkComponentHeader() (error) { if !baseNameIsValid(component.BaseName) { return errors.New ("Invalid BaseName"); } + if headerTextNeedsSanitizing(component.Copyright) { + log.Printf("Warning: the copyright of component \"%s\" contains characters that will be sanitized in generated license headers", component.NameSpace) + } + for i, line := range component.License.Lines { + if headerTextNeedsSanitizing(line.Value) { + log.Printf("Warning: license line %d of component \"%s\" contains characters that will be sanitized in generated license headers", i+1, component.NameSpace) + } + } return nil } diff --git a/Source/componentdefinition_test.go b/Source/componentdefinition_test.go new file mode 100644 index 00000000..944eecdb --- /dev/null +++ b/Source/componentdefinition_test.go @@ -0,0 +1,138 @@ +package main + +import ( + "testing" +) + +func TestCheckMethodValidatesParamDescriptions(t *testing.T) { + tests := []struct { + name string + description string + valid bool + }{ + {"regular description", "The new value of this Variable", true}, + {"empty description", "", true}, + {"C comment breakout", "x */ int injected = 1; /* y", false}, + {"Pascal comment breakout", "x *) begin end; (* y", false}, + } + + var component ComponentDefinition + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + method := ComponentDefinitionMethod{ + MethodName: "SetValue", + MethodDescription: "Sets the value", + Params: []ComponentDefinitionParam{ + {ParamName: "Value", ParamType: "double", ParamPass: "in", ParamDescription: test.description}, + }, + } + err := component.checkMethod(method, "Variable") + if test.valid && err != nil { + t.Errorf("checkMethod rejected param description %q: %v", test.description, err) + } + if !test.valid && err == nil { + t.Errorf("checkMethod accepted param description %q", test.description) + } + }) + } +} + +func TestDescriptionsRejectCommentBreakout(t *testing.T) { + safe := []string{"Returns the value", "A pointer * to the buffer", "Path C:\\Data", "Size in 100% units"} + unsafe := []string{ + "x */ int injected = 1; /* y", + "x /* y", + "x *) begin end; (* y", + "x \\u002a/ class Evil {} /\\u002a", + "x ends with a backslash \\", + "x ends with a trigraph ??/", + } + for _, s := range safe { + if !descriptionIsValid(s) { + t.Errorf("descriptionIsValid(%q) = false, expected true", s) + } + if !commentTextIsSafe(s) { + t.Errorf("commentTextIsSafe(%q) = false, expected true", s) + } + } + for _, s := range unsafe { + if descriptionIsValid(s) { + t.Errorf("descriptionIsValid(%q) = true, expected false", s) + } + if commentTextIsSafe(s) { + t.Errorf("commentTextIsSafe(%q) = true, expected false", s) + } + } + if errorDescriptionIsValid("x */ int injected = 1; /* y") { + t.Errorf("errorDescriptionIsValid accepted a comment breakout") + } + if !errorDescriptionIsValid("The value is out of range") { + t.Errorf("errorDescriptionIsValid rejected a regular description") + } +} + +func TestCheckEnumsValidatesDescriptions(t *testing.T) { + tests := []struct { + name string + enumDescription string + optionDescription string + valid bool + }{ + {"regular descriptions", "The color", "Red color", true}, + {"empty descriptions", "", "", true}, + {"enum comment breakout", "x */ int injected = 1; /* y", "", false}, + {"option comment breakout", "", "x */ int injected = 1; /* y", false}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + var component ComponentDefinition + component.NameMapsLookup.enumMap = make(map[string]bool) + component.Enums = []ComponentDefinitionEnum{{ + Name: "Color", + Description: test.enumDescription, + Options: []ComponentDefinitionEnumOption{{Name: "Red", Value: 0, Description: test.optionDescription}}, + }} + err := component.checkEnums() + if test.valid && err != nil { + t.Errorf("checkEnums rejected valid descriptions: %v", err) + } + if !test.valid && err == nil { + t.Errorf("checkEnums accepted enum description %q and option description %q", test.enumDescription, test.optionDescription) + } + }) + } +} + +func TestCheckFunctionTypesValidatesParamDescriptions(t *testing.T) { + tests := []struct { + name string + description string + valid bool + }{ + {"regular description", "The progress", true}, + {"empty description", "", true}, + {"C comment breakout", "x */ int injected = 1; /* y", false}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + var component ComponentDefinition + component.NameMapsLookup.functionTypeMap = make(map[string]bool) + component.Functions = []ComponentDefinitionFunctionType{{ + FunctionName: "ProgressCallback", + FunctionDescription: "Callback to report progress", + Params: []ComponentDefinitionParam{ + {ParamName: "Progress", ParamType: "single", ParamPass: "in", ParamDescription: test.description}, + }, + }} + err := component.checkFunctionTypes() + if test.valid && err != nil { + t.Errorf("checkFunctionTypes rejected param description %q: %v", test.description, err) + } + if !test.valid && err == nil { + t.Errorf("checkFunctionTypes accepted param description %q", test.description) + } + }) + } +} diff --git a/Source/languagewriter.go b/Source/languagewriter.go index de787830..44b127f0 100644 --- a/Source/languagewriter.go +++ b/Source/languagewriter.go @@ -37,9 +37,14 @@ import ( "fmt" "io" "os" + "regexp" "strings" + "unicode" ) +// javaUnicodeEscape matches Java unicode escapes, which Java decodes everywhere in a source file, including comments +var javaUnicodeEscape = regexp.MustCompile(`\\(u+[0-9a-fA-F]{4})`) + // LanguageWriter is a wrapper around a io.Writer that handles indentation type LanguageWriter struct { Indentation int @@ -151,6 +156,61 @@ func WriteLicenseHeader(w io.Writer, component ComponentDefinition, abstract str writeLicenseHeaderEx(w, component, abstract, includeVersion, "/*", "*/") } +// replaceUntilStable replaces old by new until old no longer occurs in s +func replaceUntilStable(s string, old string, new string) string { + for strings.Contains(s, old) { + s = strings.ReplaceAll(s, old, new) + } + return s +} + +// sanitizeCommentText neutralizes character sequences in s that could terminate a comment or string literal +// which is closed by commentEnd, or otherwise alter the code that follows it. s is written as a single line. +func sanitizeCommentText(s string, commentEnd string) string { + s = strings.Map(func(r rune) rune { + if unicode.IsControl(r) { + return ' ' + } + return r + }, s) + + switch commentEnd { + case "*/": + // An escape such as \u002a/ would otherwise close the comment in Java. + s = javaUnicodeEscape.ReplaceAllString(s, "\\ $1") + // A nested opening sequence triggers -Wcomment, which fails builds that use -Werror. + s = replaceUntilStable(s, "*/", "* /") + s = replaceUntilStable(s, "/*", "/ *") + // C and C++ splice a line ending in a backslash (or the ??/ trigraph), optionally followed by + // whitespace, with the next line before comments are recognized. This could assemble */ across lines. + trimmed := strings.TrimRight(s, " ") + if strings.HasSuffix(trimmed, "\\") || strings.HasSuffix(trimmed, "??/") { + s = trimmed + "." + } + case "*)": + // Free Pascal nests (* *) comments, so an opening sequence is as dangerous as a closing one. + s = replaceUntilStable(s, "*)", "* )") + s = replaceUntilStable(s, "(*", "( *") + case "'''": + // Python headers are string literals, so backslash escapes are interpreted as well. + s = strings.ReplaceAll(s, "\\", "\\\\") + s = strings.ReplaceAll(s, "'", "\\'") + case "\n]]": + s = replaceUntilStable(s, "]]", "] ]") + } + return s +} + +// headerTextNeedsSanitizing returns true if s would be altered in the license header of any generated file +func headerTextNeedsSanitizing(s string) bool { + for _, commentEnd := range []string{"", "*/", "*)", "'''", "\n]]"} { + if sanitizeCommentText(s, commentEnd) != s { + return true + } + } + return false +} + // writeLicenseHeaderEx writes a license header into a writer. func writeLicenseHeaderEx(w io.Writer, component ComponentDefinition, abstract string, includeVersion bool, CommandStart string, CommandEnd string) { ACTVersion := component.ACTVersion @@ -162,11 +222,11 @@ func writeLicenseHeaderEx(w io.Writer, component ComponentDefinition, abstract s fmt.Fprintf(w, "%s++\n", CommandStart) fmt.Fprintf(w, "\n") } - fmt.Fprintf(w, "Copyright (C) %d %s\n", year, copyright) + fmt.Fprintf(w, "Copyright (C) %d %s\n", year, sanitizeCommentText(copyright, CommandEnd)) fmt.Fprintf(w, "\n") for i := 0; i < len(component.License.Lines); i++ { line := component.License.Lines[i] - fmt.Fprintf(w, "%s\n", line.Value) + fmt.Fprintf(w, "%s\n", sanitizeCommentText(line.Value, CommandEnd)) } fmt.Fprintf(w, "\n") if includeVersion { diff --git a/Source/languagewriter_test.go b/Source/languagewriter_test.go new file mode 100644 index 00000000..4eab4354 --- /dev/null +++ b/Source/languagewriter_test.go @@ -0,0 +1,134 @@ +package main + +import ( + "bytes" + "strings" + "testing" +) + +func TestSanitizeHeaderText(t *testing.T) { + tests := []struct { + name string + commentEnd string + input string + expected string + }{ + {"safe C text", "*/", "All rights reserved.", "All rights reserved."}, + {"safe Pascal text", "*)", "(C) 2018 Autodesk Inc.", "(C) 2018 Autodesk Inc."}, + {"safe CMake text", "\n]]", "list [a] of [b]", "list [a] of [b]"}, + {"C comment end", "*/", "x */ int evil(); /*", "x * / int evil(); / *"}, + {"C repeated comment end", "*/", "***//", "*** //"}, + {"C repeated comment start", "*/", "//**", "// **"}, + {"C comment end inside start", "*/", "/*/", "/ * /"}, + {"C trailing backslash", "*/", "a *\\", "a *\\."}, + {"C trailing backslash and whitespace", "*/", "a *\\ \t", "a *\\."}, + {"C trailing trigraph", "*/", "a *??/", "a *??/."}, + {"C inner backslash", "*/", "C:\\Path", "C:\\Path"}, + {"Java unicode comment end", "*/", "x \\u002a/ class Evil {} /\\u002a", "x \\ u002a/ class Evil {} /\\ u002a"}, + {"Java unicode escape with repeated u", "*/", "\\uuu002A/", "\\ uuu002A/"}, + {"Java non-escape backslash u", "*/", "C:\\users", "C:\\users"}, + {"Pascal comment end", "*)", "x *) begin halt; end; (*", "x * ) begin halt; end; ( *"}, + {"Pascal nested comment start", "*)", "((**))", "(( ** ))"}, + {"Python quotes", "'''", "x ''' ; import os ; '''", "x \\'\\'\\' ; import os ; \\'\\'\\'"}, + {"Python trailing backslash", "'''", "x \\", "x \\\\"}, + {"Python unicode escape", "'''", "\\N{", "\\\\N{"}, + {"CMake bracket end", "\n]]", "x ]] message(FATAL_ERROR pwned) #[[", "x ] ] message(FATAL_ERROR pwned) #[["}, + {"CMake repeated bracket end", "\n]]", "]]]]", "] ] ] ]"}, + {"control characters", "*/", "a\nb\rc\x00d", "a b c d"}, + {"plain keeps comment sequences", "", "*/ *) ''' ]]", "*/ *) ''' ]]"}, + {"plain removes control characters", "", "a\nb", "a b"}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + actual := sanitizeCommentText(test.input, test.commentEnd) + if actual != test.expected { + t.Errorf("sanitizeCommentText(%q, %q) = %q, expected %q", test.input, test.commentEnd, actual, test.expected) + } + }) + } +} + +func TestWriteLicenseHeaderLineSplicing(t *testing.T) { + var component ComponentDefinition + component.Copyright = "Autodesk Inc." + component.Year = 2026 + component.Version = "1.0.0" + component.License.Lines = []ComponentDefinitionLicenseLine{ + {Value: "a *\\"}, + {Value: "/ int injected = 1; /\\"}, + {Value: "* comment resumes here"}, + } + + var buffer bytes.Buffer + writeLicenseHeaderEx(&buffer, component, "", false, "/*", "*/") + output := buffer.String() + + for _, line := range strings.Split(output, "\n") { + trimmed := strings.TrimRight(line, " \t") + if strings.HasSuffix(trimmed, "\\") || strings.HasSuffix(trimmed, "??/") { + t.Errorf("header line %q would be spliced with the next line:\n%s", line, output) + } + } +} + +func TestHeaderTextNeedsSanitizing(t *testing.T) { + safe := []string{ + "Autodesk Inc.", + "Redistribution and use in source and binary forms, with or without modification,", + "(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS", + } + for _, s := range safe { + if headerTextNeedsSanitizing(s) { + t.Errorf("headerTextNeedsSanitizing(%q) = true, expected false", s) + } + } + + unsafe := []string{"*/", "*)", "(*", "'", "\\", "]]", "a\nb"} + for _, s := range unsafe { + if !headerTextNeedsSanitizing(s) { + t.Errorf("headerTextNeedsSanitizing(%q) = false, expected true", s) + } + } +} + +func TestWriteLicenseHeaderCannotBeEscaped(t *testing.T) { + payload := "x */ *) (* ''' ]] \\N{ \n evil(); " + var component ComponentDefinition + component.Copyright = payload + component.Year = 2026 + component.Version = "1.0.0" + component.License.Lines = []ComponentDefinitionLicenseLine{{Value: payload}, {Value: "All rights reserved."}} + + styles := []struct { + name string + commentStart string + commentEnd string + counts map[string]int + }{ + {"C", "/*", "*/", map[string]int{"*/": 1, "/*": 1}}, + {"Pascal", "(*", "*)", map[string]int{"*)": 1, "(*": 1}}, + {"Python", "'''", "'''", map[string]int{"'''": 2}}, + {"CMake", "#[[", "\n]]", map[string]int{"]]": 1}}, + } + + for _, style := range styles { + t.Run(style.name, func(t *testing.T) { + var buffer bytes.Buffer + writeLicenseHeaderEx(&buffer, component, "Abstract", true, style.commentStart, style.commentEnd) + output := buffer.String() + + for sequence, expected := range style.counts { + if actual := strings.Count(output, sequence); actual != expected { + t.Errorf("%q occurs %d times in header, expected %d:\n%s", sequence, actual, expected, output) + } + } + if !strings.HasSuffix(strings.TrimRight(output, "\n"), strings.TrimLeft(style.commentEnd, "\n")) { + t.Errorf("header does not end with its comment terminator:\n%s", output) + } + if style.name == "Python" && strings.Contains(strings.ReplaceAll(output, "\\\\", ""), "\\N") { + t.Errorf("header contains an unescaped backslash sequence:\n%s", output) + } + }) + } +} From 4b1bb6d97e83995325eeac4404a27c663ac1da3b Mon Sep 17 00:00:00 2001 From: Martin Weismann Date: Tue, 29 Sep 2026 22:41:13 +0200 Subject: [PATCH 2/2] Pin emsdk to 4.0.21 in the example build image Emscripten 4.0.22 and later require Python 3.10, but the CentOS 8 image only provides Python 3.9, so installing the latest emsdk fails. Co-authored-by: Cursor --- Build/Dockerfile | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/Build/Dockerfile b/Build/Dockerfile index 3cf7cf30..b2868d0b 100644 --- a/Build/Dockerfile +++ b/Build/Dockerfile @@ -68,9 +68,11 @@ RUN dnf -y install \ RUN echo "source /opt/rh/gcc-toolset-9/enable" >> /etc/bashrc # ---- Emscripten SDK (emsdk) ---- +# Emscripten 4.0.22 and later require Python 3.10, which is not available for CentOS 8. +ARG EMSDK_VERSION=4.0.21 ENV EMSDK=/opt/emsdk -RUN git clone https://github.com/emscripten-core/emsdk.git ${EMSDK} && \ - bash -lc "cd ${EMSDK} && ./emsdk install latest && ./emsdk activate latest" +RUN git clone --branch ${EMSDK_VERSION} --depth 1 https://github.com/emscripten-core/emsdk.git ${EMSDK} && \ + bash -lc "cd ${EMSDK} && ./emsdk install ${EMSDK_VERSION} && ./emsdk activate ${EMSDK_VERSION}" # Source emsdk env in interactive shells RUN echo "source ${EMSDK}/emsdk_env.sh" >> /etc/bashrc