Fix medium-severity issues from follow-up security audit

Addresses the remaining medium findings from the second-pass audit:

- DoS hardening: bound GraphQL query cost with FixedComplexityLimit, and
  reject non-positive items-per-page / page values so uint64 conversion
  cannot underflow into an unbounded LIMIT/OFFSET. The -1 "load all"
  sentinel stays valid for dashboards; REST now returns 400 for bad input.

- Security headers: add X-Content-Type-Options, X-Frame-Options,
  Referrer-Policy and a conservative CSP (frame-ancestors/object-src/
  base-uri) that hardens against clickjacking and base-tag injection
  without restricting the self-hosted SPA's inline scripts.

- Stored XSS: render job.metaData.message as escaped text instead of
  {@html ...} in Job.root and JobFootprint, preserving line breaks via
  white-space: pre-wrap.

- SQL injection hardening: parameterize the tag-scope IN list and the
  manager project subquery in CountTags instead of interpolating
  user.Username / user.Projects (externally sourced via OIDC/LDAP).

- CSRF defense-in-depth: reject cross-site state-changing requests via
  Sec-Fetch-Site, failing open for non-browser clients, on top of the
  existing SameSite=Lax session cookie.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Entire-Checkpoint: de7d47a85c7c
This commit is contained in:
2026-06-04 20:08:41 +02:00
parent 6d86690c76
commit 16942f55a0
6 changed files with 75 additions and 15 deletions

View File

@@ -23,6 +23,7 @@ import (
"github.com/99designs/gqlgen/graphql"
"github.com/99designs/gqlgen/graphql/handler"
"github.com/99designs/gqlgen/graphql/handler/extension"
"github.com/99designs/gqlgen/graphql/handler/transport"
"github.com/99designs/gqlgen/graphql/playground"
"github.com/ClusterCockpit/cc-backend/internal/api"
@@ -50,6 +51,12 @@ const (
envDebug = "DEBUG"
)
// maxQueryComplexity bounds the cost of a single GraphQL query to mitigate
// denial-of-service via deeply nested or heavily aliased queries. The default
// per-field cost is 1, so this leaves ample headroom for legitimate dashboard
// queries while rejecting pathological ones.
const maxQueryComplexity = 5000
// Server encapsulates the HTTP server state and dependencies
type Server struct {
router chi.Router
@@ -90,6 +97,7 @@ func (s *Server) init() error {
generated.NewExecutableSchema(generated.Config{Resolvers: resolver}))
graphQLServer.AddTransport(transport.POST{})
graphQLServer.Use(extension.FixedComplexityLimit(maxQueryComplexity))
// Inject a per-request stats cache so that grouped statistics queries
// sharing the same (filter, groupBy) pair are executed only once.
@@ -136,8 +144,38 @@ func (s *Server) init() error {
}))
s.router.Use(func(next http.Handler) http.Handler {
return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
h := rw.Header()
h.Set("X-Content-Type-Options", "nosniff")
h.Set("X-Frame-Options", "DENY")
h.Set("Referrer-Policy", "same-origin")
// Conservative CSP: blocks clickjacking (frame-ancestors), plugin
// content (object-src) and <base> injection (base-uri) without
// restricting scripts/styles, so it cannot break the self-hosted
// SPA which relies on inline scripts. A full script-src policy needs
// per-template nonces and should be added separately.
h.Set("Content-Security-Policy", "frame-ancestors 'none'; object-src 'none'; base-uri 'self'")
if r.TLS != nil || r.Header.Get("X-Forwarded-Proto") == "https" {
rw.Header().Set("Strict-Transport-Security", "max-age=31536000; includeSubDomains")
h.Set("Strict-Transport-Security", "max-age=31536000; includeSubDomains")
}
next.ServeHTTP(rw, r)
})
})
// CSRF defense-in-depth on top of the SameSite=Lax session cookie: reject
// cross-site state-changing requests. Modern browsers set Sec-Fetch-Site on
// every request, so this stops a malicious site from driving cookie-
// authenticated POST/PUT/DELETE/PATCH calls. It fails open when the header is
// absent or not "cross-site", so non-browser API clients and the same-origin
// SPA are unaffected.
s.router.Use(func(next http.Handler) http.Handler {
return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet, http.MethodHead, http.MethodOptions, http.MethodTrace:
default:
if r.Header.Get("Sec-Fetch-Site") == "cross-site" {
http.Error(rw, "cross-site request blocked", http.StatusForbidden)
return
}
}
next.ServeHTTP(rw, r)
})