66 "net/http"
77 "os"
88 "path/filepath"
9+ "strings"
910
1011 "github.com/gin-gonic/gin"
1112 bpConfig "github.com/gruntwork-io/boilerplate/config"
@@ -14,10 +15,96 @@ import (
1415 bpVariables "github.com/gruntwork-io/boilerplate/variables"
1516)
1617
18+ // validateOutputPath validates an output path from an API request to prevent security issues.
19+ // It ensures the path is:
20+ // - Not absolute (to prevent writing to arbitrary filesystem locations)
21+ // - Does not contain ".." (to prevent directory traversal attacks)
22+ // Returns an error if validation fails.
23+ func validateOutputPath (path string ) error {
24+ // Empty path is allowed (will use default)
25+ if path == "" {
26+ return nil
27+ }
28+
29+ // Check for absolute paths (Unix-style and Windows-style)
30+ if filepath .IsAbs (path ) {
31+ return fmt .Errorf ("absolute paths are not allowed in API requests: %s" , path )
32+ }
33+
34+ // Additional check for Windows paths on Unix systems
35+ if len (path ) >= 2 && path [1 ] == ':' {
36+ return fmt .Errorf ("absolute paths are not allowed in API requests: %s" , path )
37+ }
38+
39+ // Check for directory traversal attempts
40+ if containsDirectoryTraversal (path ) {
41+ return fmt .Errorf ("directory traversal is not allowed: %s" , path )
42+ }
43+
44+ return nil
45+ }
46+
47+ // containsDirectoryTraversal checks if a path contains ".." components
48+ func containsDirectoryTraversal (path string ) bool {
49+ // Normalize path separators
50+ normalizedPath := filepath .ToSlash (path )
51+
52+ // Check for ".." as a path component
53+ // This handles: "..", "../foo", "foo/../bar", "foo/.."
54+ parts := strings .Split (normalizedPath , "/" )
55+ for _ , part := range parts {
56+ if part == ".." {
57+ return true
58+ }
59+ }
60+
61+ return false
62+ }
63+
64+ // determineOutputDirectory determines the final output directory based on CLI config and API request.
65+ // CLI output path is the path set via the --output-path CLI flag and is trusted (specified by end user)
66+ // API request output path is the path specified in a component prop in the Runbook and is untrusted (specified by runbook author).
67+ // If apiRequestOutputPath is provided, it's validated and treated as a subdirectory within the CLI path.
68+ // Returns the absolute output directory path or an error.
69+ func determineOutputDirectory (cliOutputPath string , apiRequestOutputPath * string ) (string , error ) {
70+ var outputDir string
71+
72+ if apiRequestOutputPath != nil && * apiRequestOutputPath != "" {
73+ // Validate the API-provided output path for security
74+ if err := validateOutputPath (* apiRequestOutputPath ); err != nil {
75+ return "" , fmt .Errorf ("invalid output path: %w" , err )
76+ }
77+
78+ // Treat the validated path as a subdirectory within the CLI output path
79+ if filepath .IsAbs (cliOutputPath ) {
80+ outputDir = filepath .Join (cliOutputPath , * apiRequestOutputPath )
81+ } else {
82+ currentDir , err := os .Getwd ()
83+ if err != nil {
84+ return "" , fmt .Errorf ("failed to get current working directory: %w" , err )
85+ }
86+ outputDir = filepath .Join (currentDir , cliOutputPath , * apiRequestOutputPath )
87+ }
88+ } else {
89+ // Use the CLI output path
90+ if filepath .IsAbs (cliOutputPath ) {
91+ outputDir = cliOutputPath
92+ } else {
93+ currentDir , err := os .Getwd ()
94+ if err != nil {
95+ return "" , fmt .Errorf ("failed to get current working directory: %w" , err )
96+ }
97+ outputDir = filepath .Join (currentDir , cliOutputPath )
98+ }
99+ }
100+
101+ return outputDir , nil
102+ }
103+
17104// This handler renders a boilerplate template with the provided variables.
18105
19106// HandleBoilerplateRender renders a boilerplate template with the provided variables
20- func HandleBoilerplateRender (runbookPath string ) gin.HandlerFunc {
107+ func HandleBoilerplateRender (runbookPath string , cliOutputPath string ) gin.HandlerFunc {
21108 return func (c * gin.Context ) {
22109 var req RenderRequest
23110 if err := c .ShouldBindJSON (& req ); err != nil {
@@ -54,22 +141,14 @@ func HandleBoilerplateRender(runbookPath string) gin.HandlerFunc {
54141 }
55142
56143 // Determine the output directory
57- var outputDir string
58- if req .OutputPath != nil && * req .OutputPath != "" {
59- // Use the provided output path (can be relative or absolute)
60- outputDir = * req .OutputPath
61- } else {
62- // Default to "generated" subfolder in current working directory
63- currentDir , err := os .Getwd ()
64- if err != nil {
65- slog .Error ("Failed to get current working directory" , "error" , err )
66- c .JSON (http .StatusInternalServerError , gin.H {
67- "error" : "Failed to get current working directory" ,
68- "details" : err .Error (),
69- })
70- return
71- }
72- outputDir = filepath .Join (currentDir , "generated" )
144+ outputDir , err := determineOutputDirectory (cliOutputPath , req .OutputPath )
145+ if err != nil {
146+ slog .Error ("Failed to determine output directory" , "error" , err )
147+ c .JSON (http .StatusBadRequest , gin.H {
148+ "error" : "Invalid output path" ,
149+ "details" : err .Error (),
150+ })
151+ return
73152 }
74153
75154 // Create the output directory if it doesn't exist
@@ -85,7 +164,7 @@ func HandleBoilerplateRender(runbookPath string) gin.HandlerFunc {
85164 slog .Info ("Rendering template to output directory" , "outputDir" , outputDir )
86165
87166 // Render the template using the boilerplate package
88- err : = renderBoilerplateTemplate (fullTemplatePath , outputDir , req .Variables )
167+ err = renderBoilerplateTemplate (fullTemplatePath , outputDir , req .Variables )
89168 if err != nil {
90169 slog .Error ("Failed to render boilerplate template" , "error" , err )
91170 c .JSON (http .StatusInternalServerError , gin.H {
0 commit comments