milestone 13 discrepancies: redact credentials from urls in logs, metrics and cli output

This commit is contained in:
2026-08-07 00:45:17 +02:00
parent 1ff727feb8
commit 8c3328562e
39 changed files with 5734 additions and 510 deletions
+155 -18
View File
@@ -16,11 +16,59 @@ const net = std.Io.net;
const tls = std.crypto.tls;
const Certificate = std.crypto.Certificate;
const safe_url = @import("../safe_url.zig");
const transport = @import("transport.zig");
const tls_client = @import("../platform/tls_client.zig");
const log = std.log.scoped(.dot_client);
/// One diagnostic line about one client, as a value rather than a format string
/// repeated at each call site.
///
/// Two reasons, in that order. The url is redacted in exactly one place, so a
/// line added later cannot print it whole — the defect this type closes was four
/// call sites each formatting `endpoint.url` with `{s}`, missed by three review
/// rounds because each looked like the three beside it. And a `std.log` line is
/// not readable from a unit test under the default test runner, which installs
/// its own `std_options`; the tests below read this value instead of stderr.
const Diagnostic = struct {
endpoint: transport.Endpoint,
detail: Detail,
const Detail = union(enum) {
/// `resolveAddress` refused the host.
not_an_ip_literal,
connect_failed: anyerror,
handshake_failed: Handshake,
bundle_load_failed: anyerror,
const Handshake = struct {
verify_name: []const u8,
cause: anyerror,
};
};
pub fn format(self: Diagnostic, w: *std.Io.Writer) std.Io.Writer.Error!void {
try w.print("dot upstream {f}: ", .{safe_url.redactQuoted(self.endpoint.url)});
switch (self.detail) {
// The redacted url ends in the host and the port, so naming the host
// again would add nothing but an unredacted copy of it.
.not_an_ip_literal => try w.writeAll("host is not an IP literal"),
.connect_failed => |err| try w.print("connect failed: {s}", .{@errorName(err)}),
// `verify_name` is a host name rather than a url, so it carries no
// component redaction could drop. It goes through `quoteText` for
// what that does to any operator-supplied string: it delimits it,
// escapes it and bounds it.
.handshake_failed => |hs| try w.print("TLS handshake as {f} failed: {s} ({t})", .{
safe_url.quoteText(hs.verify_name),
@errorName(hs.cause),
tls_client.classify(hs.cause),
}),
.bundle_load_failed => |err| try w.print("CA bundle load failed: {s}", .{@errorName(err)}),
}
}
};
pub const ResolveError = error{ConnectFailed};
/// DoT endpoints take IP literals. Name resolution for upstreams is out of
@@ -89,6 +137,10 @@ pub const DotClient = struct {
return .{ .ptr = self, .exchangeFn = exchangeFn };
}
fn diagnose(self: *const DotClient, detail: Diagnostic.Detail) Diagnostic {
return .{ .endpoint = self.endpoint, .detail = detail };
}
fn exchangeFn(
ptr: *anyopaque,
io: std.Io,
@@ -119,20 +171,14 @@ pub const DotClient = struct {
if (query.len > transport.max_message_len) return error.BufferTooSmall;
const address = resolveAddress(self.endpoint) catch |err| {
log.warn("dot upstream {s}: host \"{s}\" is not an IP literal", .{
self.endpoint.url,
self.endpoint.host,
});
log.warn("{f}", .{self.diagnose(.not_an_ip_literal)});
return err;
};
try self.ensureBundle(io);
var stream = address.connect(io, .{ .mode = .stream }) catch |err| {
log.debug("dot upstream {s}: connect failed: {s}", .{
self.endpoint.url,
@errorName(err),
});
log.debug("{f}", .{self.diagnose(.{ .connect_failed = err })});
return mapPhase(err, error.ConnectFailed);
};
defer closeStream(io, &stream);
@@ -155,12 +201,10 @@ pub const DotClient = struct {
.stream_write_buffer = self.buffers.stream_write,
}) catch |err| {
const cause = concreteHandshake(&tls_stream, err);
log.warn("dot upstream {s}: TLS handshake as \"{s}\" failed: {s} ({t})", .{
self.endpoint.url,
self.verify_name,
@errorName(cause),
tls_client.classify(cause),
});
log.warn("{f}", .{self.diagnose(.{ .handshake_failed = .{
.verify_name = self.verify_name,
.cause = cause,
} })});
return mapPhase(cause, error.TlsFailed);
};
defer closeTls(io, &tls_stream);
@@ -214,10 +258,7 @@ pub const DotClient = struct {
self.bundle.rescan(self.gpa, io, std.Io.Clock.real.now(io)) catch |err| {
self.bundle.deinit(self.gpa);
self.bundle.* = .empty;
log.warn("dot upstream {s}: CA bundle load failed: {s}", .{
self.endpoint.url,
@errorName(err),
});
log.warn("{f}", .{self.diagnose(.{ .bundle_load_failed = err })});
return mapPhase(err, error.TlsFailed);
};
}
@@ -286,6 +327,102 @@ fn receiveFailure(stream: *tls_client.TlsStream, err: anyerror) transport.Exchan
const testing = std.testing;
fn expectDiagnostic(expected: []const u8, url: []const u8, detail: Diagnostic.Detail) !void {
var buf: [8 * safe_url.max_len]u8 = undefined;
const line = try std.fmt.bufPrint(&buf, "{f}", .{Diagnostic{
.endpoint = .{ .scheme = .dot, .url = url, .host = "host.example", .port = 853, .path = "/" },
.detail = detail,
}});
try testing.expectEqualStrings(expected, line);
}
test "every diagnostic line redacts the url it names the upstream by" {
// The endpoints are built by hand rather than parsed, on purpose.
// `Endpoint.parse` refuses `@`, `?` and `#` in the authority and refuses a
// `.dot` path other than "/", so no url reaching this client through it can
// carry a credential in a component `redact` drops. That is a property of a
// parser one file away, not of this file, and these lines used to print
// whatever `endpoint.url` held. The redaction is what keeps the parser's
// rules from being load-bearing here.
try expectDiagnostic(
"dot upstream 'tls://dns.example': host is not an IP literal",
"tls://user:hunter2@dns.example/abcd12",
.not_an_ip_literal,
);
try expectDiagnostic(
"dot upstream 'tls://dns.example:8853': connect failed: ConnectionRefused",
"tls://dns.example:8853/abcd12",
.{ .connect_failed = error.ConnectionRefused },
);
try expectDiagnostic(
"dot upstream 'tls://dns.example': CA bundle load failed: FileNotFound",
"tls://dns.example/abcd12?apikey=s3cr3t",
.{ .bundle_load_failed = error.FileNotFound },
);
try expectDiagnostic(
"dot upstream 'tls://dns.example': TLS handshake as 'one.one.one.one' failed: " ++
"CertificateHostMismatch (certificate)",
"tls://token@dns.example/abcd12",
.{ .handshake_failed = .{
.verify_name = "one.one.one.one",
.cause = error.CertificateHostMismatch,
} },
);
}
test "a diagnostic escapes and bounds the operator-supplied text it prints" {
// A control byte in either field would forge a second record on the one
// output `std.log`'s own sink does not reach, and an unbounded host or
// verification name would write an unbounded line.
try expectDiagnostic(
"dot upstream 'tls://dns.example\\n2026-01-01 ERROR forged': host is not an IP literal",
"tls://dns.example\n2026-01-01 ERROR forged",
.not_an_ip_literal,
);
try expectDiagnostic(
"dot upstream 'tls://dns.example': TLS handshake as 'a\\nb' failed: TlsAlert (handshake)",
"tls://dns.example",
.{ .handshake_failed = .{ .verify_name = "a\nb", .cause = error.TlsAlert } },
);
// Doubling every operator-supplied field writes the same line, and each
// field is built from the three byte costs at once: a printable byte spends
// one character of the budget, `\n` spends two and `\x00` spends four. That
// expansion is why `safe_url.max_len` counts printed characters rather than
// source bytes, so the bound holds against the widest escape rather than in
// spite of it.
const long = "h\n\x00" ** (2 * safe_url.max_len);
const longer = long ** 2;
var short_buf: [16 * safe_url.max_len]u8 = undefined;
var long_buf: [16 * safe_url.max_len]u8 = undefined;
try testing.expectEqualStrings(
try std.fmt.bufPrint(&short_buf, "{f}", .{Diagnostic{
.endpoint = .{ .scheme = .dot, .url = "tls://" ++ long, .host = long, .port = 853, .path = "/" },
.detail = .{ .handshake_failed = .{ .verify_name = long, .cause = error.TlsAlert } },
}}),
try std.fmt.bufPrint(&long_buf, "{f}", .{Diagnostic{
.endpoint = .{ .scheme = .dot, .url = "tls://" ++ longer, .host = longer, .port = 853, .path = "/" },
.detail = .{ .handshake_failed = .{ .verify_name = longer, .cause = error.TlsAlert } },
}}),
);
}
test "a diagnostic keeps what a parsed DoT url carries" {
// The limit, pinned so it stays visible: a NextDNS DoT upstream is
// `tls://abcd12.dns.nextdns.io`, whose hostname is the whole account
// identifier. Redaction cannot remove it without leaving no host and an
// unactionable line. See `safe_url.SafeUrl`.
var buf: [8 * safe_url.max_len]u8 = undefined;
const line = try std.fmt.bufPrint(&buf, "{f}", .{Diagnostic{
.endpoint = try .parse("tls://abcd12.dns.nextdns.io"),
.detail = .not_an_ip_literal,
}});
try testing.expectEqualStrings(
"dot upstream 'tls://abcd12.dns.nextdns.io': host is not an IP literal",
line,
);
}
test "resolveAddress accepts IP literals" {
const v4 = try resolveAddress(try .parse("tls://1.1.1.1:853"));
try testing.expectEqual(@as(u16, 853), v4.ip4.port);