diff --git a/packages/bun-types/bun.d.ts b/packages/bun-types/bun.d.ts index 3fed4ec2bc..861744c02b 100644 --- a/packages/bun-types/bun.d.ts +++ b/packages/bun-types/bun.d.ts @@ -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?: diff --git a/src/analytics/analytics_thread.zig b/src/analytics/analytics_thread.zig index cd5fd5abc7..6410a85836 100644 --- a/src/analytics/analytics_thread.zig +++ b/src/analytics/analytics_thread.zig @@ -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" }); diff --git a/src/bun.js/api/BunObject.zig b/src/bun.js/api/BunObject.zig index 034fdc0de0..6c2ed8272c 100644 --- a/src/bun.js/api/BunObject.zig +++ b/src/bun.js/api/BunObject.zig @@ -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 { diff --git a/src/bun.js/bindings/BunObject+exports.h b/src/bun.js/bindings/BunObject+exports.h index 0e2563398d..d05a8cde6a 100644 --- a/src/bun.js/bindings/BunObject+exports.h +++ b/src/bun.js/bindings/BunObject+exports.h @@ -33,6 +33,7 @@ macro(embeddedFiles) \ macro(S3Client) \ macro(s3) \ + macro(CSRF) \ // --- Callbacks --- #define FOR_EACH_CALLBACK(macro) \ diff --git a/src/bun.js/bindings/BunObject.cpp b/src/bun.js/bindings/BunObject.cpp index ff800aa188..8b48e654b3 100644 --- a/src/bun.js/bindings/BunObject.cpp +++ b/src/bun.js/bindings/BunObject.cpp @@ -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 diff --git a/src/bun.js/bindings/bindings.zig b/src/bun.js/bindings/bindings.zig index 2c1e52edd2..615bd9e041 100644 --- a/src/bun.js/bindings/bindings.zig +++ b/src/bun.js/bindings/bindings.zig @@ -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); } diff --git a/src/bun.js/rare_data.zig b/src/bun.js/rare_data.zig index f69bfca2f5..939d56d309 100644 --- a/src/bun.js/rare_data.zig +++ b/src/bun.js/rare_data.zig @@ -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); } diff --git a/src/bun.zig b/src/bun.zig index cb0de7a020..0779b201ba 100644 --- a/src/bun.zig +++ b/src/bun.zig @@ -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"); diff --git a/src/csrf.zig b/src/csrf.zig new file mode 100644 index 0000000000..bf6709cbd1 --- /dev/null +++ b/src/csrf.zig @@ -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); diff --git a/test/js/bun/util/csrf.test.ts b/test/js/bun/util/csrf.test.ts new file mode 100644 index 0000000000..00e5c367b3 --- /dev/null +++ b/test/js/bun/util/csrf.test.ts @@ -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 = ["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(); + }); +});