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
12 changes: 11 additions & 1 deletion src/cdp/cdp.zig
Original file line number Diff line number Diff line change
Expand Up @@ -477,12 +477,16 @@ pub fn BrowserContext(comptime CDP_T: type) type {
self.cdp.browser.notification.unregister(.http_response_header_done, self);
}

pub fn fetchEnable(self: *Self) !void {
pub fn fetchEnable(self: *Self, authRequests: bool) !void {
try self.cdp.browser.notification.register(.http_request_intercept, self, onHttpRequestIntercept);
if (authRequests) {
try self.cdp.browser.notification.register(.http_request_auth_required, self, onHttpRequestAuthRequired);
}
}

pub fn fetchDisable(self: *Self) void {
self.cdp.browser.notification.unregister(.http_request_intercept, self);
self.cdp.browser.notification.unregister(.http_request_auth_required, self);
}

pub fn onPageRemove(ctx: *anyopaque, _: Notification.PageRemove) !void {
Expand Down Expand Up @@ -548,6 +552,12 @@ pub fn BrowserContext(comptime CDP_T: type) type {
try gop.value_ptr.appendSlice(arena, try arena.dupe(u8, msg.data));
}

pub fn onHttpRequestAuthRequired(ctx: *anyopaque, data: *const Notification.RequestAuthRequired) !void {
const self: *Self = @alignCast(@ptrCast(ctx));
defer self.resetNotificationArena();
try @import("domains/fetch.zig").requestAuthRequired(self.notification_arena, self, data);
}

fn resetNotificationArena(self: *Self) void {
defer _ = self.cdp.notification_arena.reset(.{ .retain_with_limit = 1024 * 64 });
}
Expand Down
109 changes: 104 additions & 5 deletions src/cdp/domains/fetch.zig
Original file line number Diff line number Diff line change
Expand Up @@ -32,12 +32,14 @@ pub fn processMessage(cmd: anytype) !void {
continueRequest,
failRequest,
fulfillRequest,
continueWithAuth,
}, cmd.input.action) orelse return error.UnknownMethod;

switch (action) {
.disable => return disable(cmd),
.enable => return enable(cmd),
.continueRequest => return continueRequest(cmd),
.continueWithAuth => return continueWithAuth(cmd),
.failRequest => return failRequest(cmd),
.fulfillRequest => return fulfillRequest(cmd),
}
Expand Down Expand Up @@ -144,12 +146,8 @@ fn enable(cmd: anytype) !void {
return cmd.sendResult(null, .{});
}

if (params.handleAuthRequests) {
log.warn(.cdp, "not implemented", .{ .feature = "Fetch.enable handleAuthRequests is not supported yet" });
}

const bc = cmd.browser_context orelse return error.BrowserContextNotLoaded;
try bc.fetchEnable();
try bc.fetchEnable(params.handleAuthRequests);

return cmd.sendResult(null, .{});
}
Expand Down Expand Up @@ -276,6 +274,63 @@ fn continueRequest(cmd: anytype) !void {
return cmd.sendResult(null, .{});
}

// https://chromedevtools.github.io/devtools-protocol/tot/Fetch/#type-AuthChallengeResponse
const AuthChallengeResponse = enum {
Default,
CancelAuth,
ProvideCredentials,
};

fn continueWithAuth(cmd: anytype) !void {
const bc = cmd.browser_context orelse return error.BrowserContextNotLoaded;
const params = (try cmd.params(struct {
requestId: []const u8, // "INTERCEPT-{d}"
authChallengeResponse: struct {
response: AuthChallengeResponse,
username: []const u8 = "",
password: []const u8 = "",
},
})) orelse return error.InvalidParams;

const page = bc.session.currentPage() orelse return error.PageNotLoaded;

var intercept_state = &bc.intercept_state;
const request_id = try idFromRequestId(params.requestId);
const transfer = intercept_state.remove(request_id) orelse return error.RequestNotFound;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we should add a flag to make sure that it was a auth interception and not a normal intercept.
Otherwise the situation may occur that a user sends a continueWithAuth as a responce to a requestPaused.
In that case for example if we call abortAuthChallenge we do not do the correct cleanup. In general we do not know what could happen, so better to catch it.

Copy link
Member Author

@krichprollsch krichprollsch Aug 26, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What about testing if the transfer has a auth challenge?
Or you do want a complete separate intercept_state list?

Copy link
Contributor

@sjorsdonkers sjorsdonkers Aug 27, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Checking and I guess then removing the auth challenge should work, then the opposite for continue/abort/fulfill


log.debug(.cdp, "request intercept", .{
.state = "continue with auth",
.id = transfer.id,
.response = params.authChallengeResponse.response,
});

if (params.authChallengeResponse.response != .ProvideCredentials) {
transfer.abortAuthChallenge();
return cmd.sendResult(null, .{});
}

// cancel the request, deinit the transfer on error.
errdefer transfer.abortAuthChallenge();

// restart the request with the provided credentials.
const arena = transfer.arena.allocator();
transfer.updateCredentials(
try std.fmt.allocPrintZ(arena, "{s}:{s}", .{
params.authChallengeResponse.username,
params.authChallengeResponse.password,
}),
);

transfer.reset();
try bc.cdp.browser.http_client.process(transfer);

if (intercept_state.empty()) {
page.request_intercepted = false;
}

return cmd.sendResult(null, .{});
}

fn fulfillRequest(cmd: anytype) !void {
const bc = cmd.browser_context orelse return error.BrowserContextNotLoaded;

Expand Down Expand Up @@ -346,6 +401,50 @@ fn failRequest(cmd: anytype) !void {
return cmd.sendResult(null, .{});
}

pub fn requestAuthRequired(arena: Allocator, bc: anytype, intercept: *const Notification.RequestAuthRequired) !void {
// unreachable because we _have_ to have a page.
const session_id = bc.session_id orelse unreachable;
const target_id = bc.target_id orelse unreachable;
const page = bc.session.currentPage() orelse unreachable;

// We keep it around to wait for modifications to the request.
// NOTE: we assume whomever created the request created it with a lifetime of the Page.
// TODO: What to do when receiving replies for a previous page's requests?

const transfer = intercept.transfer;
try bc.intercept_state.put(transfer);

const challenge = transfer._auth_challenge orelse return error.NullAuthChallenge;

try bc.cdp.sendEvent("Fetch.authRequired", .{
.requestId = try std.fmt.allocPrint(arena, "INTERCEPT-{d}", .{transfer.id}),
.request = network.TransferAsRequestWriter.init(transfer),
.frameId = target_id,
.resourceType = switch (transfer.req.resource_type) {
.script => "Script",
.xhr => "XHR",
.document => "Document",
},
.authChallenge = .{
.source = if (challenge.source == .server) "Server" else "Proxy",
.origin = "", // TODO get origin, could be the proxy address for example.
.scheme = if (challenge.scheme == .digest) "digest" else "basic",
.realm = challenge.realm,
},
.networkId = try std.fmt.allocPrint(arena, "REQ-{d}", .{transfer.id}),
}, .{ .session_id = session_id });

log.debug(.cdp, "request auth required", .{
.state = "paused",
.id = transfer.id,
.url = transfer.uri,
});
// Await continueWithAuth

intercept.wait_for_interception.* = true;
page.request_intercepted = true;
}

// Get u64 from requestId which is formatted as: "INTERCEPT-{d}"
fn idFromRequestId(request_id: []const u8) !u64 {
if (!std.mem.startsWith(u8, request_id, "INTERCEPT-")) {
Expand Down
130 changes: 123 additions & 7 deletions src/http/Client.zig
Original file line number Diff line number Diff line change
Expand Up @@ -325,6 +325,11 @@ fn makeRequest(self: *Client, handle: *Handle, transfer: *Transfer) !void {
}

try errorCheck(c.curl_easy_setopt(easy, c.CURLOPT_PRIVATE, transfer));

// add credentials
if (req.credentials) |creds| {
try errorCheck(c.curl_easy_setopt(easy, c.CURLOPT_PROXYUSERPWD, creds.ptr));
}
}

// Once soon as this is called, our "perform" loop is responsible for
Expand Down Expand Up @@ -365,6 +370,22 @@ fn perform(self: *Client, timeout_ms: c_int) !void {
const easy = msg.easy_handle.?;
const transfer = try Transfer.fromEasy(easy);

// In case of auth challenge
if (transfer._auth_challenge != null and transfer._tries < 10) { // TODO give a way to configure the number of auth retries.
if (transfer.client.notification) |notification| {
var wait_for_interception = false;
notification.dispatch(.http_request_auth_required, &.{ .transfer = transfer, .wait_for_interception = &wait_for_interception });
if (wait_for_interception) {
// the request is put on hold to be intercepted.
// In this case we ignore callbacks for now.
// Note: we don't deinit transfer on purpose: we want to keep
// using it for the following request.
self.endTransfer(transfer);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wonder whether we need to send a transfer.resetCallback as consumer may need to reset their state for the replaced intercept to be sent.
Or we can make it part of the contract that this is the case when transfer.start_callback is called for the same transfer, but we probably should document that perhaps near: start_callback: ?*const fn (transfer: *Transfer) anyerror!void = null, in Request.

| don't see issue with the current consumers implementation.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hum, I suppose the point of the interception is to take place during the request lifecycle. So the following callbacks will be called depending the result of the interception and the optional request following.

continue;
}
}
}

// release it ASAP so that it's available; some done_callbacks
// will load more resources.
self.endTransfer(transfer);
Expand Down Expand Up @@ -542,6 +563,7 @@ pub const Request = struct {
body: ?[]const u8 = null,
cookie_jar: *CookieJar,
resource_type: ResourceType,
credentials: ?[:0]const u8 = null,

// arbitrary data that can be associated with this request
ctx: *anyopaque = undefined,
Expand All @@ -559,6 +581,46 @@ pub const Request = struct {
};
};

pub const AuthChallenge = struct {
status: u16,
source: enum { server, proxy },
scheme: enum { basic, digest },
realm: []const u8,

pub fn parse(status: u16, header: []const u8) !AuthChallenge {
var ac: AuthChallenge = .{
.status = status,
.source = undefined,
.realm = "TODO", // TODO parser and set realm
.scheme = undefined,
};

const sep = std.mem.indexOfPos(u8, header, 0, ": ") orelse return error.InvalidHeader;
const hname = header[0..sep];
const hvalue = header[sep + 2 ..];

if (std.ascii.eqlIgnoreCase("WWW-Authenticate", hname)) {
ac.source = .server;
} else if (std.ascii.eqlIgnoreCase("Proxy-Authenticate", hname)) {
ac.source = .proxy;
} else {
return error.InvalidAuthChallenge;
}

const pos = std.mem.indexOfPos(u8, std.mem.trim(u8, hvalue, std.ascii.whitespace[0..]), 0, " ") orelse hvalue.len;
const _scheme = hvalue[0..pos];
if (std.ascii.eqlIgnoreCase(_scheme, "basic")) {
ac.scheme = .basic;
} else if (std.ascii.eqlIgnoreCase(_scheme, "digest")) {
ac.scheme = .digest;
} else {
return error.UnknownAuthChallengeScheme;
}

return ac;
}
};

pub const Transfer = struct {
arena: ArenaAllocator,
id: usize = 0,
Expand All @@ -571,7 +633,6 @@ pub const Transfer = struct {
bytes_received: usize = 0,

// We'll store the response header here
proxy_response_header: ?ResponseHeader = null,
response_header: ?ResponseHeader = null,

// track if the header callbacks done have been called.
Expand All @@ -582,7 +643,22 @@ pub const Transfer = struct {
_handle: ?*Handle = null,

_redirecting: bool = false,
_forbidden: bool = false,
_auth_challenge: ?AuthChallenge = null,

// number of times the transfer has been tried.
// incremented by reset func.
_tries: u8 = 0,

pub fn reset(self: *Transfer) void {
self._redirecting = false;
self._auth_challenge = null;
self._notified_fail = false;
self._header_done_called = false;
self.response_header = null;
self.bytes_received = 0;

self._tries += 1;
}

fn deinit(self: *Transfer) void {
self.req.headers.deinit();
Expand All @@ -600,7 +676,11 @@ pub const Transfer = struct {
try errorCheck(c.curl_easy_getinfo(easy, c.CURLINFO_EFFECTIVE_URL, &url));

var status: c_long = undefined;
try errorCheck(c.curl_easy_getinfo(easy, c.CURLINFO_RESPONSE_CODE, &status));
if (self._auth_challenge) |_| {
status = 407;
} else {
try errorCheck(c.curl_easy_getinfo(easy, c.CURLINFO_RESPONSE_CODE, &status));
}

self.response_header = .{
.url = url,
Expand Down Expand Up @@ -633,6 +713,10 @@ pub const Transfer = struct {
self.req.url = url;
}

pub fn updateCredentials(self: *Transfer, userpwd: [:0]const u8) void {
self.req.credentials = userpwd;
}

pub fn replaceRequestHeaders(self: *Transfer, allocator: Allocator, headers: []const Http.Header) !void {
self.req.headers.deinit();

Expand All @@ -657,6 +741,14 @@ pub const Transfer = struct {
self.deinit();
}

// abortAuthChallenge is called when an auth chanllenge interception is
// abort. We don't call self.client.endTransfer here b/c it has been done
// before interception process.
pub fn abortAuthChallenge(self: *Transfer) void {
self.client.requestFailed(self, error.AbortAuthChallenge);
self.deinit();
}

// redirectionCookies manages cookies during redirections handled by Curl.
// It sets the cookies from the current response to the cookie jar.
// It also immediately sets cookies for the following request.
Expand Down Expand Up @@ -782,20 +874,44 @@ pub const Transfer = struct {
transfer._redirecting = false;

if (status == 401 or status == 407) {
transfer._forbidden = true;
// The auth challenge must be parsed from a following
// WWW-Authenticate or Proxy-Authenticate header.
transfer._auth_challenge = .{
.status = status,
.source = undefined,
.scheme = undefined,
.realm = undefined,
};
return buf_len;
}
transfer._forbidden = false;
transfer._auth_challenge = null;

transfer.bytes_received = buf_len;
return buf_len;
}

if (transfer._redirecting == false and transfer._forbidden == false) {
if (transfer._redirecting == false and transfer._auth_challenge != null) {
transfer.bytes_received += buf_len;
}

if (buf_len != 2) {
if (transfer._auth_challenge != null) {
// try to parse auth challenge.
if (std.ascii.startsWithIgnoreCase(header, "WWW-Authenticate") or
std.ascii.startsWithIgnoreCase(header, "Proxy-Authenticate"))
{
const ac = AuthChallenge.parse(
transfer._auth_challenge.?.status,
header,
) catch |err| {
// We can't parse the auth challenge
log.err(.http, "parse auth challenge", .{ .err = err, .header = header });
// Should we cancel the request? I don't think so.
return buf_len;
};
transfer._auth_challenge = ac;
}
}
return buf_len;
}

Expand Down Expand Up @@ -823,7 +939,7 @@ pub const Transfer = struct {
return c.CURL_WRITEFUNC_ERROR;
};

if (transfer._redirecting) {
if (transfer._redirecting or transfer._auth_challenge != null) {
return chunk_len;
}

Expand Down
Loading