diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a49c000f..0bf28e9a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -84,7 +84,6 @@ jobs: env: DATABASE_URL: ${{ matrix.db_url }} RUST_BACKTRACE: 1 - RUST_LOG: sqlpage=debug windows_test: runs-on: windows-latest diff --git a/CHANGELOG.md b/CHANGELOG.md index ed763f1d..b67566f7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,14 @@ # CHANGELOG.md +## v0.38.0 + - Added a new `sqlpage.hmac()` function for cryptographic HMAC (Hash-based Message Authentication Code) operations. + - Create and verify secure signatures for webhooks (Shopify, Stripe, GitHub, etc.) + - Generate tamper-proof tokens for API authentication + - Secure download links and temporary access codes + - Supports SHA-256 (default) and SHA-512 algorithms + - Output formats: hexadecimal (default) or base64 (e.g., `sha256-base64`) + - See the [function documentation](https://sql-page.com/functions.sql?function=hmac) for detailed examples + ## v0.37.1 - fixed decoding of UUID values - Fixed handling of NULL values in `sqlpage.link`. They were encoded as the string `'null'` instead of being omitted from the link's parameters. diff --git a/Cargo.lock b/Cargo.lock index 48d02933..44edd790 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4277,6 +4277,7 @@ dependencies = [ "env_logger", "futures-util", "handlebars", + "hmac", "include_dir", "lambda-web", "libflate", @@ -4292,6 +4293,7 @@ dependencies = [ "rustls-native-certs", "serde", "serde_json", + "sha2", "sqlparser", "sqlx-oldapi", "tokio", diff --git a/Cargo.toml b/Cargo.toml index 4887e6bf..cf7bc4cf 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -61,6 +61,8 @@ actix-web-httpauth = "0.8.0" rand = "0.9.0" actix-multipart = "0.7.2" base64 = "0.22" +hmac = "0.12" +sha2 = "0.10" rustls-acme = "0.14" dotenvy = "0.15.7" csv-async = { version = "1.2.6", features = ["tokio"] } diff --git a/examples/official-site/sqlpage/migrations/67_hmac_function.sql b/examples/official-site/sqlpage/migrations/67_hmac_function.sql new file mode 100644 index 00000000..e667f03f --- /dev/null +++ b/examples/official-site/sqlpage/migrations/67_hmac_function.sql @@ -0,0 +1,137 @@ +-- HMAC function documentation and examples +INSERT INTO + sqlpage_functions ( + "name", + "introduced_in_version", + "icon", + "description_md" + ) +VALUES + ( + 'hmac', + '0.38.0', + 'shield-lock', + 'Creates a unique "signature" for some data using a secret key. +This signature proves that the data hasn''t been tampered with and comes from someone who knows the secret. + +### What is HMAC used for? + +[**HMAC**](https://en.wikipedia.org/wiki/HMAC) (Hash-based Message Authentication Code) is commonly used to: + - **Verify webhooks**: Use HMAC to ensure only a given external service can call a given endpoint in your application. +The service signs their request with a secret key, and you verify the signature before processing the data they sent you. +Used for instance by [Stripe](https://docs.stripe.com/webhooks?verify=verify-manually), and [Shopify](https://shopify.dev/docs/apps/build/webhooks/subscribe/https#step-2-validate-the-origin-of-your-webhook-to-ensure-its-coming-from-shopify). + - **Secure API requests**: Prove that an API request comes from an authorized source + - **Generate secure tokens**: Create temporary access codes for downloads or password resets + - **Protect data**: Ensure data hasn''t been modified during transmission + +### How to use it + +The `sqlpage.hmac` function takes three inputs: +1. **Your data** - The text you want to sign (like a message or request body) +2. **Your secret key** - A password only you know (keep this safe!) +3. **Algorithm** (optional) - The hash algorithm and output format: + - `sha256` (default) - SHA-256 with hexadecimal output + - `sha256-base64` - SHA-256 with base64 output + - `sha512` - SHA-512 with hexadecimal output + - `sha512-base64` - SHA-512 with base64 output + +It returns a signature string. If someone changes even one letter in your data, the signature will be completely different. + +### Example: Verify a Webhooks signature + +When Shopify sends you a webhook (like when someone places an order), it includes a signature. Here''s how to verify it''s really from Shopify. +This supposes you store the secret key in an [environment variable](https://en.wikipedia.org/wiki/Environment_variable) named `WEBHOOK_SECRET`. + +```sql +SET body = sqlpage.request_body(); +SET secret = sqlpage.environment_variable(''WEBHOOK_SECRET''); +SET expected_signature = sqlpage.hmac($body, $secret, ''sha256''); +SET actual_signature = sqlpage.header(''X-Webhook-Signature''); + +-- redirect to an error page and stop execution if the signature does not match +SELECT + ''redirect'' as component, + ''/error.sql?err=bad_webhook_signature'' as link +WHERE $actual_signature != $expected_signature OR $actual_signature IS NULL; + +-- If we reach here, the signature is valid - process the order +INSERT INTO orders (order_data) VALUES ($body); + +SELECT ''json'' as component, ''jsonlines'' as type; +SELECT ''success'' as status; +``` + +### Example: Time-limited links + +You can create links that will be valid only for a limited time by including a signature in them. +Let''s say we have a `download.sql` page we want to link to, +but we don''t want it to be accessible to anyone who can find the link. +Sign `file_id|expires_at` with a secret. Accept only if not expired and the signature matches. + +#### Generate a signed link + +```sql +SET expires_at = datetime(''now'', ''+1 hour''); +SET token = sqlpage.hmac( + $file_id || ''|'' || $expires_at, + sqlpage.environment_variable(''DOWNLOAD_SECRET''), + ''sha256'' +); +SELECT ''/download.sql?file_id='' || $file_id || ''&expires_at='' || $expires_at || ''&token='' || $token AS link; +``` + +#### Verify the signed link + +```sql +SET expected = sqlpage.hmac( + $file_id || ''|'' || $expires_at, + sqlpage.environment_variable(''DOWNLOAD_SECRET''), + ''sha256'' +); +SELECT ''redirect'' AS component, ''/error.sql?err=expired'' AS link +WHERE $expected != $token OR $token IS NULL OR $expires_at < datetime(''now''); + +-- serve the file +``` + +### Important Security Notes + + - **Keep your secret key safe**: If your secret leaks, anyone can forge signatures and access protected pages + - **The signature is case-sensitive**: Even a single wrong letter means the signature won''t match + - **NULL handling**: Always use `IS DISTINCT FROM`, not `=` to check for hmac matches. + - `SELECT ''redirect'' as component WHERE sqlpage.hmac(...) != $signature` will not redirect if `$signature` is NULL (the signature is absent). + - `SELECT ''redirect'' as component WHERE sqlpage.hmac(...) IS DISTINCT FROM $signature` checks for both NULL and non-NULL values (but is not available in all SQL dialects). + - `SELECT ''redirect'' as component WHERE sqlpage.hmac(...) != $signature OR $signature IS NULL` is the most portable solution. +' + ); + +INSERT INTO + sqlpage_function_parameters ( + "function", + "index", + "name", + "description_md", + "type" + ) +VALUES + ( + 'hmac', + 1, + 'data', + 'The input data to compute the HMAC for. Can be any text string. Cannot be NULL.', + 'TEXT' + ), + ( + 'hmac', + 2, + 'key', + 'The secret key used to compute the HMAC. Should be kept confidential. Cannot be NULL.', + 'TEXT' + ), + ( + 'hmac', + 3, + 'algorithm', + 'The hash algorithm and output format. Optional, defaults to `sha256` (hex output). Supported values: `sha256`, `sha256-base64`, `sha512`, `sha512-base64`. Defaults to `sha256`.', + 'TEXT' + ); \ No newline at end of file diff --git a/src/webserver/database/sqlpage_functions/functions.rs b/src/webserver/database/sqlpage_functions/functions.rs index 58beebaf..169ebc8b 100644 --- a/src/webserver/database/sqlpage_functions/functions.rs +++ b/src/webserver/database/sqlpage_functions/functions.rs @@ -32,6 +32,7 @@ super::function_definition_macro::sqlpage_functions! { hash_password(password: Option); header((&RequestInfo), name: Cow); headers((&RequestInfo)); + hmac(data: Cow, key: Cow, algorithm: Option>); user_info_token((&RequestInfo)); link(file: Cow, parameters: Option>, hash: Option>); @@ -738,10 +739,83 @@ async fn headers(request: &RequestInfo) -> String { serde_json::to_string(&request.headers).unwrap_or_default() } +/// Computes the HMAC (Hash-based Message Authentication Code) of the input data +/// using the specified key and hashing algorithm. +async fn hmac<'a>( + data: Cow<'a, str>, + key: Cow<'a, str>, + algorithm: Option>, +) -> anyhow::Result> { + use hmac::{Hmac, Mac}; + use sha2::{Sha256, Sha512}; + + let algorithm = algorithm.as_deref().unwrap_or("sha256"); + + // Parse algorithm and output format (e.g., "sha256" or "sha256-base64") + let (hash_algo, output_format) = if let Some((algo, format)) = algorithm.split_once('-') { + (algo, format) + } else { + (algorithm, "hex") + }; + + let result = match hash_algo.to_lowercase().as_str() { + "sha256" => { + let mut mac = Hmac::::new_from_slice(key.as_bytes()) + .map_err(|e| anyhow!("Invalid HMAC key: {e}"))?; + mac.update(data.as_bytes()); + mac.finalize().into_bytes().to_vec() + } + "sha512" => { + let mut mac = Hmac::::new_from_slice(key.as_bytes()) + .map_err(|e| anyhow!("Invalid HMAC key: {e}"))?; + mac.update(data.as_bytes()); + mac.finalize().into_bytes().to_vec() + } + _ => { + anyhow::bail!( + "Unsupported HMAC algorithm: {hash_algo}. Supported algorithms: sha256, sha512" + ) + } + }; + + // Convert to requested output format + let output = match output_format.to_lowercase().as_str() { + "hex" => result.into_iter().fold(String::new(), |mut acc, byte| { + write!(&mut acc, "{byte:02x}").unwrap(); + acc + }), + "base64" => base64::Engine::encode(&base64::engine::general_purpose::STANDARD, result), + _ => { + anyhow::bail!( + "Unsupported output format: {output_format}. Supported formats: hex, base64" + ) + } + }; + + Ok(Some(output)) +} + async fn client_ip(request: &RequestInfo) -> Option { Some(request.client_ip?.to_string()) } +#[tokio::test] +async fn test_hmac() { + // Test vector from RFC 4231 - HMAC-SHA256 + let result = hmac( + Cow::Borrowed("The quick brown fox jumps over the lazy dog"), + Cow::Borrowed("key"), + Some(Cow::Borrowed("sha256")), + ) + .await + .unwrap() + .unwrap(); + assert_eq!( + result, + "f7bc83f430538424b13298e6aa6fb143ef4d59a14946175997479dbc2d1a3cd8" + ); +} + /// Returns the ID token claims as a JSON object. async fn user_info_token(request: &RequestInfo) -> anyhow::Result> { let Some(claims) = &request.oidc_claims else { diff --git a/tests/requests/mod.rs b/tests/requests/mod.rs index 992ccce4..a99af008 100644 --- a/tests/requests/mod.rs +++ b/tests/requests/mod.rs @@ -119,3 +119,5 @@ async fn test_large_form_field_roundtrip() -> actix_web::Result<()> { ); Ok(()) } + +mod webhook_hmac; diff --git a/tests/requests/webhook_hmac.rs b/tests/requests/webhook_hmac.rs new file mode 100644 index 00000000..12e61d66 --- /dev/null +++ b/tests/requests/webhook_hmac.rs @@ -0,0 +1,93 @@ +use actix_web::{http::StatusCode, test}; +use sqlpage::webserver::http::main_handler; + +use crate::common::get_request_to; + +#[actix_web::test] +async fn test_webhook_hmac_invalid_signature() -> actix_web::Result<()> { + // Set up environment variable for webhook secret + std::env::set_var("WEBHOOK_SECRET", "test-secret-key"); + + let webhook_body = r#"{"order_id":12345,"total":"99.99"}"#; + let invalid_signature = "96a5f6f65c85a2d4d1f3a37813ab2c0b44041bdc17691fbb0884e3eb52b7c54b"; + + let req = get_request_to("/tests/webhook_hmac_validation.sql") + .await? + .insert_header(("content-type", "application/json")) + .insert_header(("X-Webhook-Signature", invalid_signature)) + .set_payload(webhook_body) + .to_srv_request(); + + let resp = main_handler(req).await?; + + // Should redirect to error page when signature is invalid + assert!( + resp.status() == StatusCode::FOUND || resp.status() == StatusCode::SEE_OTHER, + "Expected redirect (302 or 303) for invalid signature, got: {}", + resp.status() + ); + + let location = resp + .headers() + .get("location") + .expect("Should have Location header") + .to_str() + .unwrap(); + assert_eq!(location, "/error.sql?err=bad_webhook_signature"); + Ok(()) +} + +#[actix_web::test] +async fn test_webhook_hmac_valid_signature() -> actix_web::Result<()> { + // Set up environment variable for webhook secret + std::env::set_var("WEBHOOK_SECRET", "test-secret-key"); + + let webhook_body = r#"{"order_id":12345,"total":"99.99"}"#; + let valid_signature = "260b3b5ead84843645588af82d5d2c3fe24c598a950d36c45438c3a5f5bb941c"; + + let req = get_request_to("/tests/webhook_hmac_validation.sql") + .await? + .insert_header(("content-type", "application/json")) + .insert_header(("X-Webhook-Signature", valid_signature)) + .set_payload(webhook_body) + .to_srv_request(); + + let resp = main_handler(req).await?; + + // Should return success when signature is valid + assert_eq!(resp.status(), StatusCode::OK, "200 resp for signed req"); + assert!(!resp.headers().contains_key("location"), "no redirect"); + + assert_eq!( + test::read_body_json::(resp).await, + serde_json::json! ({"msg": "Webhook signature is valid !"}) + ); + Ok(()) +} + +#[actix_web::test] +async fn test_webhook_hmac_missing_signature() -> actix_web::Result<()> { + // Set up environment variable for webhook secret + std::env::set_var("WEBHOOK_SECRET", "test-secret-key"); + + let webhook_body = r#"{"order_id":12345,"total":"99.99"}"#; + + // Don't include the X-Webhook-Signature header + let req = get_request_to("/tests/webhook_hmac_validation.sql") + .await? + .insert_header(("content-type", "application/json")) + .set_payload(webhook_body) + .to_srv_request(); + + let resp = main_handler(req).await?; + + let location = resp + .headers() + .get("location") + .expect("Should have Location header") + .to_str() + .unwrap(); + assert_eq!(location, "/error.sql?err=bad_webhook_signature"); + + Ok(()) +} diff --git a/tests/sql_test_files/it_works_hmac_base64.sql b/tests/sql_test_files/it_works_hmac_base64.sql new file mode 100644 index 00000000..32132cb6 --- /dev/null +++ b/tests/sql_test_files/it_works_hmac_base64.sql @@ -0,0 +1,6 @@ +-- Test HMAC with base64 output format +-- Redirect if hash doesn't match expected value +SELECT 'redirect' as component, '/error.sql' as link +WHERE sqlpage.hmac('The quick brown fox jumps over the lazy dog', 'key', 'sha256-base64') != '97yD9DBThCSxMpjmqm+xQ+9NWaFJRhdZl0edvC0aPNg='; + +SELECT 'text' as component, 'It works ! HMAC SHA-256 base64 output is correct' as contents; \ No newline at end of file diff --git a/tests/sql_test_files/it_works_hmac_default.sql b/tests/sql_test_files/it_works_hmac_default.sql new file mode 100644 index 00000000..df93da49 --- /dev/null +++ b/tests/sql_test_files/it_works_hmac_default.sql @@ -0,0 +1,5 @@ +-- Redirect if default algorithm doesn't match sha256 +SELECT 'redirect' as component, '/error.sql' as link +WHERE sqlpage.hmac('test data', 'test key') != sqlpage.hmac('test data', 'test key', 'sha256'); + +SELECT 'text' as component, 'It works ! HMAC default algorithm is SHA-256' as contents; \ No newline at end of file diff --git a/tests/sql_test_files/it_works_hmac_sha256.sql b/tests/sql_test_files/it_works_hmac_sha256.sql new file mode 100644 index 00000000..35334387 --- /dev/null +++ b/tests/sql_test_files/it_works_hmac_sha256.sql @@ -0,0 +1,5 @@ +-- Redirect if hash doesn't match expected value +SELECT 'redirect' as component, '/error.sql' as link +WHERE sqlpage.hmac('The quick brown fox jumps over the lazy dog', 'key', 'sha256') != 'f7bc83f430538424b13298e6aa6fb143ef4d59a14946175997479dbc2d1a3cd8'; + +SELECT 'text' as component, 'It works ! HMAC SHA-256 hash is correct' as contents; \ No newline at end of file diff --git a/tests/sql_test_files/it_works_hmac_sha512.sql b/tests/sql_test_files/it_works_hmac_sha512.sql new file mode 100644 index 00000000..0b6ad9e1 --- /dev/null +++ b/tests/sql_test_files/it_works_hmac_sha512.sql @@ -0,0 +1,5 @@ +-- Redirect if hash doesn't match expected value +SELECT 'redirect' as component, '/error.sql' as link +WHERE sqlpage.hmac('The quick brown fox jumps over the lazy dog', 'key', 'sha512') != 'b42af09057bac1e2d41708e48a902e09b5ff7f12ab428a4fe86653c73dd248fb82f948a549f7b791a5b41915ee4d1ec3935357e4e2317250d0372afa2ebeeb3a'; + +SELECT 'text' as component, 'It works ! HMAC SHA-512 hash is correct' as contents; \ No newline at end of file diff --git a/tests/sql_test_files/it_works_hmac_shopify_webhook.sql b/tests/sql_test_files/it_works_hmac_shopify_webhook.sql new file mode 100644 index 00000000..909ee8a2 --- /dev/null +++ b/tests/sql_test_files/it_works_hmac_shopify_webhook.sql @@ -0,0 +1,15 @@ +-- Test Shopify webhook HMAC validation with base64 output +-- Shopify sends webhook body and HMAC signature in X-Shopify-Hmac-SHA256 header (base64 format) + +-- Redirect to error if signature doesn't match (proper pattern) +SELECT 'redirect' as component, + '/error.sql?msg=invalid_signature' as link +WHERE sqlpage.hmac( + '{"id":1234567890,"email":"customer@example.com","total_price":"123.45"}', + 'test-webhook-secret', + 'sha256-base64' +) != 'QNyObTlKbMx2qDlPF/ZOZcBqg5OgPg+2oky3zldc0Gw='; + +-- If we reach here, signature is valid +SELECT 'text' as component, + 'It works ! Shopify webhook signature verified (base64 format)' as contents; \ No newline at end of file diff --git a/tests/webhook_hmac_validation.sql b/tests/webhook_hmac_validation.sql new file mode 100644 index 00000000..db431158 --- /dev/null +++ b/tests/webhook_hmac_validation.sql @@ -0,0 +1,17 @@ +-- Webhook HMAC signature validation example +-- This simulates receiving a webhook with HMAC signature in header +-- Redirect to error page if signature is invalid +-- test this with: curl localhost:8080/tests/webhook_hmac_validation.sql -H 'X-Webhook-Signature: 260b3b5ead84843645588af82d5d2c3fe24c598a950d36c45438c3a5f5bb941c' -H 'Content-Type: application/json' --data-raw '{"order_id":12345,"total":"99.99"}' -v +SET body = sqlpage.request_body(); +SET secret = sqlpage.environment_variable('WEBHOOK_SECRET'); +SET expected_signature = sqlpage.hmac($body, $secret, 'sha256'); +SET actual_signature = sqlpage.header('X-Webhook-Signature'); + +SELECT + 'redirect' as component, + '/error.sql?err=bad_webhook_signature' as link +WHERE $actual_signature != $expected_signature OR $actual_signature IS NULL; + +-- If we reach here, signature is valid - return success +SELECT 'json' as component, 'jsonlines' as type; +select 'Webhook signature is valid !' as msg; \ No newline at end of file