Merge pull request #3015 from lightpanda-io/cdp-utf8-sanitization

cdp: Sanitize non-UTF 8 values
This commit is contained in:
Karl Seguin
2026-07-24 08:03:38 +08:00
committed by GitHub
4 changed files with 194 additions and 70 deletions

View File

@@ -1287,6 +1287,9 @@ fn frameHeaderDoneCallback(transfer: *HttpClient.Transfer) !HttpClient.Transfer.
// (allow/allowAndName) and the response carries Content-Disposition: attachment.
// See issue #2701.
fn maybeStartDownload(self: *Frame, transfer: *HttpClient.Transfer) !bool {
const uuidv4 = @import("../id.zig").uuidv4;
const latin1ToUtf8 = @import("../string.zig").latin1ToUtf8;
const session = self._session;
switch (session.download_behavior) {
.allow, .allow_and_name => {},
@@ -1314,11 +1317,16 @@ fn maybeStartDownload(self: *Frame, transfer: *HttpClient.Transfer) !bool {
// `guid` is the CDP "Global Unique Identifier" that ties the
// downloadWillBegin / downloadProgress events to one download.
var guid_buf: [36]u8 = undefined;
@import("../id.zig").uuidv4(&guid_buf);
uuidv4(&guid_buf);
const guid = try self.arena.dupe(u8, &guid_buf);
// Content-Disposition filenames can be in a legacy encoding (e.g.
// Shift_JIS) but must yield valid UTF-8
const suggested = dispositionFilename(disposition) orelse (try urlBasename(self.arena, self.url)) orelse guid;
const suggested_filename = try self.arena.dupe(u8, suggested);
const suggested_filename = if (std.unicode.utf8ValidateSlice(suggested))
try self.arena.dupe(u8, suggested)
else
try latin1ToUtf8(self.arena, suggested);
// allowAndName stores the file under its guid; allow uses the suggested name.
const on_disk_name = switch (session.download_behavior) {

126
src/cdp/SafeString.zig Normal file
View File

@@ -0,0 +1,126 @@
// Copyright (C) 2023-2026 Lightpanda (Selecy SAS)
//
// Francis Bouvier <francis@lightpanda.io>
// Pierre Tachoire <pierre@lightpanda.io>
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as
// published by the Free Software Foundation, either version 3 of the
// License, or (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
/// Wraps bytes that may not be valid UTF-8 — raw network octets like header
/// values. std.json serializes invalid UTF-8 as a JSON array of numbers,
/// which crashes CDP clients that expect a string (#2972, #2992). Non-UTF-8
/// bytes are interpreted as Latin-1 (ISO-8859-1) and transcoded, matching
/// Chrome's behavior for DevTools.
const std = @import("std");
const SafeString = @This();
bytes: []const u8,
pub fn wrap(bytes: []const u8) SafeString {
return .{ .bytes = bytes };
}
pub fn jsonStringify(self: *const SafeString, jws: anytype) !void {
if (std.unicode.utf8ValidateSlice(self.bytes)) {
return jws.write(self.bytes);
}
try jws.beginWriteRaw();
try writeQuoted(jws, self.bytes);
jws.endWriteRaw();
}
// Object keys can't take std.json's byte-array fallback: objectField writes
// invalid UTF-8 straight into the frame, and RFC 6455 lets strict clients
// kill the connection over an invalid UTF-8 text frame.
pub fn writeObjectField(jws: anytype, name: []const u8) !void {
if (std.unicode.utf8ValidateSlice(name)) {
return jws.objectField(name);
}
try jws.beginObjectFieldRaw();
try writeQuoted(jws, name);
jws.endObjectFieldRaw();
}
// Latin-1 -> UTF-8: each byte is a codepoint U+0000..U+00FF (max 2 bytes)
fn writeQuoted(jws: anytype, value: []const u8) !void {
try jws.writer.writeByte('"');
var start: usize = 0;
for (value, 0..) |b, i| {
if (b < 0x80) {
continue;
}
try std.json.Stringify.encodeJsonStringChars(value[start..i], jws.options, jws.writer);
var buf: [2]u8 = undefined;
const n = std.unicode.utf8Encode(b, &buf) catch unreachable;
try jws.writer.writeAll(buf[0..n]);
start = i + 1;
}
try std.json.Stringify.encodeJsonStringChars(value[start..], jws.options, jws.writer);
try jws.writer.writeByte('"');
}
test "cdp.SafeString: jsonStringify" {
const expectJson = struct {
fn expect(expected: []const u8, value: []const u8) !void {
var buf: [256]u8 = undefined;
var writer = std.Io.Writer.fixed(&buf);
var jws: std.json.Stringify = .{ .writer = &writer };
try jws.write(wrap(value));
try std.testing.expectEqualStrings(expected, writer.buffered());
}
}.expect;
// valid UTF-8 is written as-is
try expectJson(
"\"mié, 15 jul 2026 13:19:10 GMT\"",
"mié, 15 jul 2026 13:19:10 GMT",
);
// Latin-1 bytes are transcoded to UTF-8 instead of a byte array
try expectJson(
"\"mié, 15 jul 2026 13:19:10 GMT\"",
"mi\xE9, 15 jul 2026 13:19:10 GMT",
);
// JSON escaping still applies around transcoded bytes
try expectJson(
"\"a\\\"é\\nb\"",
"a\"\xE9\nb",
);
// pure ASCII untouched
try expectJson(
"\"max-age=180, s-maxage=180, public\"",
"max-age=180, s-maxage=180, public",
);
}
test "cdp.SafeString: writeObjectField" {
const expectJson = struct {
fn expect(expected: []const u8, name: []const u8) !void {
var buf: [256]u8 = undefined;
var writer = std.Io.Writer.fixed(&buf);
var jws: std.json.Stringify = .{ .writer = &writer };
try jws.beginObject();
try writeObjectField(&jws, name);
try jws.write(true);
try jws.endObject();
try std.testing.expectEqualStrings(expected, writer.buffered());
}
}.expect;
try expectJson("{\"etag\":true}", "etag");
try expectJson("{\"naïve\":true}", "na\xEFve");
try expectJson("{\"a\\\"é\":true}", "a\"\xE9");
}

View File

@@ -21,6 +21,7 @@ const lp = @import("lightpanda");
const id = @import("../id.zig");
const CDP = @import("../CDP.zig");
const SafeString = @import("../SafeString.zig");
const Config = @import("../../Config.zig");
const URL = @import("../../browser/URL.zig");
@@ -295,7 +296,9 @@ fn getResponseBody(cmd: *CDP.Command) !void {
const bc = cmd.browser_context orelse return error.BrowserContextNotLoaded;
const resp = bc.captured_responses.getPtr(key) orelse return error.RequestNotFound;
if (!resp.must_encode) {
// must_encode trusts the declared charset; a server can declare UTF-8 and
// still send invalid bytes.
if (!resp.must_encode and std.unicode.utf8ValidateSlice(resp.data.items)) {
return cmd.sendResult(.{
.body = resp.data.items,
.base64Encoded = false,
@@ -449,8 +452,8 @@ pub const RequestWriter = struct {
try jws.beginObject();
var it = request.headers.iterator();
while (it.next()) |hdr| {
try jws.objectField(hdr.name);
try writeHeaderValue(jws, hdr.value);
try SafeString.writeObjectField(jws, hdr.name);
try jws.write(SafeString.wrap(hdr.value));
}
if (try request.getCookieString(transfer.arena)) |cookies| {
try jws.objectField("Cookie");
@@ -554,8 +557,8 @@ const ResponseWriter = struct {
try jws.beginObject();
var map_it = map.iterator();
while (map_it.next()) |entry| {
try jws.objectField(entry.key_ptr.*);
try writeHeaderValue(jws, entry.value_ptr.*);
try SafeString.writeObjectField(jws, entry.key_ptr.*);
try jws.write(SafeString.wrap(entry.value_ptr.*));
}
try jws.endObject();
}
@@ -563,33 +566,6 @@ const ResponseWriter = struct {
}
};
// HTTP header values are octets; per historical practice non-UTF-8 bytes are
// interpreted as Latin-1 (ISO-8859-1), which is what Chrome does for DevTools.
// Transcode so we emit a JSON string — std.json would otherwise serialize
// invalid UTF-8 as a JSON array of numbers.
fn writeHeaderValue(jws: anytype, value: []const u8) !void {
if (std.unicode.utf8ValidateSlice(value)) {
return jws.write(value);
}
// Latin-1 -> UTF-8: each byte is a codepoint U+0000..U+00FF (max 2 bytes)
try jws.beginWriteRaw();
try jws.writer.writeByte('"');
var start: usize = 0;
for (value, 0..) |b, i| {
if (b < 0x80) {
continue;
}
try std.json.Stringify.encodeJsonStringChars(value[start..i], jws.options, jws.writer);
var buf: [2]u8 = undefined;
const n = std.unicode.utf8Encode(b, &buf) catch unreachable;
try jws.writer.writeAll(buf[0..n]);
start = i + 1;
}
try std.json.Stringify.encodeJsonStringChars(value[start..], jws.options, jws.writer);
try jws.writer.writeByte('"');
jws.endWriteRaw();
}
fn keyFromRequestId(request_id: []const u8) !CDP.BrowserContext.CapturedResponseKey {
const key = std.fmt.parseInt(u32, request_id[4..], 10) catch return error.InvalidParams;
@@ -726,42 +702,6 @@ test "cdp.network setExtraHTTPHeaders rejects a header that smuggles CRLF" {
try testing.expectEqual("x-keep: ok", std.mem.span(bc.extra_headers.items[0]));
}
test "cdp.network writeHeaderValue" {
const expectHeaderJson = struct {
fn expect(expected: []const u8, value: []const u8) !void {
var buf: [256]u8 = undefined;
var writer = std.Io.Writer.fixed(&buf);
var jws: std.json.Stringify = .{ .writer = &writer };
try writeHeaderValue(&jws, value);
try std.testing.expectEqualStrings(expected, writer.buffered());
}
}.expect;
// valid UTF-8 is written as-is
try expectHeaderJson(
"\"mié, 15 jul 2026 13:19:10 GMT\"",
"mié, 15 jul 2026 13:19:10 GMT",
);
// Latin-1 bytes are transcoded to UTF-8 instead of a byte array
try expectHeaderJson(
"\"mié, 15 jul 2026 13:19:10 GMT\"",
"mi\xE9, 15 jul 2026 13:19:10 GMT",
);
// JSON escaping still applies around transcoded bytes
try expectHeaderJson(
"\"a\\\"é\\nb\"",
"a\"\xE9\nb",
);
// pure ASCII untouched
try expectHeaderJson(
"\"max-age=180, s-maxage=180, public\"",
"max-age=180, s-maxage=180, public",
);
}
test "cdp.Network: cookies" {
const ResCookie = CdpStorage.ResCookie;
const CdpCookie = CdpStorage.CdpCookie;

View File

@@ -17,7 +17,9 @@
// along with this program. If not, see <https://www.gnu.org/licenses/>.
const std = @import("std");
const Allocator = std.mem.Allocator;
const IS_DEBUG = @import("builtin").mode == .Debug;
const M = @This();
@@ -412,6 +414,40 @@ pub fn truncateUtf8(bytes: []const u8, max_bytes: usize) []const u8 {
return bytes[0..i];
}
/// Reinterprets `bytes` as Latin-1 (each byte one codepoint) and encodes it
/// as UTF-8. For bytes that aren't valid UTF-8 but must become a valid UTF-8
/// string (JSON, filenames).
pub fn latin1ToUtf8(allocator: Allocator, bytes: []const u8) ![]u8 {
var extra: usize = 0;
for (bytes) |b| {
if (b >= 0x80) {
extra += 1;
}
}
if (comptime IS_DEBUG) {
// The way this is currently used:
// 1 - the caller always wants the value duped,
// 2 - the caller only got here because utf8ValidateSlice failed.
// If both of those ever change, maybe it's worth reconsidering whether
// this API unconditionally dupes.
std.debug.assert(extra != 0);
}
const out = try allocator.alloc(u8, bytes.len + extra);
var i: usize = 0;
for (bytes) |b| {
if (b < 0x80) {
out[i] = b;
i += 1;
} else {
out[i] = 0xC0 | (b >> 6);
out[i + 1] = 0x80 | (b & 0x3F);
i += 2;
}
}
return out;
}
// Discriminatory type that signals the bridge to use arena instead of call_arena
// Use this for strings that need to persist beyond the current call
// The caller can unwrap and store just the underlying .str field
@@ -458,6 +494,20 @@ test "truncateUtf8" {
try testing.expectEqual("\xFFx", truncateUtf8("\xFFx", 2));
}
test "latin1ToUtf8" {
const cases = [_]struct { in: []const u8, out: []const u8 }{
.{ .in = "caf\xE9.txt", .out = "café.txt" },
.{ .in = "report\xFF.csv", .out = "report\xC3\xBF.csv" },
.{ .in = "\x82\xd3\x82\xe9.xlsx", .out = "\xC2\x82\xC3\x93\xC2\x82\xC3\xA9.xlsx" },
};
for (cases) |case| {
const out = try latin1ToUtf8(testing.allocator, case.in);
defer testing.allocator.free(out);
try testing.expectEqual(case.out, out);
try testing.expectEqual(true, std.unicode.utf8ValidateSlice(out));
}
}
test "String" {
const other_short = try String.init(undefined, "other_short", .{});
const other_long = try String.init(testing.allocator, "other_long" ** 100, .{});