mirror of
https://github.com/oven-sh/bun
synced 2026-02-09 10:28:47 +00:00
## Summary
Add `proxy` option to WebSocket constructor for connecting through HTTP
CONNECT proxies.
### Features
- Support for `ws://` and `wss://` through HTTP proxies
- Support for `ws://` and `wss://` through HTTPS proxies (with
`rejectUnauthorized: false`)
- Proxy authentication via URL credentials (Basic auth)
- Custom proxy headers support
- Full TLS options (`ca`, `cert`, `key`, etc.) for target connections
using `SSLConfig.fromJS`
### API
```javascript
// String format
new WebSocket("wss://example.com", { proxy: "http://proxy:8080" })
// With credentials
new WebSocket("wss://example.com", { proxy: "http://user:pass@proxy:8080" })
// Object format with custom headers
new WebSocket("wss://example.com", {
proxy: { url: "http://proxy:8080", headers: { "X-Custom": "value" } }
})
// HTTPS proxy
new WebSocket("ws://example.com", {
proxy: "https://proxy:8443",
tls: { rejectUnauthorized: false }
})
```
### Implementation
| File | Changes |
|------|---------|
| `WebSocketUpgradeClient.zig` | Proxy state machine and CONNECT
handling |
| `WebSocketProxyTunnel.zig` | **New** - TLS tunnel inside CONNECT for
wss:// through HTTP proxy |
| `JSWebSocket.cpp` | Parse proxy option and TLS options using
`SSLConfig.fromJS` |
| `WebSocket.cpp` | Pass proxy parameters to Zig, handle HTTPS proxy
socket selection |
| `bun.d.ts` | Add `proxy` and full TLS options to WebSocket types |
### Supported Scenarios
| Scenario | Status |
|----------|--------|
| ws:// through HTTP proxy | ✅ Working |
| wss:// through HTTP proxy | ✅ Working (TLS tunnel) |
| ws:// through HTTPS proxy | ✅ Working (with `rejectUnauthorized:
false`) |
| wss:// through HTTPS proxy | ✅ Working (with `rejectUnauthorized:
false`) |
| Proxy authentication (Basic) | ✅ Working |
| Custom proxy headers | ✅ Working |
| Custom CA for HTTPS proxy | ✅ Working |
## Test plan
- [x] API tests verify proxy option is accepted in various formats
- [x] Functional tests with local HTTP CONNECT proxy server
- [x] Proxy authentication tests (Basic auth)
- [x] HTTPS proxy tests with `rejectUnauthorized: false`
- [x] Error handling tests (auth failures, wrong credentials)
Run tests: `bun test test/js/web/websocket/websocket-proxy.test.ts`
## Changelog
- Added `proxy` option to `WebSocket` constructor for HTTP/HTTPS proxy
support
- Added full TLS options (`ca`, `cert`, `key`, `passphrase`, etc.) to
`WebSocket` constructor
🤖 Generated with [Claude Code](https://claude.com/claude-code)
---------
Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
434 lines
15 KiB
Zig
434 lines
15 KiB
Zig
const SSLConfig = @This();
|
|
|
|
server_name: ?[*:0]const u8 = null,
|
|
|
|
key_file_name: ?[*:0]const u8 = null,
|
|
cert_file_name: ?[*:0]const u8 = null,
|
|
|
|
ca_file_name: ?[*:0]const u8 = null,
|
|
dh_params_file_name: ?[*:0]const u8 = null,
|
|
|
|
passphrase: ?[*:0]const u8 = null,
|
|
|
|
key: ?[][*:0]const u8 = null,
|
|
cert: ?[][*:0]const u8 = null,
|
|
ca: ?[][*:0]const u8 = null,
|
|
|
|
secure_options: u32 = 0,
|
|
request_cert: i32 = 0,
|
|
reject_unauthorized: i32 = 0,
|
|
ssl_ciphers: ?[*:0]const u8 = null,
|
|
protos: ?[*:0]const u8 = null,
|
|
client_renegotiation_limit: u32 = 0,
|
|
client_renegotiation_window: u32 = 0,
|
|
requires_custom_request_ctx: bool = false,
|
|
is_using_default_ciphers: bool = true,
|
|
low_memory_mode: bool = false,
|
|
|
|
const ReadFromBlobError = bun.JSError || error{
|
|
NullStore,
|
|
NotAFile,
|
|
EmptyFile,
|
|
};
|
|
|
|
fn readFromBlob(
|
|
global: *jsc.JSGlobalObject,
|
|
blob: *bun.webcore.Blob,
|
|
) ReadFromBlobError![:0]const u8 {
|
|
const store = blob.store orelse return error.NullStore;
|
|
const file = switch (store.data) {
|
|
.file => |f| f,
|
|
else => return error.NotAFile,
|
|
};
|
|
var fs: jsc.Node.fs.NodeFS = .{};
|
|
const maybe = fs.readFileWithOptions(
|
|
.{ .path = file.pathlike },
|
|
.sync,
|
|
.null_terminated,
|
|
);
|
|
const result = switch (maybe) {
|
|
.result => |result| result,
|
|
.err => |err| return global.throwValue(err.toJS(global)),
|
|
};
|
|
if (result.null_terminated.len == 0) return error.EmptyFile;
|
|
return bun.default_allocator.dupeZ(u8, result.null_terminated);
|
|
}
|
|
|
|
pub fn asUSockets(this: *const SSLConfig) uws.SocketContext.BunSocketContextOptions {
|
|
var ctx_opts: uws.SocketContext.BunSocketContextOptions = .{};
|
|
|
|
if (this.key_file_name != null)
|
|
ctx_opts.key_file_name = this.key_file_name;
|
|
if (this.cert_file_name != null)
|
|
ctx_opts.cert_file_name = this.cert_file_name;
|
|
if (this.ca_file_name != null)
|
|
ctx_opts.ca_file_name = this.ca_file_name;
|
|
if (this.dh_params_file_name != null)
|
|
ctx_opts.dh_params_file_name = this.dh_params_file_name;
|
|
if (this.passphrase != null)
|
|
ctx_opts.passphrase = this.passphrase;
|
|
ctx_opts.ssl_prefer_low_memory_usage = @intFromBool(this.low_memory_mode);
|
|
|
|
if (this.key) |key| {
|
|
ctx_opts.key = key.ptr;
|
|
ctx_opts.key_count = @intCast(key.len);
|
|
}
|
|
if (this.cert) |cert| {
|
|
ctx_opts.cert = cert.ptr;
|
|
ctx_opts.cert_count = @intCast(cert.len);
|
|
}
|
|
if (this.ca) |ca| {
|
|
ctx_opts.ca = ca.ptr;
|
|
ctx_opts.ca_count = @intCast(ca.len);
|
|
}
|
|
|
|
if (this.ssl_ciphers != null) {
|
|
ctx_opts.ssl_ciphers = this.ssl_ciphers;
|
|
}
|
|
ctx_opts.request_cert = this.request_cert;
|
|
ctx_opts.reject_unauthorized = this.reject_unauthorized;
|
|
|
|
return ctx_opts;
|
|
}
|
|
|
|
/// Returns socket options for client-side TLS with manual verification.
|
|
/// Sets request_cert=1 (to receive server cert) and reject_unauthorized=0
|
|
/// (to handle verification manually in handshake callback).
|
|
pub fn asUSocketsForClientVerification(this: *const SSLConfig) uws.SocketContext.BunSocketContextOptions {
|
|
var opts = this.asUSockets();
|
|
opts.request_cert = 1;
|
|
opts.reject_unauthorized = 0;
|
|
return opts;
|
|
}
|
|
|
|
/// Returns a copy of this config for client-side TLS with manual verification.
|
|
/// Sets request_cert=1 (to receive server cert) and reject_unauthorized=0
|
|
/// (to handle verification manually in handshake callback).
|
|
pub fn forClientVerification(this: SSLConfig) SSLConfig {
|
|
var copy = this;
|
|
copy.request_cert = 1;
|
|
copy.reject_unauthorized = 0;
|
|
return copy;
|
|
}
|
|
|
|
pub fn isSame(this: *const SSLConfig, other: *const SSLConfig) bool {
|
|
inline for (comptime std.meta.fields(SSLConfig)) |field| {
|
|
const first = @field(this, field.name);
|
|
const second = @field(other, field.name);
|
|
switch (field.type) {
|
|
?[*:0]const u8 => {
|
|
const a = first orelse return second == null;
|
|
const b = second orelse return false;
|
|
if (!stringsEqual(a, b)) return false;
|
|
},
|
|
?[][*:0]const u8 => {
|
|
const slice1 = first orelse return second == null;
|
|
const slice2 = second orelse return false;
|
|
if (slice1.len != slice2.len) return false;
|
|
for (slice1, slice2) |a, b| {
|
|
if (!stringsEqual(a, b)) return false;
|
|
}
|
|
},
|
|
else => if (first != second) return false,
|
|
}
|
|
}
|
|
return true;
|
|
}
|
|
|
|
fn stringsEqual(a: [*:0]const u8, b: [*:0]const u8) bool {
|
|
const lhs = bun.asByteSlice(a);
|
|
const rhs = bun.asByteSlice(b);
|
|
return strings.eqlLong(lhs, rhs, true);
|
|
}
|
|
|
|
fn freeStrings(slice: *?[][*:0]const u8) void {
|
|
const inner = slice.* orelse return;
|
|
for (inner) |string| {
|
|
bun.freeSensitive(bun.default_allocator, std.mem.span(string));
|
|
}
|
|
bun.default_allocator.free(inner);
|
|
slice.* = null;
|
|
}
|
|
|
|
fn freeString(string: *?[*:0]const u8) void {
|
|
const inner = string.* orelse return;
|
|
bun.freeSensitive(bun.default_allocator, std.mem.span(inner));
|
|
string.* = null;
|
|
}
|
|
|
|
pub fn deinit(this: *SSLConfig) void {
|
|
bun.meta.useAllFields(SSLConfig, .{
|
|
.server_name = freeString(&this.server_name),
|
|
.key_file_name = freeString(&this.key_file_name),
|
|
.cert_file_name = freeString(&this.cert_file_name),
|
|
.ca_file_name = freeString(&this.ca_file_name),
|
|
.dh_params_file_name = freeString(&this.dh_params_file_name),
|
|
.passphrase = freeString(&this.passphrase),
|
|
.key = freeStrings(&this.key),
|
|
.cert = freeStrings(&this.cert),
|
|
.ca = freeStrings(&this.ca),
|
|
.secure_options = {},
|
|
.request_cert = {},
|
|
.reject_unauthorized = {},
|
|
.ssl_ciphers = freeString(&this.ssl_ciphers),
|
|
.protos = freeString(&this.protos),
|
|
.client_renegotiation_limit = {},
|
|
.client_renegotiation_window = {},
|
|
.requires_custom_request_ctx = {},
|
|
.is_using_default_ciphers = {},
|
|
.low_memory_mode = {},
|
|
});
|
|
}
|
|
|
|
fn cloneStrings(slice: ?[][*:0]const u8) ?[][*:0]const u8 {
|
|
const inner = slice orelse return null;
|
|
const result = bun.handleOom(bun.default_allocator.alloc([*:0]const u8, inner.len));
|
|
for (inner, result) |string, *out| {
|
|
out.* = bun.handleOom(bun.default_allocator.dupeZ(u8, std.mem.span(string)));
|
|
}
|
|
return result;
|
|
}
|
|
|
|
fn cloneString(string: ?[*:0]const u8) ?[*:0]const u8 {
|
|
return bun.handleOom(bun.default_allocator.dupeZ(u8, std.mem.span(string orelse return null)));
|
|
}
|
|
|
|
pub fn clone(this: *const SSLConfig) SSLConfig {
|
|
return .{
|
|
.server_name = cloneString(this.server_name),
|
|
.key_file_name = cloneString(this.key_file_name),
|
|
.cert_file_name = cloneString(this.cert_file_name),
|
|
.ca_file_name = cloneString(this.ca_file_name),
|
|
.dh_params_file_name = cloneString(this.dh_params_file_name),
|
|
.passphrase = cloneString(this.passphrase),
|
|
.key = cloneStrings(this.key),
|
|
.cert = cloneStrings(this.cert),
|
|
.ca = cloneStrings(this.ca),
|
|
.secure_options = this.secure_options,
|
|
.request_cert = this.request_cert,
|
|
.reject_unauthorized = this.reject_unauthorized,
|
|
.ssl_ciphers = cloneString(this.ssl_ciphers),
|
|
.protos = cloneString(this.protos),
|
|
.client_renegotiation_limit = this.client_renegotiation_limit,
|
|
.client_renegotiation_window = this.client_renegotiation_window,
|
|
.requires_custom_request_ctx = this.requires_custom_request_ctx,
|
|
.is_using_default_ciphers = this.is_using_default_ciphers,
|
|
.low_memory_mode = this.low_memory_mode,
|
|
};
|
|
}
|
|
|
|
pub const zero = SSLConfig{};
|
|
|
|
pub fn fromJS(
|
|
vm: *jsc.VirtualMachine,
|
|
global: *jsc.JSGlobalObject,
|
|
value: jsc.JSValue,
|
|
) bun.JSError!?SSLConfig {
|
|
var generated: jsc.generated.SSLConfig = try .fromJS(global, value);
|
|
defer generated.deinit();
|
|
return .fromGenerated(vm, global, &generated);
|
|
}
|
|
|
|
pub fn fromGenerated(
|
|
vm: *jsc.VirtualMachine,
|
|
global: *jsc.JSGlobalObject,
|
|
generated: *const jsc.generated.SSLConfig,
|
|
) bun.JSError!?SSLConfig {
|
|
var result: SSLConfig = zero;
|
|
errdefer result.deinit();
|
|
var any = false;
|
|
|
|
if (generated.passphrase.get()) |passphrase| {
|
|
result.passphrase = passphrase.toOwnedSliceZ(bun.default_allocator);
|
|
any = true;
|
|
}
|
|
if (generated.dh_params_file.get()) |dh_params_file| {
|
|
result.dh_params_file_name = try handlePath(global, "dhParamsFile", dh_params_file);
|
|
any = true;
|
|
}
|
|
if (generated.server_name.get()) |server_name| {
|
|
result.server_name = server_name.toOwnedSliceZ(bun.default_allocator);
|
|
result.requires_custom_request_ctx = true;
|
|
}
|
|
|
|
result.low_memory_mode = generated.low_memory_mode;
|
|
result.reject_unauthorized = @intFromBool(
|
|
generated.reject_unauthorized orelse vm.getTLSRejectUnauthorized(),
|
|
);
|
|
result.request_cert = @intFromBool(generated.request_cert);
|
|
result.secure_options = generated.secure_options;
|
|
any = any or
|
|
result.low_memory_mode or
|
|
generated.reject_unauthorized != null or
|
|
generated.request_cert or
|
|
result.secure_options != 0;
|
|
|
|
result.ca = try handleFileForField(global, "ca", &generated.ca);
|
|
result.cert = try handleFileForField(global, "cert", &generated.cert);
|
|
result.key = try handleFileForField(global, "key", &generated.key);
|
|
result.requires_custom_request_ctx = result.requires_custom_request_ctx or
|
|
result.ca != null or
|
|
result.cert != null or
|
|
result.key != null;
|
|
|
|
if (generated.key_file.get()) |key_file| {
|
|
result.key_file_name = try handlePath(global, "keyFile", key_file);
|
|
result.requires_custom_request_ctx = true;
|
|
}
|
|
if (generated.cert_file.get()) |cert_file| {
|
|
result.cert_file_name = try handlePath(global, "certFile", cert_file);
|
|
result.requires_custom_request_ctx = true;
|
|
}
|
|
if (generated.ca_file.get()) |ca_file| {
|
|
result.ca_file_name = try handlePath(global, "caFile", ca_file);
|
|
result.requires_custom_request_ctx = true;
|
|
}
|
|
|
|
const protocols = switch (generated.alpn_protocols) {
|
|
.none => null,
|
|
.string => |*ref| ref.get().toOwnedSliceZ(bun.default_allocator),
|
|
.buffer => |*ref| blk: {
|
|
const buffer: jsc.ArrayBuffer = ref.get().asArrayBuffer();
|
|
break :blk try bun.default_allocator.dupeZ(u8, buffer.byteSlice());
|
|
},
|
|
};
|
|
if (protocols) |some_protocols| {
|
|
result.protos = some_protocols;
|
|
result.requires_custom_request_ctx = true;
|
|
}
|
|
if (generated.ciphers.get()) |ciphers| {
|
|
result.ssl_ciphers = ciphers.toOwnedSliceZ(bun.default_allocator);
|
|
result.is_using_default_ciphers = false;
|
|
result.requires_custom_request_ctx = true;
|
|
}
|
|
|
|
result.client_renegotiation_limit = generated.client_renegotiation_limit;
|
|
result.client_renegotiation_window = generated.client_renegotiation_window;
|
|
any = any or
|
|
result.requires_custom_request_ctx or
|
|
result.client_renegotiation_limit != 0 or
|
|
generated.client_renegotiation_window != 0;
|
|
|
|
// We don't need to deinit `result` if `any` is false.
|
|
return if (any) result else null;
|
|
}
|
|
|
|
fn handlePath(
|
|
global: *jsc.JSGlobalObject,
|
|
comptime field: []const u8,
|
|
string: bun.string.WTFStringImpl,
|
|
) bun.JSError![:0]const u8 {
|
|
const name = string.toOwnedSliceZ(bun.default_allocator);
|
|
errdefer bun.freeSensitive(bun.default_allocator, name);
|
|
if (std.posix.system.access(name, std.posix.F_OK) != 0) {
|
|
return global.throwInvalidArguments(
|
|
std.fmt.comptimePrint("Unable to access {s} path", .{field}),
|
|
.{},
|
|
);
|
|
}
|
|
return name;
|
|
}
|
|
|
|
fn handleFileForField(
|
|
global: *jsc.JSGlobalObject,
|
|
comptime field: []const u8,
|
|
file: *const jsc.generated.SSLConfigFile,
|
|
) bun.JSError!?[][*:0]const u8 {
|
|
return handleFile(global, file) catch |err| switch (err) {
|
|
error.JSError => return error.JSError,
|
|
error.OutOfMemory => return error.OutOfMemory,
|
|
error.JSTerminated => return error.JSTerminated,
|
|
error.EmptyFile => return global.throwInvalidArguments(
|
|
std.fmt.comptimePrint("TLSOptions.{s} is an empty file", .{field}),
|
|
.{},
|
|
),
|
|
error.NullStore, error.NotAFile => return global.throwInvalidArguments(
|
|
std.fmt.comptimePrint(
|
|
"TLSOptions.{s} is not a valid BunFile (non-BunFile `Blob`s are not supported)",
|
|
.{field},
|
|
),
|
|
.{},
|
|
),
|
|
};
|
|
}
|
|
|
|
fn handleFile(
|
|
global: *jsc.JSGlobalObject,
|
|
file: *const jsc.generated.SSLConfigFile,
|
|
) ReadFromBlobError!?[][*:0]const u8 {
|
|
const single = try handleSingleFile(global, switch (file.*) {
|
|
.none => return null,
|
|
.string => |*ref| .{ .string = ref.get() },
|
|
.buffer => |*ref| .{ .buffer = ref.get() },
|
|
.file => |*ref| .{ .file = ref.get() },
|
|
.array => |*list| return try handleFileArray(global, list.items()),
|
|
});
|
|
errdefer bun.freeSensitive(bun.default_allocator, single);
|
|
const result = try bun.default_allocator.alloc([*:0]const u8, 1);
|
|
result[0] = single;
|
|
return result;
|
|
}
|
|
|
|
fn handleFileArray(
|
|
global: *jsc.JSGlobalObject,
|
|
elements: []const jsc.generated.SSLConfigSingleFile,
|
|
) ReadFromBlobError!?[][*:0]const u8 {
|
|
if (elements.len == 0) return null;
|
|
var result: bun.collections.ArrayListDefault([*:0]const u8) = try .initCapacity(elements.len);
|
|
errdefer {
|
|
for (result.items()) |string| {
|
|
bun.freeSensitive(bun.default_allocator, std.mem.span(string));
|
|
}
|
|
result.deinit();
|
|
}
|
|
for (elements) |*elem| {
|
|
result.appendAssumeCapacity(try handleSingleFile(global, switch (elem.*) {
|
|
.string => |*ref| .{ .string = ref.get() },
|
|
.buffer => |*ref| .{ .buffer = ref.get() },
|
|
.file => |*ref| .{ .file = ref.get() },
|
|
}));
|
|
}
|
|
return try result.toOwnedSlice();
|
|
}
|
|
|
|
fn handleSingleFile(
|
|
global: *jsc.JSGlobalObject,
|
|
file: union(enum) {
|
|
string: bun.string.WTFStringImpl,
|
|
buffer: *jsc.JSCArrayBuffer,
|
|
file: *bun.webcore.Blob,
|
|
},
|
|
) ReadFromBlobError![:0]const u8 {
|
|
return switch (file) {
|
|
.string => |string| string.toOwnedSliceZ(bun.default_allocator),
|
|
.buffer => |jsc_buffer| blk: {
|
|
const buffer: jsc.ArrayBuffer = jsc_buffer.asArrayBuffer();
|
|
break :blk try bun.default_allocator.dupeZ(u8, buffer.byteSlice());
|
|
},
|
|
.file => |blob| try readFromBlob(global, blob),
|
|
};
|
|
}
|
|
|
|
pub fn takeProtos(this: *SSLConfig) ?[]const u8 {
|
|
defer this.protos = null;
|
|
const protos = this.protos orelse return null;
|
|
return bun.handleOom(bun.memory.dropSentinel(protos, bun.default_allocator));
|
|
}
|
|
|
|
pub fn takeServerName(this: *SSLConfig) ?[]const u8 {
|
|
defer this.server_name = null;
|
|
const server_name = this.server_name orelse return null;
|
|
return bun.handleOom(bun.memory.dropSentinel(server_name, bun.default_allocator));
|
|
}
|
|
|
|
const std = @import("std");
|
|
|
|
const bun = @import("bun");
|
|
const strings = bun.strings;
|
|
const uws = bun.uws;
|
|
|
|
const jsc = bun.jsc;
|
|
const JSGlobalObject = jsc.JSGlobalObject;
|
|
const JSValue = jsc.JSValue;
|
|
const VirtualMachine = jsc.VirtualMachine;
|