66 "net/http"
77 "os"
88 "os/signal"
9+ "path"
10+ "path/filepath"
911 "strings"
1012 "syscall"
1113 "time"
@@ -432,111 +434,179 @@ func main() {
432434 // Serve frontend static files
433435 frontendDir := "./frontend/out"
434436 if _ , err := os .Stat (frontendDir ); err == nil {
437+ // Compute absolute paths for security (prevents path traversal)
438+ absFrontendDir , err := filepath .Abs (frontendDir )
439+ if err != nil {
440+ log .Fatalf ("Failed to resolve frontend directory: %v" , err )
441+ }
442+ dashboardBase := filepath .Join (absFrontendDir , "dashboard" )
443+
435444 // Serve static assets (CSS, JS, images) from _next directory
436- r .StaticFS ("/_next" , gin .Dir (frontendDir + "/_next" , false ))
445+ nextDir := filepath .Join (absFrontendDir , "_next" )
446+ r .StaticFS ("/_next" , gin .Dir (nextDir , false ))
437447
438448 // Serve dashboard and other frontend routes
439449 r .GET ("/dashboard" , func (c * gin.Context ) {
440- c .File (frontendDir + "/ dashboard/ index.html" )
450+ c .File (filepath . Join ( absFrontendDir , " dashboard" , " index.html") )
441451 })
442452 r .GET ("/dashboard/*path" , func (c * gin.Context ) {
443- path := c .Param ("path" )
453+ pathParam := c .Param ("path" )
454+
455+ // Normalize the path parameter first to prevent path traversal
456+ normalizedPath := path .Clean (pathParam )
457+ if ! strings .HasPrefix (normalizedPath , "/" ) {
458+ normalizedPath = "/" + normalizedPath
459+ }
444460
445- // Handle OAuth callback routes specially
446- if strings .HasPrefix (path , "/api/auth/github/callback" ) {
447- c .File (frontendDir + "/api/auth/github/callback/index.html" )
461+ // Handle OAuth callback routes specially (validate normalized path)
462+ if normalizedPath == "/api/auth/github/callback" || strings .HasPrefix (normalizedPath , "/api/auth/github/callback/" ) {
463+ // Validate the callback path doesn't escape the intended directory
464+ callbackBase := filepath .Join (absFrontendDir , "api" , "auth" , "github" , "callback" )
465+ callbackPath := filepath .Join (callbackBase , "index.html" )
466+
467+ // Resolve to absolute and verify it's within the callback directory
468+ absCallbackPath , err := filepath .Abs (callbackPath )
469+ if err != nil {
470+ c .File (filepath .Join (dashboardBase , "index.html" ))
471+ return
472+ }
473+ callbackBaseWithSep := callbackBase + string (os .PathSeparator )
474+ if ! strings .HasPrefix (absCallbackPath , callbackBaseWithSep ) && absCallbackPath != callbackBase {
475+ c .File (filepath .Join (dashboardBase , "index.html" ))
476+ return
477+ }
478+ c .File (absCallbackPath )
448479 return
449480 }
450- if strings .HasPrefix (path , "/api/auth/discord/callback" ) {
451- c .File (frontendDir + "/api/auth/discord/callback/index.html" )
481+ if normalizedPath == "/api/auth/discord/callback" || strings .HasPrefix (normalizedPath , "/api/auth/discord/callback/" ) {
482+ // Validate the callback path doesn't escape the intended directory
483+ callbackBase := filepath .Join (absFrontendDir , "api" , "auth" , "discord" , "callback" )
484+ callbackPath := filepath .Join (callbackBase , "index.html" )
485+
486+ // Resolve to absolute and verify it's within the callback directory
487+ absCallbackPath , err := filepath .Abs (callbackPath )
488+ if err != nil {
489+ c .File (filepath .Join (dashboardBase , "index.html" ))
490+ return
491+ }
492+ callbackBaseWithSep := callbackBase + string (os .PathSeparator )
493+ if ! strings .HasPrefix (absCallbackPath , callbackBaseWithSep ) && absCallbackPath != callbackBase {
494+ c .File (filepath .Join (dashboardBase , "index.html" ))
495+ return
496+ }
497+ c .File (absCallbackPath )
498+ return
499+ }
500+
501+ // Use the already normalized path
502+ reqPath := normalizedPath
503+
504+ // Build candidate path and resolve to absolute
505+ candidate := filepath .Join (dashboardBase , reqPath )
506+ absCandidate , err := filepath .Abs (candidate )
507+ if err != nil {
508+ // If we can't resolve the path, serve index.html
509+ c .File (filepath .Join (dashboardBase , "index.html" ))
452510 return
453511 }
454512
455- filePath := frontendDir + "/dashboard" + path
456- if strings .HasSuffix (path , "/" ) || path == "" {
457- filePath += "index.html"
513+ // Verify the resolved path is within dashboardBase
514+ // Use filepath.Join to ensure proper path separator handling
515+ dashboardBaseWithSep := dashboardBase + string (os .PathSeparator )
516+ if ! strings .HasPrefix (absCandidate , dashboardBaseWithSep ) && absCandidate != dashboardBase {
517+ // Path escaped the base directory, serve index.html instead
518+ c .File (filepath .Join (dashboardBase , "index.html" ))
519+ return
458520 }
459- if _ , err := os .Stat (filePath ); err == nil {
460- c .File (filePath )
521+
522+ // Add index.html if it's a directory route
523+ if strings .HasSuffix (normalizedPath , "/" ) || normalizedPath == "" {
524+ absCandidate = filepath .Join (absCandidate , "index.html" )
525+ }
526+
527+ // Check if file exists
528+ if _ , err := os .Stat (absCandidate ); err == nil {
529+ c .File (absCandidate )
461530 } else {
462- c .File (frontendDir + "/dashboard/index.html" )
531+ // Fallback to dashboard index.html
532+ c .File (filepath .Join (dashboardBase , "index.html" ))
463533 }
464534 })
465535
466536 // Serve other frontend routes (login, missions, etc.)
467537 r .GET ("/login" , func (c * gin.Context ) {
468- c .File (frontendDir + "/ login/ index.html" )
538+ c .File (filepath . Join ( absFrontendDir , " login" , " index.html") )
469539 })
470540 r .GET ("/login/*path" , func (c * gin.Context ) {
471- c .File (frontendDir + "/ login/ index.html" )
541+ c .File (filepath . Join ( absFrontendDir , " login" , " index.html") )
472542 })
473543
474544 // Serve specific frontend routes
475545 r .GET ("/api-keys" , func (c * gin.Context ) {
476- c .File (frontendDir + "/ api-keys/ index.html" )
546+ c .File (filepath . Join ( absFrontendDir , " api-keys" , " index.html") )
477547 })
478548 r .GET ("/api-keys/*path" , func (c * gin.Context ) {
479- c .File (frontendDir + "/ api-keys/ index.html" )
549+ c .File (filepath . Join ( absFrontendDir , " api-keys" , " index.html") )
480550 })
481551 r .GET ("/quests" , func (c * gin.Context ) {
482- c .File (frontendDir + "/ quests/ index.html" )
552+ c .File (filepath . Join ( absFrontendDir , " quests" , " index.html") )
483553 })
484554 r .GET ("/quests/*path" , func (c * gin.Context ) {
485- c .File (frontendDir + "/ quests/ index.html" )
555+ c .File (filepath . Join ( absFrontendDir , " quests" , " index.html") )
486556 })
487557 r .GET ("/items" , func (c * gin.Context ) {
488- c .File (frontendDir + "/ items/ index.html" )
558+ c .File (filepath . Join ( absFrontendDir , " items" , " index.html") )
489559 })
490560 r .GET ("/items/*path" , func (c * gin.Context ) {
491- c .File (frontendDir + "/ items/ index.html" )
561+ c .File (filepath . Join ( absFrontendDir , " items" , " index.html") )
492562 })
493563 r .GET ("/required-items" , func (c * gin.Context ) {
494- c .File (frontendDir + "/ required-items/ index.html" )
564+ c .File (filepath . Join ( absFrontendDir , " required-items" , " index.html") )
495565 })
496566 r .GET ("/required-items/*path" , func (c * gin.Context ) {
497- c .File (frontendDir + "/ required-items/ index.html" )
567+ c .File (filepath . Join ( absFrontendDir , " required-items" , " index.html") )
498568 })
499569 r .GET ("/skill-nodes" , func (c * gin.Context ) {
500- c .File (frontendDir + "/ skill-nodes/ index.html" )
570+ c .File (filepath . Join ( absFrontendDir , " skill-nodes" , " index.html") )
501571 })
502572 r .GET ("/skill-nodes/*path" , func (c * gin.Context ) {
503- c .File (frontendDir + "/ skill-nodes/ index.html" )
573+ c .File (filepath . Join ( absFrontendDir , " skill-nodes" , " index.html") )
504574 })
505575 r .GET ("/hideout-modules" , func (c * gin.Context ) {
506- c .File (frontendDir + "/ hideout-modules/ index.html" )
576+ c .File (filepath . Join ( absFrontendDir , " hideout-modules" , " index.html") )
507577 })
508578 r .GET ("/hideout-modules/*path" , func (c * gin.Context ) {
509- c .File (frontendDir + "/ hideout-modules/ index.html" )
579+ c .File (filepath . Join ( absFrontendDir , " hideout-modules" , " index.html") )
510580 })
511581 r .GET ("/enemy-types" , func (c * gin.Context ) {
512- c .File (frontendDir + "/ enemy-types/ index.html" )
582+ c .File (filepath . Join ( absFrontendDir , " enemy-types" , " index.html") )
513583 })
514584 r .GET ("/enemy-types/*path" , func (c * gin.Context ) {
515- c .File (frontendDir + "/ enemy-types/ index.html" )
585+ c .File (filepath . Join ( absFrontendDir , " enemy-types" , " index.html") )
516586 })
517587 r .GET ("/alerts" , func (c * gin.Context ) {
518- c .File (frontendDir + "/ alerts/ index.html" )
588+ c .File (filepath . Join ( absFrontendDir , " alerts" , " index.html") )
519589 })
520590 r .GET ("/alerts/*path" , func (c * gin.Context ) {
521- c .File (frontendDir + "/ alerts/ index.html" )
591+ c .File (filepath . Join ( absFrontendDir , " alerts" , " index.html") )
522592 })
523593 r .GET ("/users" , func (c * gin.Context ) {
524- c .File (frontendDir + "/ users/ index.html" )
594+ c .File (filepath . Join ( absFrontendDir , " users" , " index.html") )
525595 })
526596 r .GET ("/users/*path" , func (c * gin.Context ) {
527- c .File (frontendDir + "/ users/ index.html" )
597+ c .File (filepath . Join ( absFrontendDir , " users" , " index.html") )
528598 })
529599 r .GET ("/appwrite" , func (c * gin.Context ) {
530- c .File (frontendDir + "/ appwrite/ index.html" )
600+ c .File (filepath . Join ( absFrontendDir , " appwrite" , " index.html") )
531601 })
532602 r .GET ("/appwrite/*path" , func (c * gin.Context ) {
533- c .File (frontendDir + "/ appwrite/ index.html" )
603+ c .File (filepath . Join ( absFrontendDir , " appwrite" , " index.html") )
534604 })
535605 r .GET ("/export" , func (c * gin.Context ) {
536- c .File (frontendDir + "/ export/ index.html" )
606+ c .File (filepath . Join ( absFrontendDir , " export" , " index.html") )
537607 })
538608 r .GET ("/export/*path" , func (c * gin.Context ) {
539- c .File (frontendDir + "/ export/ index.html" )
609+ c .File (filepath . Join ( absFrontendDir , " export" , " index.html") )
540610 })
541611
542612 // Catch-all for other frontend routes
@@ -545,23 +615,57 @@ func main() {
545615 if ! strings .HasPrefix (c .Request .URL .Path , "/api" ) &&
546616 ! strings .HasPrefix (c .Request .URL .Path , "/health" ) &&
547617 ! strings .HasPrefix (c .Request .URL .Path , "/_next" ) {
548- path := c .Request .URL .Path
549- filePath := frontendDir + path
618+ reqPath := c .Request .URL .Path
619+
620+ // Normalize the URL path (clean up .. and . segments)
621+ cleanPath := path .Clean (reqPath )
622+ // Ensure it starts with / for consistency
623+ if ! strings .HasPrefix (cleanPath , "/" ) {
624+ cleanPath = "/" + cleanPath
625+ }
626+
627+ // Build candidate path and resolve to absolute
628+ candidate := filepath .Join (absFrontendDir , cleanPath )
629+ absCandidate , err := filepath .Abs (candidate )
630+ if err != nil {
631+ // If we can't resolve the path, serve root index.html
632+ c .File (filepath .Join (absFrontendDir , "index.html" ))
633+ return
634+ }
635+
636+ // Verify the resolved path is within absFrontendDir
637+ frontendBaseWithSep := absFrontendDir + string (os .PathSeparator )
638+ if ! strings .HasPrefix (absCandidate , frontendBaseWithSep ) && absCandidate != absFrontendDir {
639+ // Path escaped the base directory, serve root index.html instead
640+ c .File (filepath .Join (absFrontendDir , "index.html" ))
641+ return
642+ }
550643
551- // Add / index.html if it's a directory route
552- if strings .HasSuffix (path , "/" ) || path == "" || path == "/" {
553- filePath += "index.html"
554- } else if ! strings .Contains (path , "." ) {
644+ // Add index.html if it's a directory route
645+ if strings .HasSuffix (reqPath , "/" ) || reqPath == "" || reqPath == "/" {
646+ absCandidate = filepath . Join ( absCandidate , "index.html" )
647+ } else if ! strings .Contains (reqPath , "." ) {
555648 // If no extension, it's probably a route that needs index.html
556- filePath += "/index.html"
649+ absCandidate = filepath .Join (absCandidate , "index.html" )
650+ }
651+
652+ // Re-verify after adding index.html (in case directory traversal happened)
653+ absCandidateFinal , err := filepath .Abs (absCandidate )
654+ if err != nil {
655+ c .File (filepath .Join (absFrontendDir , "index.html" ))
656+ return
657+ }
658+ if ! strings .HasPrefix (absCandidateFinal , frontendBaseWithSep ) && absCandidateFinal != absFrontendDir {
659+ c .File (filepath .Join (absFrontendDir , "index.html" ))
660+ return
557661 }
558662
559663 // Check if file exists
560- if _ , err := os .Stat (filePath ); err == nil {
561- c .File (filePath )
664+ if _ , err := os .Stat (absCandidateFinal ); err == nil {
665+ c .File (absCandidateFinal )
562666 } else {
563667 // Fallback to root index.html for client-side routing
564- c .File (frontendDir + "/ index.html" )
668+ c .File (filepath . Join ( absFrontendDir , " index.html") )
565669 }
566670 } else {
567671 c .JSON (404 , gin.H {"error" : "Not found" })
0 commit comments