mirror of
https://github.com/oven-sh/bun
synced 2026-02-09 18:38:55 +00:00
feat(CSRF) implement Bun.CSRF (#18045)
Co-authored-by: Jarred Sumner <jarred@jarredsumner.com>
This commit is contained in:
70
packages/bun-types/bun.d.ts
vendored
70
packages/bun-types/bun.d.ts
vendored
@@ -2309,10 +2309,68 @@ declare module "bun" {
|
||||
*/
|
||||
interface SavepointSQL extends SQL {}
|
||||
|
||||
type CSRFAlgorithm = "blake2b256" | "blake2b512" | "sha256" | "sha384" | "sha512" | "sha512-256";
|
||||
interface CSRFGenerateOptions {
|
||||
/**
|
||||
* The number of milliseconds until the token expires. 0 means the token never expires.
|
||||
* @default 24 * 60 * 60 * 1000 (24 hours)
|
||||
*/
|
||||
expiresIn?: number;
|
||||
/**
|
||||
* The encoding of the token.
|
||||
* @default "base64url"
|
||||
*/
|
||||
encoding?: "base64" | "base64url" | "hex";
|
||||
/**
|
||||
* The algorithm to use for the token.
|
||||
* @default "sha256"
|
||||
*/
|
||||
algorithm?: CSRFAlgorithm;
|
||||
}
|
||||
|
||||
interface CSRFVerifyOptions {
|
||||
/**
|
||||
* The secret to use for the token. If not provided, a random default secret will be generated in memory and used.
|
||||
*/
|
||||
secret?: string;
|
||||
/**
|
||||
* The encoding of the token.
|
||||
* @default "base64url"
|
||||
*/
|
||||
encoding?: "base64" | "base64url" | "hex";
|
||||
/**
|
||||
* The algorithm to use for the token.
|
||||
* @default "sha256"
|
||||
*/
|
||||
algorithm?: CSRFAlgorithm;
|
||||
/**
|
||||
* The number of milliseconds until the token expires. 0 means the token never expires.
|
||||
* @default 24 * 60 * 60 * 1000 (24 hours)
|
||||
*/
|
||||
maxAge?: number;
|
||||
}
|
||||
interface CSRF {
|
||||
/**
|
||||
* Generate a CSRF token.
|
||||
* @param secret The secret to use for the token. If not provided, a random default secret will be generated in memory and used.
|
||||
* @param options The options for the token.
|
||||
* @returns The generated token.
|
||||
*/
|
||||
generate(secret?: string, options?: CSRFGenerateOptions): string;
|
||||
/**
|
||||
* Verify a CSRF token.
|
||||
* @param token The token to verify.
|
||||
* @param options The options for the token.
|
||||
* @returns True if the token is valid, false otherwise.
|
||||
*/
|
||||
verify(token: string, options?: CSRFVerifyOptions): boolean;
|
||||
}
|
||||
|
||||
var sql: SQL;
|
||||
var postgres: SQL;
|
||||
var SQL: SQL;
|
||||
|
||||
var CSRF: CSRF;
|
||||
/**
|
||||
* This lets you use macros as regular imports
|
||||
* @example
|
||||
@@ -2657,7 +2715,7 @@ declare module "bun" {
|
||||
loader?: { [k in string]: Loader };
|
||||
/**
|
||||
* Specifies if and how to generate source maps.
|
||||
*
|
||||
*
|
||||
* - `"none"` - No source maps are generated
|
||||
* - `"linked"` - A separate `*.ext.map` file is generated alongside each
|
||||
* `*.ext` file. A `//# sourceMappingURL` comment is added to the output
|
||||
@@ -2665,11 +2723,11 @@ declare module "bun" {
|
||||
* - `"inline"` - an inline source map is appended to the output file.
|
||||
* - `"external"` - Generate a separate source map file for each input file.
|
||||
* No `//# sourceMappingURL` comment is added to the output file.
|
||||
*
|
||||
*
|
||||
* `true` and `false` are aliasees for `"inline"` and `"none"`, respectively.
|
||||
*
|
||||
*
|
||||
* @default "none"
|
||||
*
|
||||
*
|
||||
* @see {@link outdir} required for `"linked"` maps
|
||||
* @see {@link publicPath} to customize the base url of linked source maps
|
||||
*/
|
||||
@@ -2704,10 +2762,10 @@ declare module "bun" {
|
||||
env?: "inline" | "disable" | `${string}*`;
|
||||
/**
|
||||
* Whether to enable minification.
|
||||
*
|
||||
*
|
||||
* Use `true`/`false` to enable/disable all minification options. Alternatively,
|
||||
* you can pass an object for granular control over certain minifications.
|
||||
*
|
||||
*
|
||||
* @default false
|
||||
*/
|
||||
minify?:
|
||||
|
||||
@@ -128,6 +128,8 @@ pub const Features = struct {
|
||||
pub var process_dlopen: usize = 0;
|
||||
pub var postgres_connections: usize = 0;
|
||||
pub var s3: usize = 0;
|
||||
pub var csrf_verify: usize = 0;
|
||||
pub var csrf_generate: usize = 0;
|
||||
|
||||
comptime {
|
||||
@export(&napi_module_register, .{ .name = "Bun__napi_module_register_count" });
|
||||
|
||||
@@ -44,6 +44,7 @@ pub const BunObject = struct {
|
||||
|
||||
// --- Getters ---
|
||||
pub const CryptoHasher = toJSGetter(Crypto.CryptoHasher.getter);
|
||||
pub const CSRF = toJSGetter(Bun.getCSRFObject);
|
||||
pub const FFI = toJSGetter(Bun.FFIObject.getter);
|
||||
pub const FileSystemRouter = toJSGetter(Bun.getFileSystemRouter);
|
||||
pub const Glob = toJSGetter(Bun.getGlobConstructor);
|
||||
@@ -101,6 +102,7 @@ pub const BunObject = struct {
|
||||
|
||||
// --- Getters ---
|
||||
@export(&BunObject.CryptoHasher, .{ .name = getterName("CryptoHasher") });
|
||||
@export(&BunObject.CSRF, .{ .name = getterName("CSRF") });
|
||||
@export(&BunObject.FFI, .{ .name = getterName("FFI") });
|
||||
@export(&BunObject.FileSystemRouter, .{ .name = getterName("FileSystemRouter") });
|
||||
@export(&BunObject.MD4, .{ .name = getterName("MD4") });
|
||||
@@ -4422,6 +4424,30 @@ pub fn stringWidth(str: bun.String, opts: gen.StringWidthOptions) usize {
|
||||
|
||||
/// EnvironmentVariables is runtime defined.
|
||||
/// Also, you can't iterate over process.env normally since it only exists at build-time otherwise
|
||||
pub fn getCSRFObject(globalObject: *JSC.JSGlobalObject, _: *JSC.JSObject) JSC.JSValue {
|
||||
return CSRFObject.create(globalObject);
|
||||
}
|
||||
|
||||
const CSRFObject = struct {
|
||||
pub fn create(globalThis: *JSC.JSGlobalObject) JSC.JSValue {
|
||||
const object = JSValue.createEmptyObject(globalThis, 2);
|
||||
|
||||
object.put(
|
||||
globalThis,
|
||||
ZigString.static("generate"),
|
||||
JSC.createCallback(globalThis, ZigString.static("generate"), 1, @import("../../csrf.zig").csrf__generate),
|
||||
);
|
||||
|
||||
object.put(
|
||||
globalThis,
|
||||
ZigString.static("verify"),
|
||||
JSC.createCallback(globalThis, ZigString.static("verify"), 1, @import("../../csrf.zig").csrf__verify),
|
||||
);
|
||||
|
||||
return object;
|
||||
}
|
||||
};
|
||||
|
||||
// This is aliased to Bun.env
|
||||
pub const EnvironmentVariables = struct {
|
||||
pub export fn Bun__getEnvCount(globalObject: *JSC.JSGlobalObject, ptr: *[*][]const u8) usize {
|
||||
|
||||
@@ -33,6 +33,7 @@
|
||||
macro(embeddedFiles) \
|
||||
macro(S3Client) \
|
||||
macro(s3) \
|
||||
macro(CSRF) \
|
||||
|
||||
// --- Callbacks ---
|
||||
#define FOR_EACH_CALLBACK(macro) \
|
||||
|
||||
@@ -711,6 +711,7 @@ JSC_DEFINE_HOST_FUNCTION(functionFileURLToPath, (JSC::JSGlobalObject * globalObj
|
||||
embeddedFiles BunObject_getter_wrap_embeddedFiles DontDelete|PropertyCallback
|
||||
S3Client BunObject_getter_wrap_S3Client DontDelete|PropertyCallback
|
||||
s3 BunObject_getter_wrap_s3 DontDelete|PropertyCallback
|
||||
CSRF BunObject_getter_wrap_CSRF DontDelete|PropertyCallback
|
||||
allocUnsafe BunObject_callback_allocUnsafe DontDelete|Function 1
|
||||
argv BunObject_getter_wrap_argv DontDelete|PropertyCallback
|
||||
build BunObject_callback_build DontDelete|Function 1
|
||||
|
||||
@@ -316,7 +316,7 @@ pub fn createCallback(
|
||||
comptime functionPointer: anytype,
|
||||
) JSValue {
|
||||
if (@TypeOf(functionPointer) == JSC.JSHostFunctionType) {
|
||||
return NewRuntimeFunction(globalObject, symbolName, argCount, functionPointer, false, false);
|
||||
return NewRuntimeFunction(globalObject, symbolName, argCount, functionPointer, false, false, null);
|
||||
}
|
||||
return NewRuntimeFunction(globalObject, symbolName, argCount, toJSHostFunction(functionPointer), false, false, null);
|
||||
}
|
||||
|
||||
@@ -51,6 +51,7 @@ temp_pipe_read_buffer: ?*PipeReadBuffer = null,
|
||||
aws_signature_cache: AWSSignatureCache = .{},
|
||||
|
||||
s3_default_client: JSC.Strong = .empty,
|
||||
default_csrf_secret: []const u8 = "",
|
||||
|
||||
const PipeReadBuffer = [256 * 1024]u8;
|
||||
const DIGESTED_HMAC_256_LEN = 32;
|
||||
@@ -475,6 +476,15 @@ pub fn s3DefaultClient(rare: *RareData, globalThis: *JSC.JSGlobalObject) JSC.JSV
|
||||
};
|
||||
}
|
||||
|
||||
pub fn defaultCSRFSecret(this: *RareData) []const u8 {
|
||||
if (this.default_csrf_secret.len == 0) {
|
||||
const secret = bun.default_allocator.alloc(u8, 16) catch bun.outOfMemory();
|
||||
bun.rand(secret);
|
||||
this.default_csrf_secret = secret;
|
||||
}
|
||||
return this.default_csrf_secret;
|
||||
}
|
||||
|
||||
pub fn deinit(this: *RareData) void {
|
||||
if (this.temp_pipe_read_buffer) |pipe| {
|
||||
this.temp_pipe_read_buffer = null;
|
||||
@@ -487,6 +497,9 @@ pub fn deinit(this: *RareData) void {
|
||||
if (this.boring_ssl_engine) |engine| {
|
||||
_ = bun.BoringSSL.c.ENGINE_free(engine);
|
||||
}
|
||||
if (this.default_csrf_secret.len > 0) {
|
||||
bun.default_allocator.free(this.default_csrf_secret);
|
||||
}
|
||||
|
||||
this.cleanup_hooks.clearAndFree(bun.default_allocator);
|
||||
}
|
||||
|
||||
@@ -149,6 +149,7 @@ pub const patch = @import("./patch.zig");
|
||||
pub const ini = @import("./ini.zig");
|
||||
pub const Bitflags = @import("./bitflags.zig").Bitflags;
|
||||
pub const css = @import("./css/css_parser.zig");
|
||||
pub const csrf = @import("./csrf.zig");
|
||||
pub const validators = @import("./bun.js/node/util/validators.zig");
|
||||
|
||||
pub const shell = @import("./shell/shell.zig");
|
||||
|
||||
377
src/csrf.zig
Normal file
377
src/csrf.zig
Normal file
@@ -0,0 +1,377 @@
|
||||
const bun = @import("root").bun;
|
||||
const std = @import("std");
|
||||
const JSC = bun.JSC;
|
||||
const boring = bun.BoringSSL.c;
|
||||
const hmac = @import("hmac.zig");
|
||||
const string = @import("string.zig");
|
||||
const gen = bun.gen.csrf;
|
||||
|
||||
/// CSRF Token implementation for Bun
|
||||
/// It provides protection against Cross-Site Request Forgery attacks
|
||||
/// by generating and validating tokens using HMAC signatures
|
||||
pub const CSRF = @This();
|
||||
|
||||
/// Default expiration time for tokens (24 hours)
|
||||
pub const DEFAULT_EXPIRATION_MS: u64 = 24 * 60 * 60 * 1000;
|
||||
|
||||
/// Default HMAC algorithm used for token signing
|
||||
pub const DEFAULT_ALGORITHM: JSC.API.Bun.Crypto.EVP.Algorithm = .sha256;
|
||||
|
||||
/// Error types for CSRF operations
|
||||
pub const Error = error{
|
||||
InvalidToken,
|
||||
ExpiredToken,
|
||||
TokenCreationFailed,
|
||||
DecodingFailed,
|
||||
};
|
||||
|
||||
/// Options for generating CSRF tokens
|
||||
pub const GenerateOptions = struct {
|
||||
/// Secret key to use for signing
|
||||
secret: []const u8,
|
||||
/// How long the token should be valid (in milliseconds)
|
||||
expires_in_ms: u64 = DEFAULT_EXPIRATION_MS,
|
||||
/// Format to encode the token in
|
||||
encoding: TokenFormat = .base64url,
|
||||
/// Algorithm to use for signing
|
||||
algorithm: JSC.API.Bun.Crypto.EVP.Algorithm = DEFAULT_ALGORITHM,
|
||||
};
|
||||
|
||||
/// Options for validating CSRF tokens
|
||||
pub const VerifyOptions = struct {
|
||||
/// The token to verify
|
||||
token: []const u8,
|
||||
/// Secret key used to sign the token
|
||||
secret: []const u8,
|
||||
/// Maximum age of the token in milliseconds
|
||||
max_age_ms: u64 = DEFAULT_EXPIRATION_MS,
|
||||
/// Encoding to use for the token
|
||||
encoding: TokenFormat = .base64url,
|
||||
/// Algorithm to use for signing
|
||||
algorithm: JSC.API.Bun.Crypto.EVP.Algorithm = DEFAULT_ALGORITHM,
|
||||
};
|
||||
|
||||
/// Token encoding format
|
||||
pub const TokenFormat = enum {
|
||||
base64,
|
||||
base64url,
|
||||
hex,
|
||||
|
||||
pub fn toNodeEncoding(self: TokenFormat) JSC.Node.Encoding {
|
||||
return switch (self) {
|
||||
.base64 => .base64,
|
||||
.base64url => .base64url,
|
||||
.hex => .hex,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
/// Generate a new CSRF token
|
||||
///
|
||||
/// Parameters:
|
||||
/// - options: Configuration for token generation
|
||||
/// - vm: The JSC virtual machine context
|
||||
///
|
||||
/// Returns: A string.Slice containing the encoded token
|
||||
pub fn generate(
|
||||
options: GenerateOptions,
|
||||
out_buffer: *[512]u8,
|
||||
) ![]u8 {
|
||||
// Generate nonce from entropy
|
||||
var nonce: [16]u8 = undefined;
|
||||
bun.rand(&nonce);
|
||||
|
||||
// Current timestamp in milliseconds
|
||||
const timestamp = std.time.milliTimestamp();
|
||||
const timestamp_u64: u64 = @bitCast(@as(i64, timestamp));
|
||||
|
||||
// Write timestamp to out_buffer
|
||||
var timestamp_bytes: [8]u8 = undefined;
|
||||
std.mem.writeInt(u64, ×tamp_bytes, timestamp_u64, .big);
|
||||
var expires_in_bytes: [8]u8 = undefined;
|
||||
std.mem.writeInt(u64, &expires_in_bytes, options.expires_in_ms, .big);
|
||||
// Prepare payload for signing: timestamp|nonce
|
||||
var payload_buf: [32]u8 = undefined; // 8 (timestamp) + 16 (nonce)
|
||||
@memcpy(payload_buf[0..8], ×tamp_bytes);
|
||||
@memcpy(payload_buf[8..24], &nonce);
|
||||
@memcpy(payload_buf[24..32], &expires_in_bytes);
|
||||
|
||||
// Sign the payload
|
||||
var digest_buf: [boring.EVP_MAX_MD_SIZE]u8 = undefined;
|
||||
const digest = hmac.generate(options.secret, &payload_buf, options.algorithm, &digest_buf) orelse
|
||||
return Error.TokenCreationFailed;
|
||||
|
||||
// Create the final token: timestamp|nonce|expires_in|signature in out_buffer
|
||||
@memcpy(out_buffer[0..8], ×tamp_bytes);
|
||||
@memcpy(out_buffer[8..24], &nonce);
|
||||
@memcpy(out_buffer[24..32], &expires_in_bytes);
|
||||
@memcpy(out_buffer[32 .. 32 + digest.len], digest);
|
||||
|
||||
// Return slice of the output buffer with the final token
|
||||
return out_buffer[0 .. 32 + digest.len];
|
||||
}
|
||||
|
||||
/// Validate a CSRF token
|
||||
///
|
||||
/// Parameters:
|
||||
/// - options: Configuration for token validation
|
||||
///
|
||||
/// Returns: true if valid, false if invalid
|
||||
pub fn verify(options: VerifyOptions) bool {
|
||||
// Detect the encoding format
|
||||
const encoding: TokenFormat = options.encoding;
|
||||
|
||||
// Allocate output buffer for decoded data
|
||||
var buf: [boring.EVP_MAX_MD_SIZE + 32]u8 = undefined;
|
||||
var token = options.token;
|
||||
// check if ends with \0
|
||||
if (token.len > 0 and token[token.len - 1] == 0) {
|
||||
token = token[0 .. token.len - 1];
|
||||
}
|
||||
|
||||
const decoded: []const u8 = brk: switch (encoding) {
|
||||
// shares same decoder but encoder is different see encoding.zig
|
||||
.base64url, .base64 => {
|
||||
// do the same as Buffer.from(token, "base64url" | "base64")
|
||||
const slice = bun.strings.trim(token, "\r\n\t " ++ [_]u8{std.ascii.control_code.vt});
|
||||
if (slice.len == 0) return false;
|
||||
|
||||
const outlen = bun.base64.decodeLen(slice);
|
||||
if (outlen > buf.len) return false;
|
||||
const wrote = bun.base64.decode(buf[0..outlen], slice).count;
|
||||
break :brk buf[0..wrote];
|
||||
},
|
||||
.hex => {
|
||||
if (token.len % 2 != 0) return false;
|
||||
// decoded len
|
||||
const decoded_len = token.len / 2;
|
||||
if (decoded_len > buf.len) return false;
|
||||
const result = bun.strings.decodeHexToBytesTruncate(buf[0..decoded_len], u8, token);
|
||||
if (result == decoded_len) {
|
||||
break :brk buf[0..decoded_len];
|
||||
}
|
||||
return false;
|
||||
},
|
||||
};
|
||||
|
||||
// Minimum token length: 8 (timestamp) + 16 (nonce) + 8 (expires_in) + 32 (minimum HMAC-SHA256 size)
|
||||
if (decoded.len < 64) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Extract timestamp (first 8 bytes)
|
||||
const timestamp = std.mem.readInt(u64, decoded[0..8], .big);
|
||||
|
||||
// Check if token has expired
|
||||
const current_time = @as(u64, @bitCast(std.time.milliTimestamp()));
|
||||
// Extract expires_in (last 8 bytes)
|
||||
const expires_in = std.mem.readInt(u64, decoded[24..32], .big);
|
||||
{
|
||||
// respect the token's expiration time
|
||||
if (expires_in > 0) {
|
||||
if (current_time > timestamp + expires_in) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
{
|
||||
// repect options.max_age_ms
|
||||
const expiry = options.max_age_ms;
|
||||
if (expiry > 0) {
|
||||
if (current_time > timestamp + expiry) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Extract the parts
|
||||
const payload = decoded[0..32]; // timestamp + nonce + expires_in
|
||||
const received_signature = decoded[32..];
|
||||
|
||||
// Verify the signature
|
||||
var expected_signature: [boring.EVP_MAX_MD_SIZE]u8 = undefined;
|
||||
const signature = hmac.generate(options.secret, payload, options.algorithm, &expected_signature) orelse
|
||||
return false;
|
||||
|
||||
// Compare signatures in constant time
|
||||
if (received_signature.len != signature.len) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Use BoringSSL's constant-time comparison to prevent timing attacks
|
||||
return boring.CRYPTO_memcmp(
|
||||
received_signature.ptr,
|
||||
signature.ptr,
|
||||
signature.len,
|
||||
) == 0;
|
||||
}
|
||||
|
||||
/// JS binding function for generating CSRF tokens
|
||||
/// First argument is secret (required), second is options (optional)
|
||||
pub fn csrf__generate_impl(globalObject: *JSC.JSGlobalObject, callframe: *JSC.CallFrame) bun.JSError!JSC.JSValue {
|
||||
if (bun.Analytics.Features.csrf_generate < std.math.maxInt(usize))
|
||||
bun.Analytics.Features.csrf_generate += 1;
|
||||
|
||||
// We should have at least one argument (secret)
|
||||
const args = callframe.arguments();
|
||||
var secret: ?JSC.ZigString.Slice = null;
|
||||
if (args.len >= 1) {
|
||||
const jsSecret = args[0];
|
||||
// Extract the secret (required)
|
||||
if (jsSecret.isEmptyOrUndefinedOrNull()) {
|
||||
return globalObject.throwInvalidArguments("Secret is required", .{});
|
||||
}
|
||||
if (!jsSecret.isString() or jsSecret.getLength(globalObject) == 0) {
|
||||
return globalObject.throwInvalidArguments("Secret must be a non-empty string", .{});
|
||||
}
|
||||
secret = try jsSecret.toSlice(globalObject, bun.default_allocator);
|
||||
}
|
||||
defer if (secret) |s| s.deinit();
|
||||
|
||||
// Default values
|
||||
var expires_in: u64 = DEFAULT_EXPIRATION_MS;
|
||||
var encoding: TokenFormat = .base64url;
|
||||
var algorithm: JSC.API.Bun.Crypto.EVP.Algorithm = DEFAULT_ALGORITHM;
|
||||
|
||||
// Check if we have options object
|
||||
if (args.len > 1 and args[1].isObject()) {
|
||||
const options_value = args[1];
|
||||
|
||||
// Extract expiresIn (optional)
|
||||
if (try options_value.get(globalObject, "expiresIn")) |expires_in_js| {
|
||||
expires_in = @intCast(try globalObject.validateIntegerRange(expires_in_js, i64, 0, .{ .min = 0, .max = JSC.MAX_SAFE_INTEGER }));
|
||||
}
|
||||
|
||||
// Extract encoding (optional)
|
||||
if (try options_value.get(globalObject, "encoding")) |encoding_js| {
|
||||
const encoding_enum = try JSC.Node.Encoding.fromJSWithDefaultOnEmpty(encoding_js, globalObject, .base64url) orelse {
|
||||
return globalObject.throwInvalidArguments("Invalid format: must be 'base64', 'base64url', or 'hex'", .{});
|
||||
};
|
||||
encoding = switch (encoding_enum) {
|
||||
.base64 => .base64,
|
||||
.base64url => .base64url,
|
||||
.hex => .hex,
|
||||
else => return globalObject.throwInvalidArguments("Invalid format: must be 'base64', 'base64url', or 'hex'", .{}),
|
||||
};
|
||||
}
|
||||
|
||||
if (try options_value.get(globalObject, "algorithm")) |algorithm_js| {
|
||||
if (!algorithm_js.isString()) {
|
||||
return globalObject.throwInvalidArgumentTypeValue("algorithm", "string", algorithm_js);
|
||||
}
|
||||
algorithm = JSC.API.Bun.Crypto.EVP.Algorithm.map.fromJSCaseInsensitive(globalObject, algorithm_js) orelse {
|
||||
return globalObject.throwInvalidArguments("Algorithm not supported", .{});
|
||||
};
|
||||
switch (algorithm) {
|
||||
.blake2b256, .blake2b512, .sha256, .sha384, .sha512, .@"sha512-256" => {},
|
||||
else => return globalObject.throwInvalidArguments("Algorithm not supported", .{}),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Buffer for token generation
|
||||
var token_buffer: [512]u8 = undefined;
|
||||
|
||||
// Generate the token
|
||||
const token_bytes = generate(.{
|
||||
.secret = if (secret) |s| s.slice() else globalObject.bunVM().rareData().defaultCSRFSecret(),
|
||||
.expires_in_ms = expires_in,
|
||||
.encoding = encoding,
|
||||
.algorithm = algorithm,
|
||||
}, &token_buffer) catch |err| {
|
||||
return switch (err) {
|
||||
Error.TokenCreationFailed => globalObject.throw("Failed to create CSRF token", .{}),
|
||||
else => globalObject.throwError(err, "Failed to generate CSRF token"),
|
||||
};
|
||||
};
|
||||
|
||||
// Encode the token
|
||||
return encoding.toNodeEncoding().encodeWithMaxSize(globalObject, boring.EVP_MAX_MD_SIZE + 32, token_bytes);
|
||||
}
|
||||
|
||||
pub const csrf__generate: JSC.JSHostFunctionType = JSC.toJSHostFunction(csrf__generate_impl);
|
||||
|
||||
/// JS binding function for verifying CSRF tokens
|
||||
/// First argument is token (required), second is options (optional)
|
||||
pub fn csrf__verify_impl(globalObject: *JSC.JSGlobalObject, call_frame: *JSC.CallFrame) bun.JSError!JSC.JSValue {
|
||||
if (bun.Analytics.Features.csrf_verify < std.math.maxInt(usize)) {
|
||||
bun.Analytics.Features.csrf_verify += 1;
|
||||
}
|
||||
// We should have at least one argument (token)
|
||||
const args = call_frame.arguments();
|
||||
if (args.len < 1) {
|
||||
return globalObject.throwInvalidArguments("Missing required token parameter", .{});
|
||||
}
|
||||
const jsToken: JSC.JSValue = args[0];
|
||||
// Extract the token (required)
|
||||
if (jsToken.isUndefinedOrNull()) {
|
||||
return globalObject.throwInvalidArguments("Token is required", .{});
|
||||
}
|
||||
if (!jsToken.isString() or jsToken.getLength(globalObject) == 0) {
|
||||
return globalObject.throwInvalidArguments("Token must be a non-empty string", .{});
|
||||
}
|
||||
const token = try jsToken.toSlice(globalObject, bun.default_allocator);
|
||||
defer token.deinit();
|
||||
|
||||
// Default values
|
||||
var secret: ?JSC.ZigString.Slice = null;
|
||||
defer if (secret) |s| s.deinit();
|
||||
var max_age: u64 = DEFAULT_EXPIRATION_MS;
|
||||
var encoding: TokenFormat = .base64url;
|
||||
|
||||
var algorithm: JSC.API.Bun.Crypto.EVP.Algorithm = DEFAULT_ALGORITHM;
|
||||
|
||||
// Check if we have options object
|
||||
if (args.len > 1 and args[1].isObject()) {
|
||||
const options_value = args[1];
|
||||
|
||||
// Extract the secret (required)
|
||||
if (try options_value.getOptional(globalObject, "secret", JSC.ZigString.Slice)) |secretSlice| {
|
||||
if (secretSlice.len == 0) {
|
||||
return globalObject.throwInvalidArguments("Secret must be a non-empty string", .{});
|
||||
}
|
||||
secret = secretSlice;
|
||||
}
|
||||
|
||||
// Extract maxAge (optional)
|
||||
if (try options_value.get(globalObject, "maxAge")) |max_age_js| {
|
||||
max_age = @intCast(try globalObject.validateIntegerRange(max_age_js, i64, 0, .{ .min = 0, .max = JSC.MAX_SAFE_INTEGER }));
|
||||
}
|
||||
|
||||
// Extract encoding (optional)
|
||||
if (try options_value.get(globalObject, "encoding")) |encoding_js| {
|
||||
const encoding_enum = try JSC.Node.Encoding.fromJSWithDefaultOnEmpty(encoding_js, globalObject, .base64url) orelse {
|
||||
return globalObject.throwInvalidArguments("Invalid format: must be 'base64', 'base64url', or 'hex'", .{});
|
||||
};
|
||||
encoding = switch (encoding_enum) {
|
||||
.base64 => .base64,
|
||||
.base64url => .base64url,
|
||||
.hex => .hex,
|
||||
else => return globalObject.throwInvalidArguments("Invalid format: must be 'base64', 'base64url', or 'hex'", .{}),
|
||||
};
|
||||
}
|
||||
if (try options_value.get(globalObject, "algorithm")) |algorithm_js| {
|
||||
if (!algorithm_js.isString()) {
|
||||
return globalObject.throwInvalidArgumentTypeValue("algorithm", "string", algorithm_js);
|
||||
}
|
||||
algorithm = JSC.API.Bun.Crypto.EVP.Algorithm.map.fromJSCaseInsensitive(globalObject, algorithm_js) orelse {
|
||||
return globalObject.throwInvalidArguments("Algorithm not supported", .{});
|
||||
};
|
||||
switch (algorithm) {
|
||||
.blake2b256, .blake2b512, .sha256, .sha384, .sha512, .@"sha512-256" => {},
|
||||
else => return globalObject.throwInvalidArguments("Algorithm not supported", .{}),
|
||||
}
|
||||
}
|
||||
}
|
||||
// Verify the token
|
||||
const is_valid = verify(.{
|
||||
.token = token.slice(),
|
||||
.secret = if (secret) |s| s.slice() else globalObject.bunVM().rareData().defaultCSRFSecret(),
|
||||
.max_age_ms = max_age,
|
||||
.encoding = encoding,
|
||||
.algorithm = algorithm,
|
||||
});
|
||||
|
||||
return JSC.JSValue.jsBoolean(is_valid);
|
||||
}
|
||||
|
||||
pub const csrf__verify: JSC.JSHostFunctionType = JSC.toJSHostFunction(csrf__verify_impl);
|
||||
144
test/js/bun/util/csrf.test.ts
Normal file
144
test/js/bun/util/csrf.test.ts
Normal file
@@ -0,0 +1,144 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { CSRF, type CSRFAlgorithm } from "bun";
|
||||
describe("Bun.CSRF", () => {
|
||||
const secret = "this-is-my-super-secure-secret-key";
|
||||
|
||||
test("CSRF exists", () => {
|
||||
expect(CSRF).toBeDefined();
|
||||
expect(typeof CSRF).toBe("object");
|
||||
expect(typeof CSRF.generate).toBe("function");
|
||||
expect(typeof CSRF.verify).toBe("function");
|
||||
});
|
||||
|
||||
test("generates a token with default options", () => {
|
||||
const token = CSRF.generate(secret);
|
||||
expect(typeof token).toBe("string");
|
||||
expect(token.length).toBeGreaterThan(0);
|
||||
|
||||
// Should be a base64url token (contains only base64url-safe characters)
|
||||
expect(token).toMatch(/^[A-Za-z0-9_-]+$/);
|
||||
});
|
||||
|
||||
test("generates a token with different formats", () => {
|
||||
// Base64 format
|
||||
const base64Token = CSRF.generate(secret, { encoding: "base64" });
|
||||
expect(typeof base64Token).toBe("string");
|
||||
expect(base64Token).toMatch(/^[A-Za-z0-9+/]+={0,2}$/);
|
||||
|
||||
// Hex format
|
||||
const hexToken = CSRF.generate(secret, { encoding: "hex" });
|
||||
expect(typeof hexToken).toBe("string");
|
||||
expect(hexToken).toMatch(/^[0-9a-f]+$/);
|
||||
});
|
||||
|
||||
test("verifies a valid token", () => {
|
||||
const token = CSRF.generate(secret);
|
||||
const isValid = CSRF.verify(token, { secret });
|
||||
expect(isValid).toBe(true);
|
||||
});
|
||||
|
||||
test("rejects an invalid token", () => {
|
||||
const token = CSRF.generate(secret);
|
||||
|
||||
// Tamper with the token
|
||||
const tamperedToken = token.substring(0, token.length - 5) + "XXXXX";
|
||||
|
||||
const isValid = CSRF.verify(tamperedToken, { secret });
|
||||
expect(isValid).toBe(false);
|
||||
});
|
||||
|
||||
test("token verification is sensitive to the secret", () => {
|
||||
const token = CSRF.generate(secret);
|
||||
|
||||
// Try to verify with a different secret
|
||||
const isValid = CSRF.verify(token, { secret: "wrong-secret" });
|
||||
expect(isValid).toBe(false);
|
||||
});
|
||||
|
||||
test("tokens expire after the specified time", async () => {
|
||||
// Generate a token with a very short expiration (1 millisecond)
|
||||
const token = CSRF.generate(secret, {
|
||||
expiresIn: 1,
|
||||
});
|
||||
|
||||
// Wait a bit to ensure expiration
|
||||
await Bun.sleep(10);
|
||||
|
||||
// Should be expired now
|
||||
const isValid = CSRF.verify(token, { secret });
|
||||
expect(isValid).toBe(false);
|
||||
});
|
||||
|
||||
test("verification respects maxAge parameter", async () => {
|
||||
// Generate a token with default expiration (24 hours)
|
||||
const token = CSRF.generate(secret);
|
||||
|
||||
// But verify with a very short maxAge (1 millisecond)
|
||||
await Bun.sleep(10);
|
||||
|
||||
// Should be rejected because our maxAge is very short
|
||||
const isValid = CSRF.verify(token, { secret, maxAge: 1 });
|
||||
expect(isValid).toBe(false);
|
||||
});
|
||||
|
||||
test("token with expiresIn parameter works", async () => {
|
||||
// Generate a token with a longer expiration (1 second)
|
||||
const token = CSRF.generate(secret, {
|
||||
expiresIn: 100,
|
||||
});
|
||||
|
||||
// Should be valid immediately
|
||||
expect(CSRF.verify(token, { secret })).toBe(true);
|
||||
|
||||
// Should still be valid after a short time
|
||||
await Bun.sleep(10);
|
||||
expect(CSRF.verify(token, { secret })).toBe(true);
|
||||
|
||||
// Ensure that expiration works properly
|
||||
await Bun.sleep(100);
|
||||
expect(CSRF.verify(token, { secret })).toBe(false);
|
||||
});
|
||||
|
||||
test("token format doesn't affect verification", () => {
|
||||
// Test that tokens in different formats can all be verified
|
||||
const base64Token = CSRF.generate(secret, { encoding: "base64" });
|
||||
const base64urlToken = CSRF.generate(secret, { encoding: "base64url" });
|
||||
const hexToken = CSRF.generate(secret, { encoding: "hex" });
|
||||
|
||||
expect(CSRF.verify(base64Token, { secret, encoding: "base64" })).toBe(true);
|
||||
expect(CSRF.verify(base64urlToken, { secret, encoding: "base64url" })).toBe(true);
|
||||
expect(CSRF.verify(hexToken, { secret, encoding: "hex" })).toBe(true);
|
||||
});
|
||||
|
||||
test("test with default algorithm", async () => {
|
||||
// default
|
||||
const token = CSRF.generate(secret);
|
||||
expect(CSRF.verify(token, { secret })).toBe(true);
|
||||
});
|
||||
const algorithms: Array<CSRFAlgorithm> = ["blake2b256", "blake2b512", "sha256", "sha384", "sha512", "sha512-256"];
|
||||
for (const algorithm of algorithms) {
|
||||
test(`test with algorithm ${algorithm}`, async () => {
|
||||
const token2 = CSRF.generate(secret, { algorithm });
|
||||
expect(CSRF.verify(token2, { secret, algorithm })).toBe(true);
|
||||
});
|
||||
}
|
||||
|
||||
test("default secret", () => {
|
||||
const token = CSRF.generate();
|
||||
expect(token).toBeDefined();
|
||||
expect(token.length).toBeGreaterThan(0);
|
||||
expect(CSRF.verify(token, { secret: "wrong-secret" })).toBe(false);
|
||||
expect(CSRF.verify(token)).toBe(true);
|
||||
});
|
||||
|
||||
test("error handling", () => {
|
||||
// Empty token
|
||||
expect(() => CSRF.verify("", { secret })).toThrow();
|
||||
|
||||
// Empty secret for generation
|
||||
expect(() => CSRF.generate("")).toThrow();
|
||||
|
||||
// Empty secret for verification
|
||||
expect(() => CSRF.verify("some-token", { secret: "" })).toThrow();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user