Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Security

- **CVE-2026-44982 / GHSA-rw47-hm26-6wr7**: Resolved high-severity CrowdSec AppSec vulnerability where HTTP request bodies were silently dropped for chunked/HTTP-2 requests, allowing WAF bypass
- Upgraded `CROWDSEC_VERSION` to `v1.7.8` in the Dockerfile
- Upgraded `caddy-crowdsec-bouncer` to `v0.12.1` to align with the updated crowdsec API
- Applied build-time source patches for two breaking API changes in crowdsec v1.7.8 (`DecisionsListOpts` field pointer types, `version.DetectOS()` return arity)

- **CVE-2026-34040**: Remediated high-severity vulnerability by migrating from `github.com/docker/docker` to `github.com/moby/moby/client v0.4.1`
- Affected component: Docker client SDK used for container management features
- Resolution: Updated `go.mod` to reference the actively maintained `moby/moby` module
Expand Down
54 changes: 52 additions & 2 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -242,6 +242,7 @@ ARG XCADDY_VERSION=0.4.6
ARG EXPR_LANG_VERSION
ARG XNET_VERSION
ARG XCRYPTO_VERSION
ARG CROWDSEC_VERSION

# hadolint ignore=DL3018
RUN apk add --no-cache bash git
Expand All @@ -257,6 +258,18 @@ RUN --mount=type=cache,target=/go/pkg/mod \
RUN --mount=type=cache,target=/root/.cache/go-build \
--mount=type=cache,target=/go/pkg/mod \
bash -c 'set -e; \
# Restore any module cache files patched by a previous build run.
# xcaddy Stage 1 resolves crowdsec to its native version (v1.6.x, IPEquals *string).
# If a prior build left IPEquals: value, (plain string) in the cache, xcaddy fails.
_GOMC="$(go env GOMODCACHE)"; \
for _PF in \
"${_GOMC}/github.com/hslatman/caddy-crowdsec-bouncer@v0.12.1/internal/bouncer/live.go" \
"${_GOMC}/github.com/crowdsecurity/go-cs-bouncer@v0.0.14/live_bouncer.go"; do \
if [ -f "${_PF}" ]; then \
chmod +w "${_PF}"; \
sed -i "s/IPEquals: value,/IPEquals: \&value,/g" "${_PF}"; \
fi; \
done; \
CADDY_TARGET_VERSION="${CADDY_VERSION}"; \
if [ "${CADDY_USE_CANDIDATE}" = "1" ]; then \
CADDY_TARGET_VERSION="${CADDY_CANDIDATE_VERSION}"; \
Expand All @@ -270,7 +283,7 @@ RUN --mount=type=cache,target=/root/.cache/go-build \
--with github.com/caddyserver/caddy/v2@v${CADDY_TARGET_VERSION} \
--with github.com/greenpau/caddy-security@v${CADDY_SECURITY_VERSION} \
--with github.com/corazawaf/coraza-caddy/v2@v${CORAZA_CADDY_VERSION} \
--with github.com/hslatman/caddy-crowdsec-bouncer@v0.10.0 \
--with github.com/hslatman/caddy-crowdsec-bouncer@v0.12.1 \
--with github.com/zhangjiayin/caddy-geoip2 \
--with github.com/mholt/caddy-ratelimit \
--output /tmp/caddy-initial; \
Expand Down Expand Up @@ -321,6 +334,13 @@ RUN --mount=type=cache,target=/root/.cache/go-build \
# Affects /usr/bin/caddy (transitive dependency). Fix available at v0.1.1.
# renovate: datasource=go depName=github.com/Azure/go-ntlmssp
go get github.com/Azure/go-ntlmssp@v0.1.1; \
# CVE-2026-44982 (GHSA-rw47-hm26-6wr7): CrowdSec AppSec silently drops HTTP request
# body for chunked/HTTP-2 requests, bypassing WAF body inspection rules.
# caddy-crowdsec-bouncer@v0.12.1 was built against crowdsec v1.6.3 whose
# DecisionsListOpts fields were *string; v1.7.8 changed them to plain string.
# The source-level incompatibility is patched below via local copy + go.mod replace.
# Remove once bouncer ships against crowdsec >= v1.7.8.
go get github.com/crowdsecurity/crowdsec@v${CROWDSEC_VERSION}; \
if [ "${CADDY_PATCH_SCENARIO}" = "A" ]; then \
# Rollback scenario: keep explicit nebula pin if upstream compatibility regresses.
# NOTE: smallstep/certificates (pulled by caddy-security stack) currently
Expand All @@ -340,6 +360,36 @@ RUN --mount=type=cache,target=/root/.cache/go-build \
go get github.com/caddyserver/caddy/v2@v${CADDY_TARGET_VERSION}; \
# Clean up go.mod and ensure all dependencies are resolved
go mod tidy; \
# Patch DecisionsListOpts API: crowdsec v1.7.8 changed fields (IPEquals, ScopeEquals,
# etc.) from *string to plain string. caddy-crowdsec-bouncer@v0.12.1 and its transitive
# dep go-cs-bouncer@v0.0.14 still use the old pointer form.
# Strategy: copy modules to ephemeral /tmp dirs and use go.mod replace directives.
# This avoids modifying the shared BuildKit module cache, which would corrupt xcaddy
# Stage 1 of subsequent builds (where these modules are compiled with crowdsec v1.6.x).
go mod download github.com/hslatman/caddy-crowdsec-bouncer@v0.12.1; \
BOUNCER_CACHE="${_GOMC}/github.com/hslatman/caddy-crowdsec-bouncer@v0.12.1"; \
BOUNCER_LOCAL="/tmp/bouncer-patched"; \
rm -rf "${BOUNCER_LOCAL}"; \
cp -r "${BOUNCER_CACHE}/." "${BOUNCER_LOCAL}/"; \
chmod -R +w "${BOUNCER_LOCAL}"; \
sed -i "s/IPEquals: &value,/IPEquals: value,/g" "${BOUNCER_LOCAL}/internal/bouncer/live.go"; \
echo "Patched caddy-crowdsec-bouncer at ${BOUNCER_LOCAL}"; \
go mod edit -replace "github.com/hslatman/caddy-crowdsec-bouncer@v0.12.1=${BOUNCER_LOCAL}"; \
GO_CS_CACHE="${_GOMC}/github.com/crowdsecurity/go-cs-bouncer@v0.0.14"; \
if [ -d "${GO_CS_CACHE}" ]; then \
GO_CS_LOCAL="/tmp/go-cs-bouncer-patched"; \
rm -rf "${GO_CS_LOCAL}"; \
cp -r "${GO_CS_CACHE}/." "${GO_CS_LOCAL}/"; \
chmod -R +w "${GO_CS_LOCAL}"; \
sed -i "s/IPEquals: &value,/IPEquals: value,/g" "${GO_CS_LOCAL}/live_bouncer.go"; \
sed -i "s/ScopeEquals: &value,/ScopeEquals: value,/g" "${GO_CS_LOCAL}/live_bouncer.go"; \
sed -i "s/ValueEquals: &value,/ValueEquals: value,/g" "${GO_CS_LOCAL}/live_bouncer.go"; \
sed -i "s/TypeEquals: &value,/TypeEquals: value,/g" "${GO_CS_LOCAL}/live_bouncer.go"; \
sed -i "s/RangeEquals: &value,/RangeEquals: value,/g" "${GO_CS_LOCAL}/live_bouncer.go"; \
sed -i "s/osName, osVersion := version.DetectOS()/osName, osVersion, _ := version.DetectOS()/g" "${GO_CS_LOCAL}/metrics.go"; \
echo "Patched go-cs-bouncer at ${GO_CS_LOCAL}"; \
go mod edit -replace "github.com/crowdsecurity/go-cs-bouncer@v0.0.14=${GO_CS_LOCAL}"; \
fi; \
# Hard assertion: fail if module graph resolves to a different Caddy core version.
ACTUAL_CADDY_VERSION="$(go list -m -f "{{.Version}}" github.com/caddyserver/caddy/v2)"; \
if [ "$ACTUAL_CADDY_VERSION" != "v${CADDY_TARGET_VERSION}" ]; then \
Expand Down Expand Up @@ -415,7 +465,7 @@ RUN go get github.com/expr-lang/expr@v${EXPR_LANG_VERSION} && \
go get github.com/aws/aws-sdk-go-v2/service/cloudwatchlogs@v1.74.0 && \
go get github.com/aws/aws-sdk-go-v2/service/kinesis@v1.43.7 && \
# renovate: datasource=go depName=github.com/aws/aws-sdk-go-v2/service/s3
go get github.com/aws/aws-sdk-go-v2/service/s3@v1.101.0 && \
go get github.com/aws/aws-sdk-go-v2/service/s3@v1.102.0 && \
# CVE-2026-32952: go-ntlmssp DoS via malicious NTLM challenge response
# Affects /usr/local/bin/cscli (transitive dependency). Fix available at v0.1.1.
# renovate: datasource=go depName=github.com/Azure/go-ntlmssp
Expand Down
46 changes: 45 additions & 1 deletion SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,51 @@ public disclosure.

## Known Vulnerabilities

Last reviewed: 2026-05-25
Last reviewed: 2026-05-27

### [RESOLVED] GHSA-rw47-hm26-6wr7 / CVE-2026-44982 · CrowdSec AppSec Drops HTTP Request Body

| Field | Value |
|--------------|-------|
| **ID** | GHSA-rw47-hm26-6wr7 / CVE-2026-44982 |
| **Severity** | High |
| **Status** | Resolved — crowdsec upgraded to v1.7.8 |

**What**
The CrowdSec AppSec component silently dropped the HTTP request body for chunked-encoded or
HTTP/2 requests, causing the Web Application Firewall rules to operate on an empty body. This
allowed malicious payloads in those request types to bypass WAF inspection.

**Who**

- Discovered by: CrowdSec security team
- Reported: 2026-05-27 (via GHSA advisory)
- Affects: Charon deployments with the AppSec/WAF security module enabled

**Where**

- Component: `github.com/crowdsecurity/crowdsec` (via `caddy-crowdsec-bouncer`)
- Versions affected: crowdsec < v1.7.8

**When**

- Discovered: 2026-05-27
- Fixed upstream: crowdsec v1.7.8
- Resolved in Charon: 2026-05-27

**How**
The body reader in the AppSec engine did not correctly buffer chunked or HTTP/2 request bodies
before passing them to the WAF rule evaluation pipeline. Requests with these transfer encodings
would present an empty body to inspection rules, meaning payload-based WAF rules had no effect.

**Resolution**
Upgraded `CROWDSEC_VERSION` to `v1.7.8` in the Dockerfile. The `caddy-crowdsec-bouncer` module
(upgraded to `v0.12.1`) now builds against crowdsec v1.7.8 which contains the body-reader fix.
Two source-level compatibility patches are applied at build time to handle breaking API changes
introduced between v1.6.x and v1.7.8 (`DecisionsListOpts` field types and
`version.DetectOS()` return signature).

---

### [HIGH] CVE-2026-31790 · OpenSSL Vulnerability in Alpine Base Image

Expand Down
95 changes: 95 additions & 0 deletions backend/internal/hecate/providers/cloudflare/coverage_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,13 @@ package cloudflare

import (
"context"
"errors"
"fmt"
"net/http"
"net/http/httptest"
"os"
"os/exec"
"path/filepath"
"testing"
"time"

Expand Down Expand Up @@ -207,3 +209,96 @@ func TestListTunnels_RequestBuildError(t *testing.T) {
_, err := c.ListTunnels(context.Background())
require.Error(t, err)
}

// TestStart_StdoutPipeError covers the true branch of the stdout pipe error guard (lines 132–133)
// by injecting osPipe to fail on the first call.
func TestStart_StdoutPipeError(t *testing.T) {
dir := t.TempDir()
fakeBin := filepath.Join(dir, "cloudflared")
require.NoError(t, os.WriteFile(fakeBin, []byte("not elf"), 0o755)) //nolint:gosec

p := &CloudflareTunnelProvider{
binaryPath: fakeBin,
creds: cfCredentials{TunnelToken: "tok"},
buf: hecate.NewRingBuffer(1000),
}

orig := osPipe
t.Cleanup(func() { osPipe = orig })
osPipe = func() (*os.File, *os.File, error) {
return nil, nil, errors.New("simulated stdout pipe failure")
}

err := p.Start(context.Background())

require.Error(t, err)
assert.Contains(t, err.Error(), "stdout pipe")
assert.Equal(t, hecate.TunnelStateConnecting, p.Status())
}

// TestStart_StderrPipeError covers the true branch of the stderr pipe error guard (lines 136–139)
// by injecting osPipe to succeed on the first call and fail on the second.
func TestStart_StderrPipeError(t *testing.T) {
dir := t.TempDir()
fakeBin := filepath.Join(dir, "cloudflared")
require.NoError(t, os.WriteFile(fakeBin, []byte("not elf"), 0o755)) //nolint:gosec

p := &CloudflareTunnelProvider{
binaryPath: fakeBin,
creds: cfCredentials{TunnelToken: "tok"},
buf: hecate.NewRingBuffer(1000),
}

calls := 0
origPipe := osPipe
t.Cleanup(func() { osPipe = origPipe })
osPipe = func() (*os.File, *os.File, error) {
calls++
if calls == 1 {
return origPipe()
}
return nil, nil, errors.New("simulated stderr pipe failure")
}

err := p.Start(context.Background())

require.Error(t, err)
assert.Contains(t, err.Error(), "stderr pipe")
assert.Equal(t, hecate.TunnelStateConnecting, p.Status())
}

// TestStart_WriteEndCloseErrors covers the error-log branches for closeWriteFile (lines 161, 163, 166, 168)
// by injecting closeWriteFile to physically close the file (unblocking scanners) but also return an error.
func TestStart_WriteEndCloseErrors(t *testing.T) {
trueBin, err := exec.LookPath("true")
if err != nil {
t.Skip("true binary not available")
}

p := &CloudflareTunnelProvider{
binaryPath: trueBin,
creds: cfCredentials{TunnelToken: "tok"},
buf: hecate.NewRingBuffer(1000),
}

origClose := closeWriteFile
t.Cleanup(func() { closeWriteFile = origClose })
closeWriteFile = func(f *os.File) error {
_ = f.Close()
return errors.New("simulated write-end close error")
}

startErr := p.Start(context.Background())

require.NoError(t, startErr, "close errors are logged, not returned from Start()")

p.mu.RLock()
done := p.done
p.mu.RUnlock()

select {
case <-done:
case <-time.After(5 * time.Second):
t.Fatal("timed out waiting for cloudflared goroutines to exit")
}
}
44 changes: 40 additions & 4 deletions backend/internal/hecate/providers/cloudflare/provider.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,18 @@ import (
"time"

"github.com/Wikid82/charon/backend/internal/hecate"
"github.com/Wikid82/charon/backend/internal/logger"
"github.com/Wikid82/charon/backend/internal/models"
"github.com/sirupsen/logrus"
)

// Test hooks to allow overriding OS functions in unit tests.
var (
// osPipe wraps os.Pipe to allow simulating pipe-creation failures.
osPipe = os.Pipe
// closeWriteFile wraps (*os.File).Close for the pipe write-ends closed
// after cmd.Start() succeeds. Allows simulating close errors in tests.
closeWriteFile = func(f *os.File) error { return f.Close() }
)

// cfCredentials holds the decrypted JSON credentials for the Cloudflare provider.
Expand Down Expand Up @@ -126,23 +137,46 @@ func (p *CloudflareTunnelProvider) Start(ctx context.Context) error {
cmd := exec.CommandContext(ctx, binaryPath, "tunnel", "run") //nolint:gosec
cmd.Env = append(os.Environ(), "TUNNEL_TOKEN="+p.creds.TunnelToken)

stdoutPipe, err := cmd.StdoutPipe()
stdoutR, stdoutW, err := osPipe()
if err != nil {
return fmt.Errorf("cloudflare: stdout pipe: %w", err)
}
stderrPipe, err := cmd.StderrPipe()
stderrR, stderrW, err := osPipe()
if err != nil {
_ = stdoutR.Close()
_ = stdoutW.Close()
return fmt.Errorf("cloudflare: stderr pipe: %w", err)
}

cmd.Stdout = stdoutW
cmd.Stderr = stderrW

if err := cmd.Start(); err != nil {
_ = stdoutR.Close()
_ = stdoutW.Close()
_ = stderrR.Close()
_ = stderrW.Close()
p.mu.Lock()
p.state = hecate.TunnelStateError
close(p.done)
p.mu.Unlock()
return fmt.Errorf("cloudflare: start cloudflared: %w", err)
}

// Close the write ends in the parent process. The child holds its own
// copies via exec.Cmd; keeping parent write ends open would prevent the
// read ends from reaching EOF when the child exits.
if err := closeWriteFile(stdoutW); err != nil {
logger.Log().WithFields(logrus.Fields{
"error": err,
}).Error("cloudflare: failed to close stdout write end")
}
if err := closeWriteFile(stderrW); err != nil {
logger.Log().WithFields(logrus.Fields{
"error": err,
}).Error("cloudflare: failed to close stderr write end")
}

p.mu.Lock()
p.cmd = cmd
p.state = hecate.TunnelStateConnected
Expand All @@ -154,7 +188,8 @@ func (p *CloudflareTunnelProvider) Start(ctx context.Context) error {
scanWg.Add(1)
go func() {
defer scanWg.Done()
s := bufio.NewScanner(stdoutPipe)
defer stdoutR.Close() //nolint:errcheck
s := bufio.NewScanner(stdoutR)
for s.Scan() {
p.buf.Write(s.Text())
}
Expand All @@ -164,7 +199,8 @@ func (p *CloudflareTunnelProvider) Start(ctx context.Context) error {
scanWg.Add(1)
go func() {
defer scanWg.Done()
s := bufio.NewScanner(stderrPipe)
defer stderrR.Close() //nolint:errcheck
s := bufio.NewScanner(stderrR)
for s.Scan() {
p.buf.Write(s.Text())
}
Expand Down
Loading
Loading