Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,6 @@ jobs:
env:
DATABASE_URL: ${{ matrix.db_url }}
RUST_BACKTRACE: 1
RUST_LOG: sqlpage=debug

windows_test:
runs-on: windows-latest
Expand Down
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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.
Expand Down
2 changes: 2 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"] }
Expand Down
137 changes: 137 additions & 0 deletions examples/official-site/sqlpage/migrations/67_hmac_function.sql
Original file line number Diff line number Diff line change
@@ -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'
);
74 changes: 74 additions & 0 deletions src/webserver/database/sqlpage_functions/functions.rs
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ super::function_definition_macro::sqlpage_functions! {
hash_password(password: Option<String>);
header((&RequestInfo), name: Cow<str>);
headers((&RequestInfo));
hmac(data: Cow<str>, key: Cow<str>, algorithm: Option<Cow<str>>);

user_info_token((&RequestInfo));
link(file: Cow<str>, parameters: Option<Cow<str>>, hash: Option<Cow<str>>);
Expand Down Expand Up @@ -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<Cow<'a, str>>,
) -> anyhow::Result<Option<String>> {
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::<Sha256>::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::<Sha512>::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<String> {
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<Option<String>> {
let Some(claims) = &request.oidc_claims else {
Expand Down
2 changes: 2 additions & 0 deletions tests/requests/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -119,3 +119,5 @@ async fn test_large_form_field_roundtrip() -> actix_web::Result<()> {
);
Ok(())
}

mod webhook_hmac;
93 changes: 93 additions & 0 deletions tests/requests/webhook_hmac.rs
Original file line number Diff line number Diff line change
@@ -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::<serde_json::Value, _>(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(())
}
6 changes: 6 additions & 0 deletions tests/sql_test_files/it_works_hmac_base64.sql
Original file line number Diff line number Diff line change
@@ -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;
5 changes: 5 additions & 0 deletions tests/sql_test_files/it_works_hmac_default.sql
Original file line number Diff line number Diff line change
@@ -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;
Loading