-
Notifications
You must be signed in to change notification settings - Fork 0
feat: add runtime hardening #57
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -78,13 +78,15 @@ func NewServer(cfg ServerConfig) (*Server, error) { | |
| } | ||
| rl := newRateLimiter(1.0, burst) | ||
|
|
||
| // Build middleware stack: Recovery → Logging → RateLimit → CORS → User → Session → CSRF → Routes | ||
| // Build middleware stack (outermost first): | ||
| // Recovery → Logging → CORS → RateLimit → User → Session → CSRF → Routes | ||
| // CORS must be before RateLimit so preflight OPTIONS gets proper CORS headers. | ||
| var handler http.Handler = mux | ||
| handler = csrfMiddleware(sm, logger)(handler) | ||
| handler = sessionMiddleware(sm)(handler) | ||
| handler = userMiddleware(sm)(handler) | ||
| handler = corsMiddleware(cfg.CORSOrigins)(handler) | ||
| handler = rateLimitMiddleware(rl, cfg.TrustProxy, logger)(handler) | ||
| handler = corsMiddleware(cfg.CORSOrigins)(handler) | ||
| handler = loggingMiddleware(logger)(handler) | ||
|
Comment on lines
+89
to
90
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Middleware Order: CORS and LoggingThe CORS middleware is applied before the logging middleware. This means that CORS preflight requests (OPTIONS) may not be logged, potentially omitting important request traces for debugging or auditing. Consider placing the logging middleware as the outermost layer (before CORS) to ensure all requests, including preflight, are logged: handler = loggingMiddleware(logger)(handler)
handler = corsMiddleware(cfg.CORSOrigins)(handler)This change will improve observability without affecting CORS behavior. |
||
| handler = recoveryMiddleware(logger)(handler) | ||
|
|
||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -120,9 +120,14 @@ func (sm *sessionManager) CheckCSRF(userID, token string) error { | |
| message := fmt.Sprintf("%s:%d", userID, timestamp) | ||
| h := hmac.New(sha256.New, sm.hmacSecret) | ||
| h.Write([]byte(message)) | ||
| expectedSig := base64.URLEncoding.EncodeToString(h.Sum(nil)) | ||
| expectedSig := h.Sum(nil) | ||
|
|
||
| if subtle.ConstantTimeCompare([]byte(parts[1]), []byte(expectedSig)) != 1 { | ||
| actualSig, err := base64.URLEncoding.DecodeString(parts[1]) | ||
| if err != nil { | ||
| return ErrCSRFMalformed | ||
| } | ||
|
|
||
| if subtle.ConstantTimeCompare(actualSig, expectedSig) != 1 { | ||
| return ErrCSRFInvalid | ||
|
Comment on lines
+130
to
131
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Potential ambiguity in signature length handling: The comparison Recommended solution: if len(actualSig) != len(expectedSig) || subtle.ConstantTimeCompare(actualSig, expectedSig) != 1 {
return ErrCSRFInvalid
} |
||
| } | ||
|
|
||
|
|
@@ -176,9 +181,14 @@ func (sm *sessionManager) CheckPreSessionCSRF(token string) error { | |
| message := fmt.Sprintf("%s:%d", nonce, timestamp) | ||
| h := hmac.New(sha256.New, sm.hmacSecret) | ||
| h.Write([]byte(message)) | ||
| expectedSig := base64.URLEncoding.EncodeToString(h.Sum(nil)) | ||
| expectedSig := h.Sum(nil) | ||
|
|
||
| actualSig, err := base64.URLEncoding.DecodeString(parts[2]) | ||
| if err != nil { | ||
| return ErrCSRFMalformed | ||
| } | ||
|
|
||
| if subtle.ConstantTimeCompare([]byte(parts[2]), []byte(expectedSig)) != 1 { | ||
| if subtle.ConstantTimeCompare(actualSig, expectedSig) != 1 { | ||
| return ErrCSRFInvalid | ||
|
Comment on lines
+191
to
192
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Potential ambiguity in signature length handling: As in the user-bound CSRF check, Recommended solution: if len(actualSig) != len(expectedSig) || subtle.ConstantTimeCompare(actualSig, expectedSig) != 1 {
return ErrCSRFInvalid
} |
||
| } | ||
|
|
||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -112,7 +112,7 @@ func SetupTest(t *testing.T) *TestFramework { | |
| } | ||
|
|
||
| // Create toolsets | ||
| pathValidator, err := security.NewPath([]string{os.TempDir()}) | ||
| pathValidator, err := security.NewPath([]string{os.TempDir()}, nil) | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Test Flexibility Limitation: Recommended Solution: allowedPaths := []string{os.TempDir(), "/tmp", "/var/tmp"} // Example
pathValidator, err := security.NewPath(allowedPaths, nil) |
||
| if err != nil { | ||
| t.Fatalf("creating path validator: %v", err) | ||
| } | ||
|
Comment on lines
+115
to
118
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Error Handling and Test Robustness: Recommended Solution: if err != nil {
t.Fatalf("creating path validator for temp dir '%s': %v. Ensure the directory is writable and accessible.", os.TempDir(), err)
} |
||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -16,7 +16,7 @@ | |
| // Path Validator: Prevents directory traversal and ensures file operations | ||
| // stay within allowed boundaries. | ||
| // | ||
| // pathValidator, err := security.NewPath([]string{"/safe/dir"}) | ||
| // pathValidator, err := security.NewPath([]string{"/safe/dir"}, nil) | ||
| // if _, err := pathValidator.Validate(userInput); err != nil { | ||
| // return fmt.Errorf("invalid path: %w", err) | ||
| // } | ||
|
|
@@ -67,7 +67,7 @@ | |
| // # Integration Example | ||
| // | ||
| // // Create validators | ||
| // pathVal, _ := security.NewPath([]string{workDir}) | ||
| // pathVal, _ := security.NewPath([]string{workDir}, nil) | ||
| // cmdVal := security.NewCommand() | ||
| // urlVal := security.NewURL() | ||
| // envVal := security.NewEnv() | ||
|
Comment on lines
+70
to
73
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Error Handling Risk: The integration example discards errors returned by validator constructors (e.g., pathVal, err := security.NewPath([]string{workDir}, nil)
if err != nil {
// handle error (e.g., log and abort initialization)
}This approach ensures that initialization failures are detected and handled, maintaining the integrity of the security layer. |
||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -20,18 +20,25 @@ var ( | |
|
|
||
| // ErrPathNullByte indicates the path contains a null byte (CWE-626). | ||
| ErrPathNullByte = errors.New("path contains null byte") | ||
|
|
||
| // ErrPathDenied indicates the path matches a denied prefix (e.g. prompts/). | ||
| ErrPathDenied = errors.New("path is denied") | ||
| ) | ||
|
|
||
| // Path validates and sanitizes file paths to prevent traversal attacks. | ||
| // Used to prevent path traversal attacks (CWE-22). | ||
| type Path struct { | ||
| allowedDirs []string | ||
| workDir string | ||
| allowedDirs []string | ||
| deniedPrefixes []string // absolute paths that are always denied (case-insensitive on macOS HFS+) | ||
| workDir string | ||
| } | ||
|
|
||
| // NewPath creates a new Path validator. | ||
| // allowedDirs: list of allowed directories (empty list means only working directory is allowed) | ||
| func NewPath(allowedDirs []string) (*Path, error) { | ||
| // allowedDirs: list of allowed directories (empty list means only working directory is allowed). | ||
| // deniedPrefixes: list of directory prefixes that are always denied even if inside allowed dirs | ||
| // (e.g. "prompts/" to protect system prompt files). Compared case-insensitively on | ||
| // case-insensitive filesystems (macOS HFS+). | ||
| func NewPath(allowedDirs, deniedPrefixes []string) (*Path, error) { | ||
| workDir, err := os.Getwd() | ||
| if err != nil { | ||
| return nil, fmt.Errorf("unable to get working directory: %w", err) | ||
|
|
@@ -47,12 +54,35 @@ func NewPath(allowedDirs []string) (*Path, error) { | |
| absAllowedDirs = append(absAllowedDirs, absDir) | ||
| } | ||
|
|
||
| // Convert denied prefixes to absolute paths | ||
| absDenied := make([]string, 0, len(deniedPrefixes)) | ||
| for _, prefix := range deniedPrefixes { | ||
| absPrefix, err := filepath.Abs(prefix) | ||
| if err != nil { | ||
| return nil, fmt.Errorf("unable to resolve denied prefix %s: %w", prefix, err) | ||
| } | ||
| absDenied = append(absDenied, absPrefix) | ||
| } | ||
|
|
||
| return &Path{ | ||
| allowedDirs: absAllowedDirs, | ||
| workDir: workDir, | ||
| allowedDirs: absAllowedDirs, | ||
| deniedPrefixes: absDenied, | ||
| workDir: workDir, | ||
| }, nil | ||
| } | ||
|
|
||
| // isPathDenied checks if a path matches any denied prefix. | ||
| // Uses case-insensitive comparison to handle case-insensitive filesystems (macOS HFS+). | ||
| func (v *Path) isPathDenied(absPath string) bool { | ||
| for _, denied := range v.deniedPrefixes { | ||
| deniedWithSep := filepath.Clean(denied) + string(filepath.Separator) | ||
| if strings.EqualFold(absPath, denied) || strings.HasPrefix(strings.ToLower(absPath+string(filepath.Separator)), strings.ToLower(deniedWithSep)) { | ||
| return true | ||
| } | ||
| } | ||
| return false | ||
| } | ||
|
|
||
| // isPathInAllowedDirs checks if a path is within allowed directories | ||
| // Returns true if path is in working directory or any allowed directory | ||
| func (v *Path) isPathInAllowedDirs(absPath string) bool { | ||
|
Comment on lines
54
to
88
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Potential directory containment flaw in isPathInAllowedDirs The use of if strings.HasPrefix(absPathWithSep, dirNorm) || absPath == dir {
return true
}Recommendation: Use |
||
|
|
@@ -126,6 +156,14 @@ func (v *Path) Validate(path string) (string, error) { | |
| return "", fmt.Errorf("%w: access denied", ErrPathOutsideAllowed) | ||
| } | ||
|
|
||
| // 3b. Check if path matches a denied prefix (e.g. prompts/) | ||
| if v.isPathDenied(absPath) { | ||
| slog.Warn("path denied by prefix rule", | ||
| "path", absPath, | ||
| "security_event", "denied_prefix_access_attempt") | ||
| return "", fmt.Errorf("%w: access denied", ErrPathDenied) | ||
| } | ||
|
|
||
| // 4. Resolve symbolic links (prevent bypassing restrictions through symlinks) | ||
| realPath, err := filepath.EvalSymlinks(absPath) | ||
| if err != nil { | ||
|
Comment on lines
156
to
169
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Denied prefix check missing after symlink resolution After resolving symlinks with if realPath != absPath {
if !v.isPathInAllowedDirs(realPath) {
// ...
return "", fmt.Errorf("%w: access denied", ErrSymlinkOutsideAllowed)
}
absPath = realPath
}Recommendation: After symlink resolution, also check |
||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Security/Robustness:
The test
TestChatSend_BodyTooLargeverifies the status code and error code, but does not assert that the response body is free from sensitive or unexpected information. To strengthen the test, add assertions to ensure the response body does not contain any partial or leaked data.Recommended solution: