Fix 8 high-severity audit findings across security, Go, API, and container domains

- Use typed context key for auth context to prevent collisions (auth.go)
- Eliminate nested locking in CheckRateLimit to prevent potential deadlock (auth.go)
- Add 1MB request body size limit middleware to prevent DoS (router.go)
- Return proper gRPC status codes instead of nil errors on failures (handler.go)
- Use key name instead of raw API key in admin URL paths to prevent secret leakage (keys.go, router.go, keystore_db.go, keystore_hybrid.go)
- Enforce RBAC authorization in service Send/SendBatch for both REST and gRPC (service.go)
- Pin runtime Docker image to alpine:3.21 for reproducible builds (Dockerfile)
- Enable readOnlyRootFilesystem with /tmp emptyDir in k8s deployment (deployment.yaml)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-26 20:17:51 -07:00
parent 71b02758d7
commit 298c960808
9 changed files with 212 additions and 43 deletions
+6 -9
View File
@@ -8,6 +8,8 @@ import (
pb "github.com/igodwin/notifier/api/grpc/pb"
"github.com/igodwin/notifier/internal/domain"
"github.com/igodwin/notifier/internal/logging"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
"google.golang.org/protobuf/types/known/timestamppb"
)
@@ -82,12 +84,7 @@ func (h *NotifierHandler) SendNotification(ctx context.Context, req *pb.SendNoti
if err != nil {
h.logger.Errorf("gRPC: Failed to send notification - type=%s, account=%s, error=%v",
req.Type, req.Account, err)
return &pb.SendNotificationResponse{
Result: &pb.NotificationResult{
Success: false,
Error: err.Error(),
},
}, nil
return nil, status.Errorf(codes.Internal, "failed to send notification: %v", err)
}
// Log success
@@ -139,7 +136,7 @@ func (h *NotifierHandler) SendBatchNotifications(ctx context.Context, req *pb.Se
func (h *NotifierHandler) GetNotification(ctx context.Context, req *pb.GetNotificationRequest) (*pb.GetNotificationResponse, error) {
notification, err := h.service.GetNotification(ctx, req.Id)
if err != nil {
return nil, err
return nil, status.Errorf(codes.NotFound, "notification not found: %v", err)
}
return &pb.GetNotificationResponse{
@@ -154,7 +151,7 @@ func (h *NotifierHandler) ListNotifications(ctx context.Context, req *pb.ListNot
notifications, err := h.service.ListNotifications(ctx, filter)
if err != nil {
return nil, err
return nil, status.Errorf(codes.Internal, "failed to list notifications: %v", err)
}
protoNotifications := make([]*pb.Notification, len(notifications))
@@ -230,7 +227,7 @@ func (h *NotifierHandler) GetNotifiers(ctx context.Context, req *pb.GetNotifiers
notifiers, err := h.service.GetNotifiers(ctx)
if err != nil {
h.logger.Errorf("gRPC: Failed to get notifiers - error=%v", err)
return nil, err
return nil, status.Errorf(codes.Internal, "failed to get notifiers: %v", err)
}
// Convert domain notifiers to proto notifiers
+19 -17
View File
@@ -8,6 +8,7 @@ import (
"strings"
"time"
"github.com/gorilla/mux"
"github.com/igodwin/notifier/internal/auth"
"github.com/igodwin/notifier/internal/logging"
)
@@ -76,7 +77,7 @@ func (h *KeyManagementHandler) CreateKey(w http.ResponseWriter, r *http.Request)
ctx := r.Context()
// Check authorization - must have admin role
authCtx, ok := ctx.Value("auth").(*auth.AuthContext)
authCtx, ok := auth.GetAuthContext(ctx)
if !ok || !h.hasRole(authCtx, "admin") {
h.respondError(w, http.StatusForbidden, "Insufficient permissions", "admin role required")
return
@@ -143,7 +144,7 @@ func (h *KeyManagementHandler) CreateKey(w http.ResponseWriter, r *http.Request)
func (h *KeyManagementHandler) ListKeys(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
authCtx, ok := ctx.Value("auth").(*auth.AuthContext)
authCtx, ok := auth.GetAuthContext(ctx)
if !ok {
h.respondError(w, http.StatusUnauthorized, "Unauthorized", "")
return
@@ -193,24 +194,25 @@ type RevokeKeyRequest struct {
}
// RevokeKey deactivates an API key
// DELETE /api/v1/admin/keys/:key
// DELETE /api/v1/admin/keys/:name
// Requires: admin role
func (h *KeyManagementHandler) RevokeKey(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
authCtx, ok := ctx.Value("auth").(*auth.AuthContext)
authCtx, ok := auth.GetAuthContext(ctx)
if !ok || !h.hasRole(authCtx, "admin") {
h.respondError(w, http.StatusForbidden, "Insufficient permissions", "admin role required")
return
}
// Extract key from path parameter
keyStr := strings.TrimPrefix(r.URL.Path, "/api/v1/admin/keys/")
// Extract key name from path parameter (not the raw key, to avoid leaking secrets in URLs)
vars := mux.Vars(r)
keyName := vars["name"]
var req RevokeKeyRequest
_ = json.NewDecoder(r.Body).Decode(&req) // Ignore decode errors, reason is optional
err := h.keyStore.DeactivateKey(ctx, keyStr, authCtx.ClientID)
err := h.keyStore.DeactivateKeyByName(ctx, keyName, authCtx.ClientID)
if err != nil {
if strings.Contains(err.Error(), "not found") {
h.respondError(w, http.StatusNotFound, "Key not found", "")
@@ -231,19 +233,19 @@ type RotateKeyRequest struct {
}
// RotateKey creates a new API key to replace the old one
// POST /api/v1/admin/keys/:key/rotate
// POST /api/v1/admin/keys/:name/rotate
// Requires: admin role
func (h *KeyManagementHandler) RotateKey(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
authCtx, ok := ctx.Value("auth").(*auth.AuthContext)
authCtx, ok := auth.GetAuthContext(ctx)
if !ok || !h.hasRole(authCtx, "admin") {
h.respondError(w, http.StatusForbidden, "Insufficient permissions", "admin role required")
return
}
oldKeyStr := strings.TrimPrefix(r.URL.Path, "/api/v1/admin/keys/")
oldKeyStr = strings.TrimSuffix(oldKeyStr, "/rotate")
vars := mux.Vars(r)
_ = vars["name"] // Key name from URL (rotation not yet implemented)
var req RotateKeyRequest
_ = json.NewDecoder(r.Body).Decode(&req)
@@ -265,19 +267,19 @@ type GetAuditLogResponse struct {
}
// GetAuditLog retrieves the audit log for a key
// GET /api/v1/admin/keys/:key/audit
// GET /api/v1/admin/keys/:name/audit
// Requires: admin role
func (h *KeyManagementHandler) GetAuditLog(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
authCtx, ok := ctx.Value("auth").(*auth.AuthContext)
authCtx, ok := auth.GetAuthContext(ctx)
if !ok || !h.hasRole(authCtx, "admin") {
h.respondError(w, http.StatusForbidden, "Insufficient permissions", "admin role required")
return
}
keyStr := strings.TrimPrefix(r.URL.Path, "/api/v1/admin/keys/")
keyStr = strings.TrimSuffix(keyStr, "/audit")
vars := mux.Vars(r)
keyName := vars["name"]
limit := 100
if limitStr := r.URL.Query().Get("limit"); limitStr != "" {
@@ -286,7 +288,7 @@ func (h *KeyManagementHandler) GetAuditLog(w http.ResponseWriter, r *http.Reques
}
}
logs, err := h.keyStore.GetAuditLog(ctx, keyStr, limit)
logs, err := h.keyStore.GetAuditLogByName(ctx, keyName, limit)
if err != nil {
h.logger.Errorf("Failed to get audit log: %v", err)
h.respondError(w, http.StatusInternalServerError, "Failed to get audit log", err.Error())
@@ -294,7 +296,7 @@ func (h *KeyManagementHandler) GetAuditLog(w http.ResponseWriter, r *http.Reques
}
resp := GetAuditLogResponse{
Key: "nk_" + keyStr[len(keyStr)-4:],
Key: keyName,
AuditLog: logs,
}
+17 -4
View File
@@ -86,20 +86,33 @@ func NewRouterWithAuthAndKeyStore(service domain.NotificationService, logger *lo
keyHandler := NewKeyManagementHandler(keyStore, logger)
v1.HandleFunc("/admin/keys", keyHandler.CreateKey).Methods(http.MethodPost)
v1.HandleFunc("/admin/keys", keyHandler.ListKeys).Methods(http.MethodGet)
v1.HandleFunc("/admin/keys/{key}", keyHandler.RevokeKey).Methods(http.MethodDelete)
v1.HandleFunc("/admin/keys/{key}/rotate", keyHandler.RotateKey).Methods(http.MethodPost)
v1.HandleFunc("/admin/keys/{key}/audit", keyHandler.GetAuditLog).Methods(http.MethodGet)
v1.HandleFunc("/admin/keys/{name}", keyHandler.RevokeKey).Methods(http.MethodDelete)
v1.HandleFunc("/admin/keys/{name}/rotate", keyHandler.RotateKey).Methods(http.MethodPost)
v1.HandleFunc("/admin/keys/{name}/audit", keyHandler.GetAuditLog).Methods(http.MethodGet)
}
// Health check route (no auth required)
router.HandleFunc("/health", handler.HealthCheck).Methods(http.MethodGet)
// Middleware - logging and CORS
// Middleware - logging, request size limit, and CORS
router.Use(loggingMiddleware)
v1.Use(maxBodySizeMiddleware(1 << 20)) // 1 MB limit on API request bodies
return router
}
// maxBodySizeMiddleware limits the size of incoming request bodies to prevent DoS.
func maxBodySizeMiddleware(maxBytes int64) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Body != nil {
r.Body = http.MaxBytesReader(w, r.Body, maxBytes)
}
next.ServeHTTP(w, r)
})
}
}
// loggingMiddleware logs incoming requests
func loggingMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {