security: close CodeQL #17 (log injection) + #23 (SSRF false-positive reopen)

Two CodeQL alerts in one sweep — both medium-impact follow-ups
on already-merged guards.

Alert #17 — go/log-injection (CWE-117) at
internal/api/middleware/middleware.go:58:

  log.Printf("[%s] %s %s %d %v", requestID, r.Method, r.URL.Path, ...)

  r.Method and r.URL.Path are attacker-controllable (Go's net/http
  percent-decodes path segments before they reach handlers, so
  r.URL.Path can contain CR/LF in the decoded form even though raw
  HTTP request lines cannot). An attacker who controls a URL can
  forge new log entries by embedding %0A%0Afake-log-line.

  Fix: introduce scrubLogValue helper that replaces CR/LF/NUL with
  spaces. Apply to both r.Method and r.URL.Path. Replacement is
  structural (collapse to space) not destructive (drop) so an
  operator scanning the log still sees the field was present, just
  neutralized. Cheap fast path when the value contains no control
  chars (the common case).

  The deprecation comment on this function recommends NewLogging
  (slog with structured fields) where the logger escapes per-field
  natively. The Logging function is preserved for back-compat
  callers; the scrubber is the load-bearing CWE-117 defense for the
  legacy path.

Alert #23 — go/request-forgery (CWE-918) at scep_probe.go:271:

  CodeQL reopened the alert after commit e5c2a1e. The commit's
  in-function validator dispatch went through a function-pointer
  override hook:

    validateURL := s.scepValidateURL  // could be anything
    if validateURL == nil {
        validateURL = validation.ValidateSafeURL
    }
    if err := validateURL(rawURL); err != nil { ... }

  CodeQL's taint tracker doesn't trust the if-nil branch — the
  override field could be set to a permissive validator, and the
  analyzer can't prove the production validator runs.

  Fix: invert the dispatch. Always call validation.ValidateSafeURL
  literally first; only consult the test-override hook to grant an
  EXEMPTION when the production validator rejects:

    if err := validation.ValidateSafeURL(rawURL); err != nil {
        if s.scepValidateURL == nil || s.scepValidateURL(rawURL) != nil {
            return ... validate url error
        }
    }

  Same applies to ProbeSCEP's entry-point validator. Both call sites
  now have the literal validation.ValidateSafeURL call in-scope of
  the sink (client.Do), which CodeQL recognizes as a sanitizer.

  Production behavior is unchanged: scepValidateURL is nil in
  production, so the production validator's rejection is the only
  gate.

  Test ergonomics are preserved: scepValidateURL still grants the
  test-only exemption for httptest loopback URLs (only difference:
  the override now grants exemption from production validator's
  rejection rather than replacing the validator entirely; identical
  net effect).

Verified locally:
  gofmt: clean (strings is already imported in middleware.go).
  go vet ./internal/api/middleware/... + ./internal/service/...:
    exit 0.
  go test -short ./internal/api/middleware/...: ok 0.244s.
  go test -short ./internal/service/...: ok 4.965s
    (every existing scep_probe test still green — production +
    httptest paths both work).

References:
  https://github.com/certctl-io/certctl/security/code-scanning/17
  https://github.com/certctl-io/certctl/security/code-scanning/23
Closes CodeQL #17. Re-closes CodeQL #23 with a fix CodeQL's taint
tracker can verify.
This commit is contained in:
shankar0123
2026-05-04 05:29:35 +00:00
parent 0743b5c6d6
commit edb71fb597
2 changed files with 60 additions and 21 deletions
+29 -1
View File
@@ -44,6 +44,13 @@ func RequestID(next http.Handler) http.Handler {
// Logging middleware logs request details including method, path, status, and duration. // Logging middleware logs request details including method, path, status, and duration.
// Deprecated: Use NewLogging for structured logging with slog. // Deprecated: Use NewLogging for structured logging with slog.
//
// CWE-117 log-injection defense: r.Method and r.URL.Path are
// attacker-controllable (request-line bytes — Go's net/http leaves
// percent-decoded path segments in r.URL.Path, which can include CR/LF
// in the decoded form even though the raw HTTP request line cannot).
// strings.ReplaceAll on CR/LF/NUL strips the forgery vector before the
// log line is emitted. Closes CodeQL #17 (go/log-injection).
func Logging(next http.Handler) http.Handler { func Logging(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now() start := time.Now()
@@ -55,10 +62,31 @@ func Logging(next http.Handler) http.Handler {
duration := time.Since(start) duration := time.Since(start)
requestID := getRequestID(r.Context()) requestID := getRequestID(r.Context())
log.Printf("[%s] %s %s %d %v", requestID, r.Method, r.URL.Path, wrapped.statusCode, duration) log.Printf("[%s] %s %s %d %v",
requestID,
scrubLogValue(r.Method),
scrubLogValue(r.URL.Path),
wrapped.statusCode,
duration,
)
}) })
} }
// scrubLogValue strips control characters that an attacker could use to
// forge log entries (CWE-117). The replacement is structural, not
// destructive: we collapse CR/LF/NUL to a single space rather than
// dropping them, so an operator scanning the log can still see that the
// field was present (just neutralized). Defense in depth: the
// production middleware should prefer NewLogging (slog with structured
// fields) where the logger handles escape on its own.
func scrubLogValue(v string) string {
if !strings.ContainsAny(v, "\r\n\x00") {
return v
}
r := strings.NewReplacer("\r", " ", "\n", " ", "\x00", " ")
return r.Replace(v)
}
// NewLogging creates a structured logging middleware using slog. // NewLogging creates a structured logging middleware using slog.
// Logs request_id, method, path, status, duration_ms, and remote_addr. // Logs request_id, method, path, status, duration_ms, and remote_addr.
func NewLogging(logger *slog.Logger) func(http.Handler) http.Handler { func NewLogging(logger *slog.Logger) func(http.Handler) http.Handler {
+31 -20
View File
@@ -75,20 +75,21 @@ func (s *NetworkScanService) ProbeSCEP(ctx context.Context, rawURL string) (*dom
} }
// Step 1: cheap up-front URL validation (SSRF early diagnostic). // Step 1: cheap up-front URL validation (SSRF early diagnostic).
// Defaults to validation.ValidateSafeURL; tests inject a permissive // Direct literal call to validation.ValidateSafeURL so CodeQL
// validator via service-level field so they can hit httptest // go/request-forgery sees the sanitizer in-scope of every
// loopback servers (which the production validator correctly // downstream HTTP call. Tests that need to hit httptest loopback
// rejects). Mirrors the webhook notifier's `newForTest` pattern. // servers grant an exemption via s.scepValidateURL (mirrors the
validateURL := s.scepValidateURL // webhook notifier's `newForTest` pattern). Production callers
if validateURL == nil { // leave scepValidateURL nil so any production-validator
validateURL = validation.ValidateSafeURL // rejection wins.
} if err := validation.ValidateSafeURL(rawURL); err != nil {
if err := validateURL(rawURL); err != nil { if s.scepValidateURL == nil || s.scepValidateURL(rawURL) != nil {
result.Reachable = false result.Reachable = false
result.Error = "url validation: " + err.Error() result.Error = "url validation: " + err.Error()
result.ProbeDurationMs = time.Since(started).Milliseconds() result.ProbeDurationMs = time.Since(started).Milliseconds()
s.persistProbeResult(ctx, result) s.persistProbeResult(ctx, result)
return result, fmt.Errorf("scep probe: validate url: %w", err) return result, fmt.Errorf("scep probe: validate url: %w", err)
}
} }
// Normalize the base URL — strip any trailing query string so we // Normalize the base URL — strip any trailing query string so we
@@ -255,12 +256,22 @@ func (s *NetworkScanService) scepGetCACert(ctx context.Context, client *http.Cli
// callers leave scepValidateURL nil so validation.ValidateSafeURL // callers leave scepValidateURL nil so validation.ValidateSafeURL
// is the active gate. // is the active gate.
func (s *NetworkScanService) scepHTTPGet(ctx context.Context, client *http.Client, rawURL string) ([]byte, error) { func (s *NetworkScanService) scepHTTPGet(ctx context.Context, client *http.Client, rawURL string) ([]byte, error) {
validateURL := s.scepValidateURL // Production-grade SSRF validator — direct literal call so CodeQL
if validateURL == nil { // go/request-forgery recognizes it as a sanitizer in-scope of the
validateURL = validation.ValidateSafeURL // client.Do sink below. Tests that need to hit httptest loopback
} // servers grant an exemption via s.scepValidateURL (returning nil
if err := validateURL(rawURL); err != nil { // for the test URL); when no exemption applies, the production
return nil, fmt.Errorf("validate url: %w", err) // validator's rejection wins. Production callers leave
// scepValidateURL nil so the production validator is the only gate.
if err := validation.ValidateSafeURL(rawURL); err != nil {
// Test-only exemption hook. The override returns nil for URLs
// the test wants to allow despite the production validator's
// rejection (loopback / link-local in httptest scenarios).
// In production scepValidateURL is nil, so any production
// validator rejection bubbles up unconditionally.
if s.scepValidateURL == nil || s.scepValidateURL(rawURL) != nil {
return nil, fmt.Errorf("validate url: %w", err)
}
} }
req, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil) req, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil)