Skip to content

Commit 317784d

Browse files
committed
Refactor HMAC function and update SQL examples for clarity and consistency
- Changed function parameters to remove Option types for data and key in the HMAC function. - Improved SQL documentation and examples for HMAC usage, including clearer descriptions and updated error handling. - Enhanced test cases for webhook HMAC validation to ensure accurate signature checks and responses. - Removed obsolete test file for HMAC with null values.
1 parent 690c80a commit 317784d

File tree

5 files changed

+95
-155
lines changed

5 files changed

+95
-155
lines changed
Lines changed: 62 additions & 60 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,25 @@
11
-- HMAC function documentation and examples
2-
3-
INSERT INTO sqlpage_functions (
2+
INSERT INTO
3+
sqlpage_functions (
44
"name",
55
"introduced_in_version",
66
"icon",
77
"description_md"
88
)
9-
VALUES (
9+
VALUES
10+
(
1011
'hmac',
1112
'0.38.0',
1213
'shield-lock',
13-
'Creates a unique "signature" for your data using a secret key. This signature proves that the data hasn''t been tampered with and comes from someone who knows the secret.
14-
15-
Think of it like a wax seal on a letter - only someone with the right seal (your secret key) can create it, and if someone changes the letter, the seal won''t match anymore.
14+
'Creates a unique "signature" for some data using a secret key.
15+
This signature proves that the data hasn''t been tampered with and comes from someone who knows the secret.
1616
1717
### What is HMAC used for?
1818
19-
**HMAC** (Hash-based Message Authentication Code) is commonly used to:
20-
- **Verify webhooks**: Check that notifications from services like Shopify, Stripe, or GitHub are genuine
19+
[**HMAC**](https://en.wikipedia.org/wiki/HMAC) (Hash-based Message Authentication Code) is commonly used to:
20+
- **Verify webhooks**: Use HMAC to ensure only a given external service can call a given endpoint in your application.
21+
The service signs their request with a secret key, and you verify the signature before processing the data they sent you.
22+
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).
2123
- **Secure API requests**: Prove that an API request comes from an authorized source
2224
- **Generate secure tokens**: Create temporary access codes for downloads or password resets
2325
- **Protect data**: Ensure data hasn''t been modified during transmission
@@ -35,98 +37,98 @@ The `sqlpage.hmac` function takes three inputs:
3537
3638
It returns a signature string. If someone changes even one letter in your data, the signature will be completely different.
3739
38-
### Example 1: Verify Shopify Webhooks
40+
### Example: Verify a Webhooks signature
3941
40-
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:
42+
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.
43+
This supposes you store the secret key in an [environment variable](https://en.wikipedia.org/wiki/Environment_variable) named `WEBHOOK_SECRET`.
4144
4245
```sql
43-
-- Shopify includes the signature in the X-Shopify-Hmac-SHA256 header
44-
-- and sends the webhook data in the request body
45-
46-
-- First, verify the signature - redirect to error page if invalid
47-
SELECT ''redirect'' as component,
48-
''/error.sql?message='' || sqlpage.url_encode(''Invalid webhook signature'') as link
49-
WHERE sqlpage.hmac(
50-
sqlpage.request_body(),
51-
sqlpage.environment_variable(''SHOPIFY_WEBHOOK_SECRET''),
52-
''sha256-base64''
53-
) != sqlpage.header(''X-Shopify-Hmac-SHA256'');
54-
55-
-- If we reach here, the signature is valid - process the order:
56-
INSERT INTO orders (order_data, received_at)
57-
VALUES (sqlpage.request_body(), datetime(''now''));
58-
59-
SELECT ''text'' as component,
60-
''✅ Webhook verified and processed successfully!'' as contents;
46+
SET body = sqlpage.request_body();
47+
SET secret = sqlpage.environment_variable(''WEBHOOK_SECRET'');
48+
SET expected_signature = sqlpage.hmac($body, $secret, ''sha256'');
49+
SET actual_signature = sqlpage.header(''X-Webhook-Signature'');
50+
51+
-- redirect to an error page and stop execution if the signature does not match
52+
SELECT
53+
''redirect'' as component,
54+
''/error.sql?err=bad_webhook_signature'' as link
55+
WHERE $actual_signature IS DISTINCT FROM $expected_signature;
56+
57+
-- If we reach here, the signature is valid - process the order
58+
INSERT INTO orders (order_data) VALUES ($body);
59+
60+
SELECT ''json'' as component, ''jsonlines'' as type;
61+
SELECT ''success'' as status;
6162
```
6263
63-
### Example 2: Create Secure Download Links
64+
### Example: Time-limited links
6465
65-
Generate a token that expires after 1 hour:
66+
You can create links that will be valid only for a limited time by including a signature in them.
67+
Let''s say we have a `download.sql` page we want to link to,
68+
but we don''t want it to be accessible to anyone who can find the link.
69+
Sign `file_id|expires_at` with a secret. Accept only if not expired and the signature matches.
70+
71+
#### Generate a signed link
6672
6773
```sql
68-
-- Create a download token
69-
INSERT INTO download_tokens (file_id, token, expires_at)
70-
VALUES (
71-
:file_id,
72-
sqlpage.hmac(
73-
:file_id || ''|'' || datetime(''now'', ''+1 hour''),
74-
sqlpage.environment_variable(''DOWNLOAD_SECRET''),
75-
''sha256''
76-
),
77-
datetime(''now'', ''+1 hour'')
74+
SET expires_at = datetime(''now'', ''+1 hour'');
75+
SET token = sqlpage.hmac(
76+
$file_id || ''|'' || $expires_at,
77+
sqlpage.environment_variable(''DOWNLOAD_SECRET''),
78+
''sha256''
7879
);
80+
SELECT ''/download.sql?file_id='' || $file_id || ''&expires_at='' || $expires_at || ''&token='' || $token AS link;
7981
```
8082
81-
### Example 3: Sign API Requests
82-
83-
Prove your API request is authentic:
83+
#### Verify the signed link
8484
8585
```sql
86-
-- Create a signature for your API call
87-
SELECT sqlpage.hmac(
88-
''user_id=123&action=update&timestamp='' || strftime(''%s'', ''now''),
89-
''my-secret-api-key'',
86+
SET expected = sqlpage.hmac(
87+
$file_id || ''|'' || $expires_at,
88+
sqlpage.environment_variable(''DOWNLOAD_SECRET''),
9089
''sha256''
91-
) as api_signature;
90+
);
91+
SELECT ''redirect'' AS component, ''/error.sql?err=expired'' AS link
92+
WHERE $expected IS DISTINCT FROM $token OR $expires_at < datetime(''now'');
93+
94+
-- serve the file
9295
```
9396
94-
### Important Security Tips
97+
### Important Security Notes
9598
96-
- **Keep your secret key safe**: Store it in environment variables using `sqlpage.environment_variable()`, never hardcode it in your SQL files
97-
- **Use strong keys**: Your secret should be long and random (at least 32 characters)
98-
- **The signature is case-sensitive**: Even one wrong letter means the signature won''t match
99-
- **Algorithms**: Use `sha256` for most cases (it''s the default), or `sha512` for extra security
100-
- **Output formats**: Use `hex` (default) for most cases, or `base64` when the service expects base64 (like Shopify)
101-
- **NULL handling**: If your data or key is NULL, the function returns NULL
99+
- **Keep your secret key safe**: If your secret leaks, anyone can forge signatures and access protected pages
100+
- **The signature is case-sensitive**: Even a single wrong letter means the signature won''t match
101+
- **NULL handling**: Always use `IS DISTINCT FROM`, not `=` to check for hmac matches. In SQL `SELECT ''redirect'' as component WHERE sqlpage.hmac(...) != $signature` will not redirect if `$signature` is NULL (the signature is absent). Use `SELECT ''redirect'' as component WHERE sqlpage.hmac(...) IS DISTINCT FROM $signature` instead.
102102
'
103103
);
104104

105-
INSERT INTO sqlpage_function_parameters (
105+
INSERT INTO
106+
sqlpage_function_parameters (
106107
"function",
107108
"index",
108109
"name",
109110
"description_md",
110111
"type"
111112
)
112-
VALUES (
113+
VALUES
114+
(
113115
'hmac',
114116
1,
115117
'data',
116-
'The input data to compute the HMAC for. Can be any text string.',
118+
'The input data to compute the HMAC for. Can be any text string. Cannot be NULL.',
117119
'TEXT'
118120
),
119121
(
120122
'hmac',
121123
2,
122124
'key',
123-
'The secret key used to compute the HMAC. Should be kept confidential.',
125+
'The secret key used to compute the HMAC. Should be kept confidential. Cannot be NULL.',
124126
'TEXT'
125127
),
126128
(
127129
'hmac',
128130
3,
129131
'algorithm',
130-
'The hash algorithm and output format. Optional, defaults to `sha256` (hex output). Supported values: `sha256`, `sha256-base64`, `sha512`, `sha512-base64`.',
132+
'The hash algorithm and output format. Optional, defaults to `sha256` (hex output). Supported values: `sha256`, `sha256-base64`, `sha512`, `sha512-base64`. Defaults to `sha256`.',
131133
'TEXT'
132134
);

src/webserver/database/sqlpage_functions/functions.rs

Lines changed: 5 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ super::function_definition_macro::sqlpage_functions! {
3232
hash_password(password: Option<String>);
3333
header((&RequestInfo), name: Cow<str>);
3434
headers((&RequestInfo));
35-
hmac(data: Option<Cow<str>>, key: Option<Cow<str>>, algorithm: Option<Cow<str>>);
35+
hmac(data: Cow<str>, key: Cow<str>, algorithm: Option<Cow<str>>);
3636

3737
user_info_token((&RequestInfo));
3838
link(file: Cow<str>, parameters: Option<Cow<str>>, hash: Option<Cow<str>>);
@@ -742,20 +742,13 @@ async fn headers(request: &RequestInfo) -> String {
742742
/// Computes the HMAC (Hash-based Message Authentication Code) of the input data
743743
/// using the specified key and hashing algorithm.
744744
async fn hmac<'a>(
745-
data: Option<Cow<'a, str>>,
746-
key: Option<Cow<'a, str>>,
745+
data: Cow<'a, str>,
746+
key: Cow<'a, str>,
747747
algorithm: Option<Cow<'a, str>>,
748748
) -> anyhow::Result<Option<String>> {
749749
use hmac::{Hmac, Mac};
750750
use sha2::{Sha256, Sha512};
751751

752-
let Some(data) = data else {
753-
return Ok(None);
754-
};
755-
let Some(key) = key else {
756-
return Ok(None);
757-
};
758-
759752
let algorithm = algorithm.as_deref().unwrap_or("sha256");
760753

761754
// Parse algorithm and output format (e.g., "sha256" or "sha256-base64")
@@ -810,8 +803,8 @@ async fn client_ip(request: &RequestInfo) -> Option<String> {
810803
async fn test_hmac() {
811804
// Test vector from RFC 4231 - HMAC-SHA256
812805
let result = hmac(
813-
Some(Cow::Borrowed("The quick brown fox jumps over the lazy dog")),
814-
Some(Cow::Borrowed("key")),
806+
Cow::Borrowed("The quick brown fox jumps over the lazy dog"),
807+
Cow::Borrowed("key"),
815808
Some(Cow::Borrowed("sha256")),
816809
)
817810
.await

tests/requests/webhook_hmac.rs

Lines changed: 16 additions & 63 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ async fn test_webhook_hmac_invalid_signature() -> actix_web::Result<()> {
99
std::env::set_var("WEBHOOK_SECRET", "test-secret-key");
1010

1111
let webhook_body = r#"{"order_id":12345,"total":"99.99"}"#;
12-
let invalid_signature = "invalid_signature_base64==";
12+
let invalid_signature = "96a5f6f65c85a2d4d1f3a37813ab2c0b44041bdc17691fbb0884e3eb52b7c54b";
1313

1414
let req = get_request_to("/tests/webhook_hmac_validation.sql")
1515
.await?
@@ -30,20 +30,10 @@ async fn test_webhook_hmac_invalid_signature() -> actix_web::Result<()> {
3030
let location = resp
3131
.headers()
3232
.get("location")
33-
.expect("Should have Location header");
34-
let location_str = location.to_str().unwrap();
35-
assert!(
36-
location_str.contains("/error.sql"),
37-
"Should redirect to error page, got: {}",
38-
location_str
39-
);
40-
assert!(
41-
location_str.contains("Invalid+webhook+signature")
42-
|| location_str.contains("Invalid%20webhook%20signature"),
43-
"Error message should mention invalid signature, got: {}",
44-
location_str
45-
);
46-
33+
.expect("Should have Location header")
34+
.to_str()
35+
.unwrap();
36+
assert_eq!(location, "/error.sql?err=bad_webhook_signature");
4737
Ok(())
4838
}
4939

@@ -53,52 +43,25 @@ async fn test_webhook_hmac_valid_signature() -> actix_web::Result<()> {
5343
std::env::set_var("WEBHOOK_SECRET", "test-secret-key");
5444

5545
let webhook_body = r#"{"order_id":12345,"total":"99.99"}"#;
56-
57-
// Calculate the correct HMAC signature using the same algorithm
58-
use hmac::{Hmac, Mac};
59-
use sha2::Sha256;
60-
let mut mac = Hmac::<Sha256>::new_from_slice(b"test-secret-key").unwrap();
61-
mac.update(webhook_body.as_bytes());
62-
let result = mac.finalize();
63-
let valid_signature =
64-
base64::Engine::encode(&base64::engine::general_purpose::STANDARD, result.into_bytes());
46+
let valid_signature = "260b3b5ead84843645588af82d5d2c3fe24c598a950d36c45438c3a5f5bb941c";
6547

6648
let req = get_request_to("/tests/webhook_hmac_validation.sql")
6749
.await?
6850
.insert_header(("content-type", "application/json"))
69-
.insert_header(("X-Webhook-Signature", valid_signature.as_str()))
51+
.insert_header(("X-Webhook-Signature", valid_signature))
7052
.set_payload(webhook_body)
7153
.to_srv_request();
7254

7355
let resp = main_handler(req).await?;
7456

7557
// Should return success when signature is valid
76-
assert_eq!(
77-
resp.status(),
78-
StatusCode::OK,
79-
"Expected OK status for valid signature"
80-
);
81-
82-
let body = test::read_body(resp).await;
83-
let body_str = String::from_utf8(body.to_vec()).unwrap();
58+
assert_eq!(resp.status(), StatusCode::OK, "200 resp for signed req");
59+
assert!(!resp.headers().contains_key("location"), "no redirect");
8460

85-
// Should contain success message
86-
assert!(
87-
body_str.contains("success") || body_str.contains("Success"),
88-
"Response should indicate success, got: {}",
89-
body_str
90-
);
91-
assert!(
92-
body_str.contains("Webhook signature verified"),
93-
"Response should confirm signature verification, got: {}",
94-
body_str
95-
);
96-
assert!(
97-
body_str.contains("order_id"),
98-
"Response should contain the webhook body, got: {}",
99-
body_str
61+
assert_eq!(
62+
test::read_body_json::<serde_json::Value, _>(resp).await,
63+
serde_json::json! ({"msg": "Webhook signature is valid !"})
10064
);
101-
10265
Ok(())
10366
}
10467

@@ -118,23 +81,13 @@ async fn test_webhook_hmac_missing_signature() -> actix_web::Result<()> {
11881

11982
let resp = main_handler(req).await?;
12083

121-
// Should redirect to error page when signature is missing
122-
assert!(
123-
resp.status() == StatusCode::FOUND || resp.status() == StatusCode::SEE_OTHER,
124-
"Expected redirect (302 or 303) when signature header is missing, got: {}",
125-
resp.status()
126-
);
127-
12884
let location = resp
12985
.headers()
13086
.get("location")
131-
.expect("Should have Location header");
132-
let location_str = location.to_str().unwrap();
133-
assert!(
134-
location_str.contains("/error.sql"),
135-
"Should redirect to error page, got: {}",
136-
location_str
137-
);
87+
.expect("Should have Location header")
88+
.to_str()
89+
.unwrap();
90+
assert_eq!(location, "/error.sql?err=bad_webhook_signature");
13891

13992
Ok(())
14093
}

tests/sql_test_files/it_works_hmac_null.sql

Lines changed: 0 additions & 1 deletion
This file was deleted.

tests/webhook_hmac_validation.sql

Lines changed: 12 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,17 @@
11
-- Webhook HMAC signature validation example
22
-- This simulates receiving a webhook with HMAC signature in header
3-
4-
-- Redirect to error page if signature is missing
5-
SELECT 'redirect' as component,
6-
'/error.sql?message=' || sqlpage.url_encode('Missing webhook signature') as link
7-
WHERE sqlpage.header('X-Webhook-Signature') IS NULL;
8-
93
-- Redirect to error page if signature is invalid
10-
SELECT 'redirect' as component,
11-
'/error.sql?message=' || sqlpage.url_encode('Invalid webhook signature') as link
12-
WHERE sqlpage.hmac(
13-
sqlpage.request_body(),
14-
sqlpage.environment_variable('WEBHOOK_SECRET'),
15-
'sha256-base64'
16-
) != sqlpage.header('X-Webhook-Signature');
4+
-- 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
5+
SET body = sqlpage.request_body();
6+
SET secret = sqlpage.environment_variable('WEBHOOK_SECRET');
7+
SET expected_signature = sqlpage.hmac($body, $secret, 'sha256');
8+
SET actual_signature = sqlpage.header('X-Webhook-Signature');
9+
10+
SELECT
11+
'redirect' as component,
12+
'/error.sql?err=bad_webhook_signature' as link
13+
WHERE $actual_signature IS DISTINCT FROM $expected_signature;
1714

1815
-- If we reach here, signature is valid - return success
19-
SELECT 'json' as component;
20-
SELECT json_object(
21-
'status', 'success',
22-
'message', 'Webhook signature verified',
23-
'body', sqlpage.request_body()
24-
) as contents;
16+
SELECT 'json' as component, 'jsonlines' as type;
17+
select 'Webhook signature is valid !' as msg;

0 commit comments

Comments
 (0)