Skip to content
Open
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
6 changes: 6 additions & 0 deletions docs/runtime/http/server.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -759,6 +759,12 @@ interface TLSOptions {
/** Reduce TLS memory usage */
lowMemoryMode?: boolean;

/** Maximum TLS protocol version (default: "TLSv1.3") */
maxVersion?: "TLSv1" | "TLSv1.1" | "TLSv1.2" | "TLSv1.3";

/** Minimum TLS protocol version (default: "TLSv1.2") */
minVersion?: "TLSv1" | "TLSv1.1" | "TLSv1.2" | "TLSv1.3";

/** Private key passphrase */
passphrase?: string;

Expand Down
22 changes: 22 additions & 0 deletions docs/runtime/http/tls.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,28 @@ Bun.serve({
});
```

### Protocol versions

To limit the TLS protocol versions that a connection can use, set `minVersion` and `maxVersion`. They take the same names as `node:tls`: `"TLSv1"`, `"TLSv1.1"`, `"TLSv1.2"` and `"TLSv1.3"`. The defaults are `"TLSv1.2"` and `"TLSv1.3"`.

```ts
Bun.serve({
tls: {
key: Bun.file("./key.pem"),
cert: Bun.file("./cert.pem"),
minVersion: "TLSv1.3", // [!code ++]
},
});
```

A string that is not one of these names throws `ERR_TLS_INVALID_PROTOCOL_VERSION`, and so does `null`. The same two options work in every API that takes a `tls` object, for example `fetch`, `Bun.connect` and `Bun.listen`.

A `maxVersion` below `"TLSv1.2"` also needs a `minVersion`, because the default minimum is `"TLSv1.2"`.

The defaults do not follow the `--tls-min-v1.x` and `--tls-max-v1.x` flags or `tls.DEFAULT_MIN_VERSION` and `tls.DEFAULT_MAX_VERSION`. Those apply to `node:tls`.

When `tls` is an array (see [SNI](#server-name-indication-sni) below), all entries use the `minVersion` and `maxVersion` of the first entry. A later entry must leave the two options out or repeat what the first entry sets. Any other value there throws. HTTP/3 always uses TLS 1.3: `Bun.serve` with `http3: true` throws for a `maxVersion` below `"TLSv1.3"`, and `fetch` with `protocol: "http3"` rejects a request that sets either option.

### CA Certificates

Pass `ca` to override the trusted CA certificates. By default, the server trusts the list of well-known CAs curated by Mozilla; setting `ca` replaces that list.
Expand Down
30 changes: 30 additions & 0 deletions packages/bun-types/bun.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4840,6 +4840,36 @@ declare module "bun" {
*/
secureOptions?: number | undefined; // Value is a numeric bitmask of the `SSL_OP_*` options

/**
* The minimum TLS protocol version to allow. Takes the same names as
* `node:tls`: `"TLSv1"`, `"TLSv1.1"`, `"TLSv1.2"` or `"TLSv1.3"`.
* A string that is not one of these names throws
* `ERR_TLS_INVALID_PROTOCOL_VERSION`, and so does `null`.
*
* The default does not follow the `--tls-min-v1.x` and `--tls-max-v1.x`
* flags or `tls.DEFAULT_MIN_VERSION` and `tls.DEFAULT_MAX_VERSION`. Those
* apply to `node:tls`.
*
* In a `tls: [...]` array for SNI, the entries after the first use the
* range of the first entry. A later entry must leave the option out or
* repeat what the first entry sets. Any other value there throws.
* HTTP/3 always uses TLS 1.3: `Bun.serve` with
* `http3: true` throws for a `maxVersion` below `"TLSv1.3"`, and `fetch`
* with `protocol: "http3"` rejects a request that sets either option.
*
* @default "TLSv1.2"
*/
minVersion?: "TLSv1" | "TLSv1.1" | "TLSv1.2" | "TLSv1.3" | undefined;

/**
* The maximum TLS protocol version to allow. Takes the same values as
* `minVersion`. A `maxVersion` below `"TLSv1.2"` also needs a
* `minVersion`, because the default minimum is `"TLSv1.2"`.
*
* @default "TLSv1.3"
*/
maxVersion?: "TLSv1" | "TLSv1.1" | "TLSv1.2" | "TLSv1.3" | undefined;

ALPNProtocols?: string | BufferSource;

ciphers?: string;
Expand Down
18 changes: 14 additions & 4 deletions packages/bun-usockets/src/crypto/openssl.c
Original file line number Diff line number Diff line change
Expand Up @@ -1293,10 +1293,20 @@ SSL_CTX *us_ssl_ctx_build_raw(struct us_bun_socket_context_options_t options,
* (crypto_context.cc#L1640). It only runs when the configured chain is 1. */
SSL_CTX_clear_mode(ssl_context, SSL_MODE_NO_AUTO_CHAIN);
/* Honor explicit minVersion/maxVersion (Node's secureProtocol/min/maxVersion);
* default to a TLS1.2 floor when no minimum is requested. */
SSL_CTX_set_min_proto_version(ssl_context, options.ssl_min_version ? options.ssl_min_version : TLS1_2_VERSION);
if (options.ssl_max_version) {
SSL_CTX_set_max_proto_version(ssl_context, options.ssl_max_version);
* default to a TLS1.2 floor when no minimum is requested. A bound BoringSSL
* does not accept fails the build: the setters take uint16_t, so the range
* is checked before the cast. */
int min_version = options.ssl_min_version ? options.ssl_min_version : TLS1_2_VERSION;
int max_version = options.ssl_max_version;
if (min_version < 0 || min_version > UINT16_MAX || max_version < 0 || max_version > UINT16_MAX) {
OPENSSL_PUT_ERROR(SSL, SSL_R_UNKNOWN_SSL_VERSION);
ssl_ctx_build_fail(ssl_context);
return NULL;
}
if (!SSL_CTX_set_min_proto_version(ssl_context, (uint16_t)min_version) ||
(max_version && !SSL_CTX_set_max_proto_version(ssl_context, (uint16_t)max_version))) {
ssl_ctx_build_fail(ssl_context);
return NULL;
}

if (options.ssl_prefer_low_memory_usage) {
Expand Down
27 changes: 25 additions & 2 deletions src/codegen/bindgenv2/internal/primitives.ts
Original file line number Diff line number Diff line change
Expand Up @@ -122,10 +122,15 @@ export const i64: IntegerType = makeSignedType(64);

export abstract class LooseIntegerType extends Type {}

function makeLooseIntegerType(strict: IntegerType): LooseIntegerType {
/** `strict` on the native side, converted from JS by `idlType`. `header` declares that converter. */
function makeConvertedIntegerType(
strict: IntegerType,
idlType: string,
header?: string,
): LooseIntegerType {
return new (class extends LooseIntegerType {
get idlType() {
return `::Bun::IDLLooseInteger<${strict.cppType}>`;
return idlType;
}
get bindgenType() {
return strict.bindgenType;
Expand All @@ -136,9 +141,16 @@ function makeLooseIntegerType(strict: IntegerType): LooseIntegerType {
toCpp(value: number | bigint): string {
return strict.toCpp(value);
}
getHeaders(result: Set<string>): void {
if (header) result.add(header);
}
})();
}

function makeLooseIntegerType(strict: IntegerType): LooseIntegerType {
return makeConvertedIntegerType(strict, `::Bun::IDLLooseInteger<${strict.cppType}>`);
}

export const LooseU8: LooseIntegerType = makeLooseIntegerType(u8);
export const LooseU16: LooseIntegerType = makeLooseIntegerType(u16);
export const LooseU32: LooseIntegerType = makeLooseIntegerType(u32);
Expand All @@ -163,6 +175,17 @@ const looseSignedTypes: { [width: number]: LooseIntegerType } = {
64: LooseI64,
};

function makeTLSProtocolVersionType(bound: "Minimum" | "Maximum"): Type {
return makeConvertedIntegerType(
i32,
`::Bun::IDLTLSProtocolVersion<::Bun::TLSProtocolBound::${bound}>`,
"BunIDLConvertTLSProtocolVersion.h",
);
}

export const TLSMinVersion: Type = makeTLSProtocolVersionType("Minimum");
export const TLSMaxVersion: Type = makeTLSProtocolVersionType("Maximum");

export const f64 = new (class extends Type {
/** Does not allow NaN or infinities. */
get finite() {
Expand Down
1 change: 1 addition & 0 deletions src/codegen/bindgenv2/lib.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
// organize-imports-ignore
export { bool, u8, u16, u32, u64, i8, i16, i32, i64, f64 } from "./internal/primitives.ts";
export { TLSMinVersion, TLSMaxVersion } from "./internal/primitives.ts";
export { RawAny, StrongAny } from "./internal/any.ts";
export { String } from "./internal/string.ts";
export { optional, nullable, undefined, null } from "./internal/optional.ts";
Expand Down
5 changes: 5 additions & 0 deletions src/js/internal-for-testing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,11 @@ const shellLex = $newRustFunction("shell.rs", "TestingAPIs.shellLex", 2);
const shellParse = $newRustFunction("shell.rs", "TestingAPIs.shellParse", 2);

export const sslCtxLiveCount = $newRustFunction("SecureContext.rs", "jsLiveCount", 0);
export const sslCtxBuildWithProtocolVersions: (min: number, max: number) => void = $newRustFunction(
"SecureContext.rs",
"jsBuildWithProtocolVersions",
2,
);

export const napiThreadsafeFunctionLiveCount = $newRustFunction("napi_body.rs", "jsThreadsafeFunctionLiveCount", 0);

Expand Down
92 changes: 92 additions & 0 deletions src/jsc/bindings/BunIDLConvertTLSProtocolVersion.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
#pragma once
#include "BunIDLTypes.h"
#include "BunIDLConvertBase.h"
#include "ErrorCode.h"
#include <openssl/ssl.h>
#include <initializer_list>

namespace Bun {

enum class TLSProtocolBound : bool {
Minimum,
Maximum,
};

// node's "TLSv1" .. "TLSv1.3", or the `TLS1_*_VERSION` code that node:tls passes. 0 means unset.
template<TLSProtocolBound Bound>
struct IDLTLSProtocolVersion : WebCore::IDLType<std::int32_t> {};

namespace Detail {
inline std::optional<std::int32_t> tlsProtocolVersionFromJS(
JSC::JSGlobalObject& globalObject,
JSC::JSValue value)
{
auto& vm = JSC::getVM(&globalObject);
auto scope = DECLARE_THROW_SCOPE(vm);
if (value.isNumber()) {
double number = value.asNumber();
for (std::int32_t version : { 0, TLS1_VERSION, TLS1_1_VERSION, TLS1_2_VERSION, TLS1_3_VERSION }) {
if (number == version)
return version;
}
return std::nullopt;
}
if (!value.isString())
return std::nullopt;
auto name = JSC::asString(value)->view(&globalObject);
RETURN_IF_EXCEPTION(scope, std::nullopt);
if (name == "TLSv1"_s)
return TLS1_VERSION;
if (name == "TLSv1.1"_s)
return TLS1_1_VERSION;
if (name == "TLSv1.2"_s)
return TLS1_2_VERSION;
if (name == "TLSv1.3"_s)
return TLS1_3_VERSION;
return std::nullopt;
}

inline void throwInvalidTLSProtocolVersion(
JSC::JSGlobalObject& globalObject,
JSC::ThrowScope& scope,
JSC::JSValue value,
WTF::ASCIILiteral bound)
{
MessageBuilder message;
// Node formats the value with %j. For a string that is its JSON form, quoted and escaped.
if (value.isString()) {
auto string = JSC::asString(value)->value(&globalObject);
RETURN_IF_EXCEPTION(scope, );
message.appendQuotedJSONString(string);
} else {
JSValueToStringSafe(&globalObject, message, value, false);
RETURN_IF_EXCEPTION(scope, );
}
message.append(" is not a valid "_s, bound, " TLS protocol version"_s);
throwError(&globalObject, scope, ErrorCode::ERR_TLS_INVALID_PROTOCOL_VERSION, message);
}
}

}

template<Bun::TLSProtocolBound Bound>
struct WebCore::Converter<Bun::IDLTLSProtocolVersion<Bound>>
: Bun::DefaultContextConverter<Bun::IDLTLSProtocolVersion<Bound>> {

template<Bun::IDLConversionContext Ctx>
static std::int32_t convert(JSC::JSGlobalObject& globalObject, JSC::JSValue value, Ctx&)
{
auto& vm = JSC::getVM(&globalObject);
auto scope = DECLARE_THROW_SCOPE(vm);
auto version = Bun::Detail::tlsProtocolVersionFromJS(globalObject, value);
RETURN_IF_EXCEPTION(scope, {});
if (version)
return *version;
Bun::Detail::throwInvalidTLSProtocolVersion(
globalObject,
scope,
value,
Bound == Bun::TLSProtocolBound::Minimum ? "minimum"_s : "maximum"_s);
return {};
}
};
18 changes: 18 additions & 0 deletions src/runtime/api/bun/SecureContext.rs
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,24 @@ pub(crate) fn js_live_count(_global: &JSGlobalObject, _callframe: &CallFrame) ->
Ok(JSValue::js_number(c::us_ssl_ctx_live_count() as f64))
}

/// `bun:internal-for-testing`: builds and drops an `SSL_CTX` from raw protocol version bounds.
#[bun_jsc::host_fn]
pub(crate) fn js_build_with_protocol_versions(
global: &JSGlobalObject,
callframe: &CallFrame,
) -> JsResult<JSValue> {
let opts = uws::socket_context::BunSocketContextOptions {
ssl_min_version: callframe.argument(0).coerce_to_i32(global)?,
ssl_max_version: callframe.argument(1).coerce_to_i32(global)?,
..Default::default()
};
let mut err = uws::create_bun_socket_error_t::none;
if opts.create_ssl_context(&mut err).is_none() {
return Err(global.throw_value(create_bun_socket_error_to_js(err, global)));
}
Ok(JSValue::UNDEFINED)
}

impl SecureContext {
// Note: no `#[bun_jsc::host_fn]` here — the `Free` shim it emits calls
// a bare `constructor(...)` which cannot resolve inside an `impl`. The
Expand Down
32 changes: 31 additions & 1 deletion src/runtime/server/ServerConfig.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1235,21 +1235,40 @@ impl ServerConfig {
if value_iter.len == 0 {
// Empty TLS array means no TLS - this is valid
} else {
let mut first_bounds = [0i32; 2];
while let Some(item) = value_iter.next()? {
let Some(ssl_config) = SSLConfig::from_js(vm, global, item, true)? else {
let generated =
crate::server::jsc::generated::SSLConfig::from_js(global, item)?;
let Some(ssl_config) =
SSLConfig::from_generated(vm, global, &generated, true)?
else {
// Backwards-compatibility; we ignored empty tls objects.
continue;
};
// As the entry sets them: the default ciphers can raise `ssl_min_version`.
let bounds = [generated.ssl_min_version, generated.ssl_max_version];

if args.ssl_config.is_none() {
args.ssl_config = Some(ssl_config);
first_bounds = bounds;
} else {
if ssl_config.server_name_bytes().unwrap_or(b"").is_empty() {
drop(ssl_config);
return Err(global.throw_invalid_arguments(format_args!(
"SNI tls object must have a serverName",
)));
}
// The first entry's range is negotiated before SNI selects this one.
for (key, bound, first_bound) in [
("minVersion", bounds[0], first_bounds[0]),
("maxVersion", bounds[1], first_bounds[1]),
] {
if bound != 0 && bound != first_bound {
return Err(global.throw_invalid_arguments(format_args!(
"SNI tls object must have the same '{key}' as the first tls object"
)));
}
}
if args.sni.is_none() {
args.sni = Some(Vec::with_capacity((value_iter.len - 1) as usize));
}
Expand Down Expand Up @@ -1278,6 +1297,17 @@ impl ServerConfig {
global.throw_invalid_arguments(format_args!("HTTP/3 requires 'tls' to be set"))
);
}
// QUIC has no TLS version below 1.3, so a lower cap would bind the TCP listener only.
if args.http3
&& args.ssl_config.as_ref().is_some_and(|ssl| {
ssl.ssl_max_version != 0
&& ssl.ssl_max_version < i32::from(bun_boringssl_sys::TLS1_3_VERSION)
})
{
return Err(global.throw_invalid_arguments(format_args!(
"HTTP/3 requires TLS 1.3, but 'tls.maxVersion' is lower"
)));
}
if !args.http1 && !args.http2 && !args.http3 {
return Err(global.throw_invalid_arguments(format_args!(
"Cannot disable http1 without enabling http2 or http3"
Expand Down
4 changes: 2 additions & 2 deletions src/runtime/socket/SSLConfig.bindv2.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,12 +60,12 @@ export const SSLConfig = b.dictionary(
internalName: "secure_options",
},
minVersion: {
type: b.i32,
type: b.TLSMinVersion,
default: 0,
internalName: "ssl_min_version",
},
maxVersion: {
type: b.i32,
type: b.TLSMaxVersion,
default: 0,
internalName: "ssl_max_version",
},
Expand Down
14 changes: 14 additions & 0 deletions test/integration/bun-types/fixture/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -472,3 +472,17 @@ controller.signal.addEventListener("abort", event => {
controller.signal.removeEventListener("abort", event => {
expectType(event).is<Event>();
});

const tlsVersionOptions: Bun.TLSOptions = {
minVersion: "TLSv1.2",
maxVersion: "TLSv1.3",
};
// The two keys have node's type, so they pass between the two option objects.
const nodeTlsVersionOptions: import("node:tls").SecureContextOptions = {
minVersion: tlsVersionOptions.minVersion,
maxVersion: tlsVersionOptions.maxVersion,
};
// @ts-expect-error
const tlsVersionOptionsWithUnknownName: Bun.TLSOptions = { minVersion: "TLSv9" };
// @ts-expect-error
const tlsVersionOptionsWithNumber: Bun.TLSOptions = { minVersion: 13 };
Loading
Loading