Skip to content

Commit 79cd990

Browse files
author
Adam Fisk
committed
egress: close the remaining ways an endpoint or address reaches a log
Round two on #417. redactEndpoint returned hostless values verbatim. url.Parse accepts "secret" and "http:token" without error, and clearing User does nothing to an opaque string, so a bare credential configured as the endpoint would have been echoed whole. Refused now, same rule sanitizeReportURL already used. The exporter-construction failure logged err directly, and an endpoint parse failure is exactly the error that quotes the endpoint back — with its credentials. The endpoint is substituted with its redacted form rather than dropped, so the diagnostic survives. The version line was emitted before instrument creation and RegisterCallback, both of which return through shutdownAfter, so it announced success for setup that could still fail. Moved to immediately before the successful return, still after enableOTELLogs. peerAttrs' doc comment had a stale paragraph explaining why remote_addr and forwarded_for were both logged, directly above the new paragraph explaining why neither is. A doc that promises address logging and denies it in the same breath is worse than either. Rewritten as one contract. Three tests added for things previously assertable only by inspection: the endpoint-redaction table including query, fragment and hostless cases; the enabled-path announcement against an httptest OTLP receiver with credentials in the endpoint; and the startup version line through the real initMetrics path, which the metrics tests miss because they stub initMetricsFn. Each verified against a mutation. Also corrected a comment of mine that said the enabled line is exported. It is emitted before the handler swap, so it is journal-only — which is deliberate, since a line reporting whether export works should not be routed through the exporter it describes.
1 parent ec533be commit 79cd990

4 files changed

Lines changed: 104 additions & 29 deletions

File tree

egress/egresslib_test.go

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,16 @@
11
package egress
22

33
import (
4+
"bytes"
45
"context"
56
"crypto/tls"
7+
"log/slog"
68
"net"
9+
"strings"
710
"testing"
811
"time"
12+
13+
"github.com/getlantern/broflake/common"
914
)
1015

1116
// TestNewListener_CleanShutdownDoesNotPanic is a regression test for the
@@ -53,3 +58,44 @@ func TestNewListener_CleanShutdownDoesNotPanic(t *testing.T) {
5358
// machine and doesn't meaningfully slow down the test suite.
5459
time.Sleep(100 * time.Millisecond)
5560
}
61+
62+
// The startup version line is the only thing that says which build is running:
63+
// the spans carry no service.version, so before this the answer lived on the
64+
// host. Asserted through the real initMetrics path — the metrics tests stub
65+
// initMetricsFn and the OTLP tests call enableOTELLogs directly, so neither
66+
// would notice this line disappearing.
67+
func TestNewListener_LogsTheRunningVersion(t *testing.T) {
68+
// No collector configured, so enableOTELLogs no-ops and this exercises the
69+
// version line alone rather than standing up an exporter.
70+
t.Setenv("OTEL_EXPORTER_OTLP_ENDPOINT", "")
71+
t.Setenv("OTEL_EXPORTER_OTLP_LOGS_ENDPOINT", "")
72+
73+
var buf bytes.Buffer
74+
prev := slog.Default()
75+
slog.SetDefault(slog.New(slog.NewTextHandler(&buf, &slog.HandlerOptions{Level: slog.LevelDebug})))
76+
t.Cleanup(func() { slog.SetDefault(prev) })
77+
78+
tcpL, err := net.Listen("tcp", "127.0.0.1:0")
79+
if err != nil {
80+
t.Fatalf("listen: %v", err)
81+
}
82+
t.Cleanup(func() { _ = tcpL.Close() })
83+
84+
ll, err := NewListener(context.Background(), tcpL, &tls.Config{
85+
NextProtos: []string{"broflake"},
86+
InsecureSkipVerify: true,
87+
})
88+
if err != nil {
89+
t.Fatalf("NewListener: %v", err)
90+
}
91+
t.Cleanup(func() { _ = ll.Close() })
92+
93+
out := buf.String()
94+
if !strings.Contains(out, "Egress telemetry initialized") {
95+
t.Fatalf("no startup line naming the build: %q", out)
96+
}
97+
if !strings.Contains(out, common.Version) {
98+
t.Errorf("the startup line does not carry %s, so a deploy stays unverifiable: %q",
99+
common.Version, out)
100+
}
101+
}

egress/otellogs.go

Lines changed: 17 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,12 @@ func enableOTELLogs(ctx context.Context) func(context.Context) error {
6969

7070
exp, err := otlploghttp.New(ctx)
7171
if err != nil {
72-
slog.Warn("Log export disabled; could not build the OTLP log exporter", "err", err)
72+
// The exporter reports what it could not parse, which for an endpoint
73+
// problem is the endpoint — credentials included. Substituted rather
74+
// than dropped, so the diagnostic survives without the secret.
75+
slog.Warn("Log export disabled; could not build the OTLP log exporter",
76+
"err", strings.ReplaceAll(err.Error(), endpoint, redactEndpoint(endpoint)),
77+
"endpoint", redactEndpoint(endpoint), "from", endpointVar)
7378
return func(context.Context) error { return nil }
7479
}
7580

@@ -189,14 +194,19 @@ func (h *teeHandler) WithGroup(name string) slog.Handler {
189194

190195
// redactEndpoint strips anything an OTLP endpoint could legally carry as a
191196
// credential before it reaches a log. These URLs are configuration rather than
192-
// user input, but "https://user:token@collector/v1/logs" is a valid value and
193-
// this line is written to the journal and exported, so the raw form is the one
194-
// thing not worth printing. Scheme, host and path are what make the line useful.
197+
// user input, but "https://user:token@collector/v1/logs" and
198+
// "https://collector/v1/logs?api-key=..." are both valid values, and the journal
199+
// is read by more people than the config is. Scheme, host and path are what make
200+
// the line useful.
201+
//
202+
// Anything without a host is refused outright rather than returned. url.Parse
203+
// accepts opaque and hostless strings — "secret", "http:token" — without error,
204+
// and clearing User does nothing to those, so returning the parsed form would
205+
// echo the whole value. Same rule as sanitizeReportURL: with no structure to
206+
// rely on, there is no way to tell which part was secret.
195207
func redactEndpoint(raw string) string {
196208
u, err := url.Parse(raw)
197-
if err != nil {
198-
// Do not echo a value that failed to parse: with no structure to rely
199-
// on, there is no way to tell which part of it was secret.
209+
if err != nil || u.Host == "" {
200210
return "(unparseable)"
201211
}
202212
u.User = nil

egress/otellogs_test.go

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -400,3 +400,29 @@ func TestEnableOTELLogs_AnnouncesItselfAndRedactsTheEndpoint(t *testing.T) {
400400
t.Error("no OTLP request reached the collector; export is announced but not wired")
401401
}
402402
}
403+
404+
// redactEndpoint's edge cases, which the enabled-path test cannot reach: it
405+
// supplies one realistic endpoint, so a mutation dropping the query/fragment
406+
// stripping — or returning a hostless value verbatim — would stay green there.
407+
func TestRedactEndpoint(t *testing.T) {
408+
for _, tc := range []struct{ name, in, want string }{
409+
{"plain endpoint survives", "https://collector:4318/v1/logs", "https://collector:4318/v1/logs"},
410+
{"userinfo is dropped", "https://user:token@collector/v1/logs", "https://collector/v1/logs"},
411+
{"query is dropped", "https://collector/v1/logs?api-key=SECRET", "https://collector/v1/logs"},
412+
{"fragment is dropped", "https://collector/v1/logs#SECRET", "https://collector/v1/logs"},
413+
{"bare query marker is dropped", "https://collector/v1/logs?", "https://collector/v1/logs"},
414+
{"everything at once", "https://u:p@collector/v1/logs?k=S#f", "https://collector/v1/logs"},
415+
// url.Parse accepts these without error, and clearing User does nothing
416+
// to them — returning the parsed form would echo the secret whole.
417+
{"opaque value is refused", "http:token", "(unparseable)"},
418+
{"bare word is refused", "secret", "(unparseable)"},
419+
{"hostless path is refused", "/v1/logs?api-key=SECRET", "(unparseable)"},
420+
{"empty is refused", "", "(unparseable)"},
421+
} {
422+
t.Run(tc.name, func(t *testing.T) {
423+
if got := redactEndpoint(tc.in); got != tc.want {
424+
t.Errorf("redactEndpoint(%q) = %q, want %q", tc.in, got, tc.want)
425+
}
426+
})
427+
}
428+
}

egress/refusals.go

Lines changed: 15 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -166,32 +166,25 @@ func classifySubprotocolRefusal(raw, parsed []string) (reason refusalReason, msg
166166
}
167167
}
168168

169-
// peerAttrs returns slog attributes identifying who was refused.
170-
//
171-
// RemoteAddr alone is useless here: Caddy terminates TLS on :443 and proxies to
172-
// localhost:9001, so every RemoteAddr is 127.0.0.1 with an ephemeral port. The
173-
// real client is in X-Forwarded-For, which Caddy sets. Both are logged because
174-
// their disagreement is itself information — an X-Forwarded-For present with a
175-
// non-loopback RemoteAddr would mean something is reaching 9001 without going
176-
// through Caddy.
177-
//
178-
// User-Agent is the field that actually discriminates the hypotheses: one
179-
// repeated UA points at a misdirected health check or monitor, many distinct ones
180-
// point at real clients failing the handshake.
181169
// peerAttrs describes the peer behind a request without recording who it is.
182170
//
183171
// Country rather than address, deliberately. The egress necessarily sees the
184-
// donor's IP — it is the other end of the socket — but seeing it and writing it
185-
// into a log are different things: a log is retained, copied and queried, and
186-
// these are the addresses of people running circumvention software. The country
187-
// is what the diagnostics actually need, and it is already what the metrics
188-
// carry (attrDonorCountry), so the log and the counters now describe a peer the
189-
// same way.
172+
// donor's IP — it is the other end of the socket, and behind Caddy it arrives
173+
// as X-Forwarded-For — but seeing it and writing it into a log are different
174+
// things: a log is retained, copied and queried, and these are the addresses of
175+
// people running circumvention software. The country is what the diagnostics
176+
// actually need, and it is already what the metrics carry (attrDonorCountry),
177+
// so the log and the counters now describe a peer the same way.
178+
//
179+
// User-Agent is the field that discriminates the hypotheses these lines exist to
180+
// separate: one repeated UA points at a misdirected health check or monitor,
181+
// many distinct ones point at real clients failing the handshake. It identifies
182+
// a client build rather than a person, so it stays.
190183
//
191-
// What this gives up: an individual host is no longer identifiable from logs.
192-
// A refused population can still be characterised — country, user agent, and
193-
// the raw subprotocol values distinguish client builds — but pinpointing one
194-
// machine now has to be done live on the box rather than after the fact.
184+
// What this gives up: an individual host is no longer identifiable from logs. A
185+
// refused population can still be characterised — country, user agent and the
186+
// raw subprotocol values distinguish client builds — but pinpointing one machine
187+
// now has to happen live on the box rather than after the fact.
195188
func peerAttrs(r *http.Request) []any {
196189
return []any{
197190
"donor_country", donorCountry(donorGeoAddr(r, transportAddr(r))),

0 commit comments

Comments
 (0)