Skip to content

Commit 8d7c179

Browse files
committed
security: Reject path traversal in repo IDs, sanitize error responses
- handlers_upload.go: reject repo IDs containing ".." in both isValidRepoID and extractRepoIDFromPath (defense in depth, storage layer already sanitizes but validation should catch first) - errors.go: WriteError no longer exposes raw error messages for 5xx responses — returns "Internal server error" instead of internal paths, SQL errors, or stack traces. CkbError messages (user-facing) and 4xx errors are still included. Addresses concerns from PRs #140 and #141 (mrwind-up-bird).
1 parent 4d9c46a commit 8d7c179

File tree

3 files changed

+28
-11
lines changed

3 files changed

+28
-11
lines changed

internal/api/errors.go

Lines changed: 15 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -16,22 +16,29 @@ type ErrorResponse struct {
1616
Drilldowns []errors.Drilldown `json:"drilldowns,omitempty"`
1717
}
1818

19-
// WriteError writes an error response to the HTTP response writer
19+
// WriteError writes an error response to the HTTP response writer.
20+
// For internal server errors (5xx), the raw error message is sanitized
21+
// to avoid leaking file paths, SQL errors, or internal details.
2022
func WriteError(w http.ResponseWriter, err error, status int) {
2123
w.Header().Set("Content-Type", "application/json")
2224
w.WriteHeader(status)
2325

24-
resp := ErrorResponse{
25-
Error: err.Error(),
26-
}
26+
resp := ErrorResponse{}
2727

28-
// If it's a CkbError, include additional information
28+
// If it's a CkbError, include structured information (safe to expose)
2929
if ckbErr, ok := err.(*errors.CkbError); ok {
30+
resp.Error = ckbErr.Message
3031
resp.Code = string(ckbErr.Code)
3132
resp.Details = ckbErr.Details
3233
resp.SuggestedFixes = ckbErr.SuggestedFixes
3334
resp.Drilldowns = ckbErr.Drilldowns
35+
} else if status >= 500 {
36+
// For internal errors, don't expose raw error details
37+
resp.Error = "Internal server error"
38+
resp.Code = "INTERNAL_ERROR"
3439
} else {
40+
// Client errors (4xx) — safe to include the message
41+
resp.Error = err.Error()
3542
resp.Code = "INTERNAL_ERROR"
3643
}
3744

@@ -101,8 +108,9 @@ func NotFound(w http.ResponseWriter, message string) {
101108
}, http.StatusNotFound)
102109
}
103110

104-
// InternalError writes a 500 Internal Server Error
105-
func InternalError(w http.ResponseWriter, message string, err error) {
111+
// InternalError writes a 500 Internal Server Error.
112+
// The message is returned to the client; the original err is not exposed.
113+
func InternalError(w http.ResponseWriter, message string, _ error) {
106114
WriteError(w, &errors.CkbError{
107115
Code: errors.InternalError,
108116
Message: message,

internal/api/errors_test.go

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -62,8 +62,9 @@ func TestWriteError(t *testing.T) {
6262
t.Fatalf("failed to parse response: %v", err)
6363
}
6464

65-
if resp.Error != "something went wrong" {
66-
t.Errorf("resp.Error = %q, want 'something went wrong'", resp.Error)
65+
// Internal errors (5xx) should NOT expose raw error messages
66+
if resp.Error != "Internal server error" {
67+
t.Errorf("resp.Error = %q, want 'Internal server error' (raw message should be sanitized)", resp.Error)
6768
}
6869
if resp.Code != "INTERNAL_ERROR" {
6970
t.Errorf("resp.Code = %q, want INTERNAL_ERROR", resp.Code)

internal/api/handlers_upload.go

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -448,7 +448,10 @@ func isValidRepoID(id string) bool {
448448
return false
449449
}
450450
}
451-
// Don't allow consecutive slashes or starting/ending with special chars
451+
// Don't allow path traversal components or leading/trailing slashes
452+
if strings.Contains(id, "..") {
453+
return false
454+
}
452455
if strings.Contains(id, "//") || strings.HasPrefix(id, "/") || strings.HasSuffix(id, "/") {
453456
return false
454457
}
@@ -462,7 +465,8 @@ func isValidRepoIDChar(c rune) bool {
462465
c == '/' || c == '-' || c == '_' || c == '.'
463466
}
464467

465-
// extractRepoIDFromPath extracts repo ID from URL path
468+
// extractRepoIDFromPath extracts repo ID from URL path.
469+
// Returns empty string if the extracted ID contains path traversal components.
466470
func extractRepoIDFromPath(path, prefix, suffix string) string {
467471
if !strings.HasPrefix(path, prefix) {
468472
return ""
@@ -471,5 +475,9 @@ func extractRepoIDFromPath(path, prefix, suffix string) string {
471475
if suffix != "" && strings.HasSuffix(id, suffix) {
472476
id = strings.TrimSuffix(id, suffix)
473477
}
478+
// Reject path traversal
479+
if strings.Contains(id, "..") {
480+
return ""
481+
}
474482
return id
475483
}

0 commit comments

Comments
 (0)