@@ -42,13 +42,17 @@ type Server struct {
4242}
4343
4444// NewServer creates a new HTTP server instance.
45- func NewServer (dataDir string ) * Server {
45+ func NewServer (dataDir string ) (* Server , error ) {
46+ baseAbs , err := filepath .Abs (dataDir )
47+ if err != nil {
48+ return nil , err
49+ }
4650 s := & Server {
47- dataDir : dataDir ,
51+ dataDir : baseAbs ,
4852 mux : http .NewServeMux (),
4953 }
5054 s .registerRoutes ()
51- return s
55+ return s , nil
5256}
5357
5458// registerRoutes sets up all HTTP routes.
@@ -101,12 +105,6 @@ func (s *Server) handleExperimentRoutes(w http.ResponseWriter, r *http.Request)
101105
102106 experimentName := parts [0 ]
103107
104- // Validate experiment name to prevent path traversal
105- if ! isValidName (experimentName ) {
106- s .writeError (w , http .StatusBadRequest , "Invalid experiment name" )
107- return
108- }
109-
110108 if len (parts ) == 1 {
111109 // GET /api/experiments/{name}
112110 s .handleGetExperiment (w , r , experimentName )
@@ -177,9 +175,12 @@ func (s *Server) handleGetResult(w http.ResponseWriter, r *http.Request, experim
177175 return
178176 }
179177
180- // Validate inputs to prevent path traversal
181- if ! isValidName (experimentName ) || ! isValidName (fileName ) {
182- s .writeError (w , http .StatusBadRequest , "Invalid experiment name or filename" )
178+ if strings .Contains (experimentName , "/" ) || strings .Contains (experimentName , "\\ " ) || strings .Contains (experimentName , ".." ) {
179+ s .writeError (w , http .StatusBadRequest , "Invalid experiment name" )
180+ return
181+ }
182+ if strings .Contains (fileName , "/" ) || strings .Contains (fileName , "\\ " ) || strings .Contains (fileName , ".." ) {
183+ s .writeError (w , http .StatusBadRequest , "Invalid result filename" )
183184 return
184185 }
185186
@@ -194,10 +195,17 @@ func (s *Server) handleGetResult(w http.ResponseWriter, r *http.Request, experim
194195 return
195196 }
196197
197- filePath := filepath .Join (s .dataDir , experimentName , fileName )
198+ // Local safety check: validate path is within dataDir
199+ // This provides explicit validation that static analysis tools can recognize
200+ expDir , ok := isSafeRelativePath (s .dataDir , experimentName )
201+ if ! ok {
202+ s .writeError (w , http .StatusForbidden , "Access denied" )
203+ return
204+ }
198205
199- // Security check: ensure the path is within dataDir
200- if ! s .isPathSafe (filePath ) {
206+ // Validate fileName is safe and construct the final file path
207+ filePath , ok := isSafeRelativePath (expDir , fileName )
208+ if ! ok {
201209 s .writeError (w , http .StatusForbidden , "Access denied" )
202210 return
203211 }
@@ -230,10 +238,12 @@ func (s *Server) handleGetImage(w http.ResponseWriter, r *http.Request, experime
230238 s .writeError (w , http .StatusMethodNotAllowed , "Method not allowed" )
231239 return
232240 }
233-
234- // Validate inputs to prevent path traversal
235- if ! isValidName (experimentName ) || ! isValidName (fileName ) {
236- s .writeError (w , http .StatusBadRequest , "Invalid experiment name or filename" )
241+ if strings .Contains (experimentName , "/" ) || strings .Contains (experimentName , "\\ " ) || strings .Contains (experimentName , ".." ) {
242+ s .writeError (w , http .StatusBadRequest , "Invalid experiment name" )
243+ return
244+ }
245+ if strings .Contains (fileName , "/" ) || strings .Contains (fileName , "\\ " ) || strings .Contains (fileName , ".." ) {
246+ s .writeError (w , http .StatusBadRequest , "Invalid result filename" )
237247 return
238248 }
239249
@@ -247,10 +257,17 @@ func (s *Server) handleGetImage(w http.ResponseWriter, r *http.Request, experime
247257 return
248258 }
249259
250- filePath := filepath .Join (s .dataDir , experimentName , fileName )
260+ // Local safety check: validate path is within dataDir
261+ // This provides explicit validation that static analysis tools can recognize
262+ expDir , ok := isSafeRelativePath (s .dataDir , experimentName )
263+ if ! ok {
264+ s .writeError (w , http .StatusForbidden , "Access denied" )
265+ return
266+ }
251267
252- // Security check
253- if ! s .isPathSafe (filePath ) {
268+ // Validate fileName is safe and construct the final file path
269+ filePath , ok := isSafeRelativePath (expDir , fileName )
270+ if ! ok {
254271 s .writeError (w , http .StatusForbidden , "Access denied" )
255272 return
256273 }
@@ -401,18 +418,35 @@ func (s *Server) listExperiments() ([]Experiment, error) {
401418
402419// getExperiment returns details of a specific experiment.
403420func (s * Server ) getExperiment (name string ) (* Experiment , error ) {
404- // Validate name to prevent path traversal
405- if ! isValidName (name ) {
421+ if strings .Contains (name , "/" ) || strings .Contains (name , "\\ " ) || strings .Contains (name , ".." ) {
406422 return nil , os .ErrNotExist
407423 }
408-
409- expDir := filepath .Join (s .dataDir , name )
410-
411- // Security check: ensure the path is within dataDir
412- if ! s .isPathSafe (expDir ) {
424+ // Resolve symlinks for both the base directory and the experiment directory
425+ // before checking containment, so a symlink under s.dataDir cannot escape it.
426+ baseDir , err := filepath .EvalSymlinks (s .dataDir )
427+ if err != nil {
428+ return nil , err
429+ }
430+ baseDir , err = filepath .Abs (baseDir )
431+ if err != nil {
432+ return nil , err
433+ }
434+ expDirJoined := filepath .Join (s .dataDir , name )
435+ expDir , err := filepath .EvalSymlinks (expDirJoined )
436+ if err != nil {
437+ return nil , err
438+ }
439+ expDir , err = filepath .Abs (expDir )
440+ if err != nil {
441+ return nil , err
442+ }
443+ relPath , err := filepath .Rel (baseDir , expDir )
444+ if err != nil {
445+ return nil , err
446+ }
447+ if relPath == ".." || strings .HasPrefix (relPath , ".." + string (os .PathSeparator )) {
413448 return nil , os .ErrNotExist
414449 }
415-
416450 info , err := os .Stat (expDir )
417451 if err != nil {
418452 return nil , err
@@ -460,15 +494,14 @@ func (s *Server) getExperiment(name string) (*Experiment, error) {
460494
461495// getResultSummaries returns summaries of all result files in an experiment.
462496func (s * Server ) getResultSummaries (experimentName string ) ([]ResultSummary , error ) {
463- // Validate name to prevent path traversal
464- if ! isValidName (experimentName ) {
497+ if strings .Contains (experimentName , "/" ) || strings .Contains (experimentName , "\\ " ) || strings .Contains (experimentName , ".." ) {
465498 return nil , os .ErrNotExist
466499 }
467500
468- expDir := filepath . Join ( s . dataDir , experimentName )
469-
470- // Security check: ensure the path is within dataDir
471- if ! s . isPathSafe ( expDir ) {
501+ // Local safety check: validate path is within dataDir
502+ // This provides explicit validation that static analysis tools can recognize
503+ expDir , ok := isSafeRelativePath ( s . dataDir , experimentName )
504+ if ! ok {
472505 return nil , os .ErrNotExist
473506 }
474507
@@ -551,64 +584,58 @@ func isValidName(name string) bool {
551584 return true
552585}
553586
554- // isPathSafe checks if the given path is within the data directory.
555- // It resolves symlinks before checking containment to prevent symlink-based path traversal attacks.
556- func (s * Server ) isPathSafe (path string ) bool {
557- // Clean the path to resolve .. and . segments
558- cleanPath := filepath .Clean (path )
559-
560- // Get absolute paths
561- absPath , err := filepath .Abs (cleanPath )
562- if err != nil {
563- return false
587+ // isSafeRelativePath validates that subpath is safe relative to baseDir and returns the absolute path.
588+ // It checks that the joined and normalized path is still within baseDir.
589+ // This provides a local, explicit safety check that static analysis tools can recognize.
590+ func isSafeRelativePath (absBaseDir , subpath string ) (string , bool ) {
591+ // First validate the subpath contains only safe characters
592+ if ! isValidName (subpath ) {
593+ return "" , false
564594 }
565- absDataDir , err := filepath .Abs (s .dataDir )
595+
596+ // Join and clean the path
597+ joined := filepath .Join (absBaseDir , subpath )
598+ absPath , err := filepath .Abs (joined )
566599 if err != nil {
567- return false
600+ return "" , false
568601 }
569602
570- // Resolve the data directory (must exist)
571- resolvedDataDir , err := filepath .EvalSymlinks (absDataDir )
603+ // Resolve symlinks for both base dir and target path
604+ // This prevents symlink-based path traversal attacks
605+ evalBaseDir , err := filepath .EvalSymlinks (absBaseDir )
572606 if err != nil {
573- return false
607+ return "" , false
574608 }
575- dataDirPrefix := resolvedDataDir + string (filepath .Separator )
576609
577- // Check if the path exists
578- _ , err = os .Stat (absPath )
579- if err == nil {
580- // Path exists - fully resolve it and check containment
581- resolvedPath , err := filepath .EvalSymlinks (absPath )
610+ // For the target path, we need to handle the case where it may not exist yet
611+ // Try to evaluate symlinks, but if the path doesn't exist, we'll validate
612+ // against the parent directory
613+ evalPath , err := filepath .EvalSymlinks (absPath )
614+ if err != nil {
615+ // Path doesn't exist - validate against base dir
616+ // Use filepath.Rel to ensure the path is relative to base dir
617+ relPath , err := filepath .Rel (absBaseDir , absPath )
582618 if err != nil {
583- return false
619+ return "" , false
584620 }
585- return strings .HasPrefix (resolvedPath + string (filepath .Separator ), dataDirPrefix )
586- }
587-
588- // Path doesn't exist - check that the nearest existing ancestor is within dataDir
589- // We can only verify the safety of existing directories; non-existent portions
590- // will be validated when actually created
591- current := filepath .Dir (absPath )
592- for {
593- // Check if current directory exists
594- _ , err := os .Stat (current )
595- if err == nil {
596- // Found an existing ancestor - resolve it and verify it's within dataDir
597- resolvedCurrent , err := filepath .EvalSymlinks (current )
598- if err != nil {
599- return false
600- }
601- return strings .HasPrefix (resolvedCurrent + string (filepath .Separator ), dataDirPrefix )
621+ // Check if relative path escapes base dir
622+ if strings .HasPrefix (relPath , ".." ) || relPath == ".." {
623+ return "" , false
602624 }
625+ return absPath , true
626+ }
603627
604- // Current doesn't exist, go to parent
605- parent := filepath .Dir (current )
606- if parent == current {
607- // Reached root without finding any existing directory
608- return false
609- }
610- current = parent
628+ // Path exists - verify it's within the resolved base dir
629+ relPath , err := filepath .Rel (evalBaseDir , evalPath )
630+ if err != nil {
631+ return "" , false
632+ }
633+ // Check if relative path escapes base dir
634+ if strings .HasPrefix (relPath , ".." ) || relPath == ".." {
635+ return "" , false
611636 }
637+
638+ return evalPath , true
612639}
613640
614641// writeJSON writes a JSON response.
0 commit comments