Skip to content

Commit 03ff3cc

Browse files
committed
Enhance security for serving frontend assets by implementing absolute path resolution and path traversal prevention. Update API client to store API keys in memory only, removing them from localStorage for improved security. Refactor frontend route handling to ensure proper path validation and fallback mechanisms for serving index.html. This update strengthens the overall security posture of the application while maintaining functionality.
1 parent 20812b9 commit 03ff3cc

3 files changed

Lines changed: 166 additions & 57 deletions

File tree

cmd/server/main.go

Lines changed: 152 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@ import (
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"})

frontend/app/dashboard/api-test/page.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -37,9 +37,9 @@ export default function APITestPage() {
3737
} | null>(null);
3838

3939
useEffect(() => {
40-
// Load auth tokens from localStorage
40+
// Load auth tokens (JWT from localStorage, API key from memory only)
4141
const jwtToken = localStorage.getItem('jwt_token');
42-
const apiKey = localStorage.getItem('api_key');
42+
const apiKey = apiClient.getApiKey(); // Get from memory, not localStorage
4343

4444
setHeaders([
4545
{ key: 'Authorization', value: jwtToken ? `Bearer ${jwtToken}` : '' },

frontend/lib/api.ts

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -53,9 +53,10 @@ class APIClient {
5353
},
5454
});
5555

56-
// Load from localStorage
56+
// Load from localStorage (JWT tokens only - API key no longer persisted for security)
5757
if (typeof window !== 'undefined') {
58-
this.apiKey = localStorage.getItem('api_key');
58+
// API key is NOT loaded from localStorage for security reasons
59+
// It will only be available in memory during the current session
5960
this.jwtToken = localStorage.getItem('jwt_token');
6061
this.refreshToken = localStorage.getItem('refresh_token');
6162
const expiresAt = localStorage.getItem('token_expires_at');
@@ -204,11 +205,10 @@ class APIClient {
204205
}
205206

206207
if (typeof window !== 'undefined') {
207-
if (apiKey) {
208-
localStorage.setItem('api_key', apiKey);
209-
} else {
210-
localStorage.removeItem('api_key');
211-
}
208+
// API key is NOT persisted to localStorage for security reasons
209+
// It remains in memory only for the current session
210+
// Remove any existing API key from localStorage if present
211+
localStorage.removeItem('api_key');
212212
localStorage.setItem('jwt_token', jwtToken);
213213
if (this.tokenExpiresAt) {
214214
localStorage.setItem('token_expires_at', this.tokenExpiresAt.toString());
@@ -236,6 +236,11 @@ class APIClient {
236236
return this.refreshToken;
237237
}
238238

239+
getApiKey() {
240+
// Return API key from memory (not from localStorage for security)
241+
return this.apiKey;
242+
}
243+
239244
setJWT(jwtToken: string) {
240245
this.jwtToken = jwtToken;
241246
if (typeof window !== 'undefined') {

0 commit comments

Comments
 (0)