v0.20.0 mtls hardening, fail-closed config, startup warnings, sink fixes

This commit is contained in:
lixen committed 2026-10-02 05:00:33 -04:00
1 parent 815ef345ea
commit f192c26fa2
37 files changed
+1042 -152

No files matched your search

+10 -4
View File
@@ -44,6 +44,7 @@ const (
DefaultHTTPChainSinkRequestTimeoutMS = 10000
DefaultHTTPChainSinkBackoffMinMS = 500
DefaultHTTPChainSinkBackoffMaxMS = 30000
maxResponseDrain = 64 * 1024
)
// HTTPChainSink batches structured entries and posts NDJSON to a downstream
@@ -92,7 +93,7 @@ func NewHTTPChainSinkPlugin(
proxy *session.Proxy,
) (sink.Sink, error) {
opts := &config.HTTPChainSinkOptions{}
if err := lconfig.ScanMap(configMap, opts); err != nil {
if err := config.Scan(configMap, opts); err != nil {
return nil, fmt.Errorf("failed to parse config: %w", err)
}
if err := lconfig.NonEmpty(opts.Host); err != nil {
@@ -182,7 +183,11 @@ func NewHTTPChainSinkPlugin(
mtls: tlsCfg != nil && len(tlsCfg.Certificates) > 0,
auth: authPolicy,
url: scheme + "://" + addr + opts.IngestPath,
client: &http.Client{Transport: transport},
// A redirect would resend the batch, even from https to plaintext http
client: &http.Client{
Transport: transport,
CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse },
},
input: make(chan core.TransportEvent, opts.BufferSize),
done: make(chan struct{}),
logger: logger,
@@ -208,6 +213,7 @@ func NewHTTPChainSinkPlugin(
"tls", t.tlsEnabled,
"mtls", t.mtls,
"auth", authPolicy.Describe())
tlsx.LogWarnings(logger, "http_chain_sink", id, opts.TLS, false)
return t, nil
}
@@ -436,8 +442,8 @@ func (t *HTTPChainSink) post(ctx context.Context, body []byte) (transient bool,
return true, err
}
defer resp.Body.Close()
// Drain for connection reuse
io.Copy(io.Discard, resp.Body)
// Drain for connection reuse, bounded so a hostile peer cannot stream forever
io.Copy(io.Discard, io.LimitReader(resp.Body, maxResponseDrain))
switch {
case resp.StatusCode >= 200 && resp.StatusCode < 300:
+47
View File
@@ -0,0 +1,47 @@
package httpchain
import (
"net"
"net/http"
"net/http/httptest"
"strconv"
"sync/atomic"
"testing"
"time"
"logwisp/internal/session"
"github.com/lixenwraith/log"
)
// A redirect is never followed: it would resend the batch wherever the
// response points, plaintext http included.
func TestRedirectIsNotFollowed(t *testing.T) {
var leaked atomic.Bool
target := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) { leaked.Store(true) }))
t.Cleanup(target.Close)
redirector := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, target.URL+r.URL.Path, http.StatusPermanentRedirect)
}))
t.Cleanup(redirector.Close)
host, port, err := net.SplitHostPort(redirector.Listener.Addr().String())
if err != nil {
t.Fatal(err)
}
p, _ := strconv.ParseInt(port, 10, 64)
manager := session.NewManager(time.Hour)
t.Cleanup(manager.Stop)
created, err := NewHTTPChainSinkPlugin("fwd", map[string]any{"host": host, "port": p},
log.NewLogger(), session.NewProxy(manager, "fwd"))
if err != nil {
t.Fatal(err)
}
transient, err := created.(*HTTPChainSink).post(t.Context(), []byte("{}\n"))
if err == nil || transient {
t.Fatalf("post = (transient %v, %v), want a permanent error", transient, err)
}
if leaked.Load() {
t.Fatal("the batch followed the redirect")
}
}