-
Notifications
You must be signed in to change notification settings - Fork 10
Feature/hmac signed http check #141
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -13,7 +13,7 @@ Resources: | |
| StatusCode: 200 | ||
| # enables the SSL check | ||
| Ssl: true | ||
| # boolean tp request a compressed response | ||
| # boolean to request a compressed response | ||
| Compressed: true | ||
| - Id: https://www.example.com | ||
| StatusCode: 301 | ||
|
|
@@ -38,6 +38,55 @@ Resources: | |
| Payload: '{"name": "john"}' | ||
| ``` | ||
|
|
||
| ## HMAC signed requests | ||
|
|
||
| For health endpoints that require authenticated requests, Guardian can send HMAC-signed headers so your application can verify the request came from Guardian and resist replay attacks. | ||
|
|
||
| When enabled, the HTTP check Lambda (see [aws-lambda-http-check](https://github.com/base2/aws-lambda-http-check)) adds these headers to each request: | ||
|
|
||
| | Header (default prefix `X-Health`) | Description | | ||
| |-----------------------------------|-------------| | ||
| | `X-Health-Signature` | HMAC-SHA256 hex digest of the canonical string | | ||
| | `X-Health-Key-Id` | Key identifier (e.g. `default`) | | ||
| | `X-Health-Timestamp` | Unix epoch timestamp in seconds | | ||
| | `X-Health-Nonce` | Random value (e.g. UUID hex) to prevent replay | | ||
|
|
||
| **Configuration (Public or Internal HTTP):** | ||
|
|
||
| | Key | Required | Description | | ||
| |-----|----------|-------------| | ||
| | `HmacSecretSsm` | Yes | SSM Parameter Store path to the HMAC secret (SecureString). The Guardian-generated IAM role grants the Lambda `ssm:GetParameter` for this path. | | ||
| | `HmacKeyId` | No | Key id sent in the `Key-Id` header. Default: `default`. | | ||
| | `HmacHeaderPrefix` | No | Prefix for all HMAC header names. Default: `X-Health` (yields `X-Health-Signature`, `X-Health-Key-Id`, etc.). | | ||
|
|
||
| **Example:** | ||
|
|
||
| ```yaml | ||
| Resources: | ||
| Http: | ||
| - Id: https://api.example.com/health | ||
| StatusCode: 200 | ||
| HmacSecretSsm: /guardian/myapp/hmac-secret | ||
| HmacKeyId: default | ||
| HmacHeaderPrefix: X-Health | ||
| ``` | ||
|
|
||
| Internal HTTP checks support the same keys under each host: | ||
|
|
||
| ```yaml | ||
| Resources: | ||
| InternalHttp: | ||
| - Environment: Prod | ||
| VpcId: vpc-1234 | ||
| Subnets: [subnet-abcd] | ||
| Hosts: | ||
| - Id: http://api.example.com/health | ||
| StatusCode: 200 | ||
| HmacSecretSsm: /guardian/myapp/hmac-secret | ||
| ``` | ||
|
|
||
| Create the secret in SSM (e.g. SecureString) and use the same value in your application when verifying the signature. | ||
|
|
||
| ## Private HTTP Check | ||
|
|
||
| Cloudwatch NameSpace: `InternalHttpCheck` | ||
|
|
@@ -58,4 +107,84 @@ Resources: | |
| # Array of resources defining the http endpoint with the Id: key | ||
| # All the same options as Http including ssl check on the internal endpoint | ||
| - Id: http://api.example.com | ||
| ``` | ||
| ``` | ||
|
|
||
| ## Supporting HMAC-signed health checks in your application | ||
|
|
||
| If you want Guardian to call a health endpoint that only accepts HMAC-authenticated requests, your server must verify the same scheme the Lambda uses. | ||
|
|
||
| **Canonical string (what gets signed):** | ||
|
|
||
| The Lambda signs this string (newline-separated, no trailing newline): | ||
|
|
||
| ``` | ||
| METHOD\nPATH\nTIMESTAMP\nNONCE\nQUERY\nBODY_HASH | ||
| ``` | ||
|
|
||
| - `METHOD` – HTTP method (e.g. `GET`). | ||
| - `PATH` – URL path (e.g. `/health`), no query. | ||
| - `TIMESTAMP` – Same value as the `{prefix}-Timestamp` header (Unix seconds). | ||
| - `NONCE` – Same value as the `{prefix}-Nonce` header. | ||
| - `QUERY` – Raw query string (e.g. `foo=bar` or empty). | ||
| - `BODY_HASH` – SHA-256 hex digest of the raw request body (empty string for GET; for POST/PUT, hash the body as sent). | ||
|
|
||
|
Comment on lines
+128
to
+130
|
||
| **Verification steps (pseudo code):** | ||
|
|
||
| 1. Read headers (default prefix `X-Health`): | ||
| `signature`, `key_id`, `timestamp`, `nonce`. | ||
| 2. **Optional – replay protection:** | ||
| Reject if `timestamp` is too old (e.g. outside last 5 minutes). | ||
| Reject if `nonce` has been seen before (e.g. cache or DB) and treat as replay. | ||
| 3. Look up the shared secret for `key_id` (e.g. from config or secrets store – same value as in SSM for Guardian). | ||
| 4. Build the canonical string from the incoming request: | ||
| - Use request method, path, and query. | ||
| - Use `timestamp` and `nonce` from the headers. | ||
| - For body: compute SHA-256 hex of the raw request body (empty string for no body). | ||
| 5. Compute `expected = HMAC-SHA256(secret, canonical_string)` and compare to `signature` (constant-time). | ||
| 6. If equal, treat the request as authenticated. | ||
|
|
||
| **Pseudo code example:** | ||
|
|
||
| ```python | ||
| import hmac | ||
| import hashlib | ||
|
|
||
| def verify_health_request(request, secret_by_key_id, header_prefix="X-Health", max_age_seconds=300): | ||
| sig_h = f"{header_prefix}-Signature" | ||
| key_h = f"{header_prefix}-Key-Id" | ||
| ts_h = f"{header_prefix}-Timestamp" | ||
| nonce_h = f"{header_prefix}-Nonce" | ||
|
|
||
| signature = request.headers.get(sig_h) | ||
| key_id = request.headers.get(key_h) | ||
| timestamp = request.headers.get(ts_h) | ||
| nonce = request.headers.get(nonce_h) | ||
|
|
||
| if not all([signature, key_id, timestamp, nonce]): | ||
| return False | ||
|
|
||
| # Replay: reject old timestamps | ||
| if abs(int(timestamp) - time.time()) > max_age_seconds: | ||
| return False | ||
|
Comment on lines
+166
to
+168
|
||
| # Replay: reject duplicate nonce (check your cache/DB) | ||
| if nonce_already_used(nonce): | ||
| return False | ||
|
|
||
| secret = secret_by_key_id.get(key_id) | ||
| if not secret: | ||
| return False | ||
|
|
||
| body_hash = hashlib.sha256(request.body or b"").hexdigest() | ||
| canonical = "\n".join([ | ||
| request.method, | ||
| request.path, | ||
| timestamp, | ||
| nonce, | ||
| request.query_string or "", | ||
| body_hash, | ||
| ]) | ||
| expected = hmac.new(secret.encode(), canonical.encode(), hashlib.sha256).hexdigest() | ||
| return hmac.compare_digest(expected, signature) | ||
| ``` | ||
|
|
||
| Use the same HMAC secret in SSM (Guardian config) and in your app’s config or secrets store. Keep the secret in SecureString or equivalent and restrict access appropriately. | ||
| Original file line number | Diff line number | Diff line change | ||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -55,6 +55,10 @@ def initialize(resource) | |||||||||||||||||
| @user_agent = resource.fetch('UserAgent',nil) | ||||||||||||||||||
| @payload = resource.fetch('Payload',nil) | ||||||||||||||||||
| @compressed = resource.fetch('Compressed',false) | ||||||||||||||||||
| @hmac_secret_ssm = resource.fetch('HmacSecretSsm',nil) | ||||||||||||||||||
| @hmac_key_id = resource.fetch('HmacKeyId','default') | ||||||||||||||||||
| @hmac_header_prefix = resource.fetch('HmacHeaderPrefix','X-Health') | ||||||||||||||||||
|
Comment on lines
+59
to
+60
|
||||||||||||||||||
| @hmac_key_id = resource.fetch('HmacKeyId','default') | |
| @hmac_header_prefix = resource.fetch('HmacHeaderPrefix','X-Health') | |
| raw_hmac_key_id = resource.fetch('HmacKeyId', nil) | |
| @hmac_key_id = raw_hmac_key_id.to_s.strip | |
| @hmac_key_id = 'default' if @hmac_key_id.empty? | |
| raw_hmac_header_prefix = resource.fetch('HmacHeaderPrefix', nil) | |
| @hmac_header_prefix = raw_hmac_header_prefix.to_s.strip | |
| @hmac_header_prefix = 'X-Health' if @hmac_header_prefix.empty? |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,4 +1,4 @@ | ||
| module CfnGuardian | ||
| VERSION = "0.12.0" | ||
| VERSION = "0.12.1" | ||
| CHANGE_SET_VERSION = VERSION.gsub('.', '-').freeze | ||
| end |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
ReportResponseBodyis supported byHttpEvent(see event model changes) but isn’t documented here alongside the other HTTP options. Please add it to this configuration table and clarify what data is emitted/recorded when enabled so users can assess sensitivity.