Skip to content

Commit f6ce929

Browse files
Feature/hmac signed http check (#141)
* Add hmac signed http check * Add new http-check parameters for hmac signed requests * Add documentation for hmac signed http requests --------- Co-authored-by: Tarun Menon <tarunmenon95@gmail.com>
1 parent af780b6 commit f6ce929

File tree

4 files changed

+147
-4
lines changed

4 files changed

+147
-4
lines changed

docs/custom_checks/http.md

Lines changed: 131 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ Resources:
1313
StatusCode: 200
1414
# enables the SSL check
1515
Ssl: true
16-
# boolean tp request a compressed response
16+
# boolean to request a compressed response
1717
Compressed: true
1818
- Id: https://www.example.com
1919
StatusCode: 301
@@ -38,6 +38,55 @@ Resources:
3838
Payload: '{"name": "john"}'
3939
```
4040
41+
## HMAC signed requests
42+
43+
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.
44+
45+
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:
46+
47+
| Header (default prefix `X-Health`) | Description |
48+
|-----------------------------------|-------------|
49+
| `X-Health-Signature` | HMAC-SHA256 hex digest of the canonical string |
50+
| `X-Health-Key-Id` | Key identifier (e.g. `default`) |
51+
| `X-Health-Timestamp` | Unix epoch timestamp in seconds |
52+
| `X-Health-Nonce` | Random value (e.g. UUID hex) to prevent replay |
53+
54+
**Configuration (Public or Internal HTTP):**
55+
56+
| Key | Required | Description |
57+
|-----|----------|-------------|
58+
| `HmacSecretSsm` | Yes | SSM Parameter Store path to the HMAC secret (SecureString). The Guardian-generated IAM role grants the Lambda `ssm:GetParameter` for this path. |
59+
| `HmacKeyId` | No | Key id sent in the `Key-Id` header. Default: `default`. |
60+
| `HmacHeaderPrefix` | No | Prefix for all HMAC header names. Default: `X-Health` (yields `X-Health-Signature`, `X-Health-Key-Id`, etc.). |
61+
62+
**Example:**
63+
64+
```yaml
65+
Resources:
66+
Http:
67+
- Id: https://api.example.com/health
68+
StatusCode: 200
69+
HmacSecretSsm: /guardian/myapp/hmac-secret
70+
HmacKeyId: default
71+
HmacHeaderPrefix: X-Health
72+
```
73+
74+
Internal HTTP checks support the same keys under each host:
75+
76+
```yaml
77+
Resources:
78+
InternalHttp:
79+
- Environment: Prod
80+
VpcId: vpc-1234
81+
Subnets: [subnet-abcd]
82+
Hosts:
83+
- Id: http://api.example.com/health
84+
StatusCode: 200
85+
HmacSecretSsm: /guardian/myapp/hmac-secret
86+
```
87+
88+
Create the secret in SSM (e.g. SecureString) and use the same value in your application when verifying the signature.
89+
4190
## Private HTTP Check
4291

4392
Cloudwatch NameSpace: `InternalHttpCheck`
@@ -58,4 +107,84 @@ Resources:
58107
# Array of resources defining the http endpoint with the Id: key
59108
# All the same options as Http including ssl check on the internal endpoint
60109
- Id: http://api.example.com
61-
```
110+
```
111+
112+
## Supporting HMAC-signed health checks in your application
113+
114+
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.
115+
116+
**Canonical string (what gets signed):**
117+
118+
The Lambda signs this string (newline-separated, no trailing newline):
119+
120+
```
121+
METHOD\nPATH\nTIMESTAMP\nNONCE\nQUERY\nBODY_HASH
122+
```
123+
124+
- `METHOD` – HTTP method (e.g. `GET`).
125+
- `PATH` – URL path (e.g. `/health`), no query.
126+
- `TIMESTAMP` – Same value as the `{prefix}-Timestamp` header (Unix seconds).
127+
- `NONCE` – Same value as the `{prefix}-Nonce` header.
128+
- `QUERY` – Raw query string (e.g. `foo=bar` or empty).
129+
- `BODY_HASH` – SHA-256 hex digest of the raw request body (empty string for GET; for POST/PUT, hash the body as sent).
130+
131+
**Verification steps (pseudo code):**
132+
133+
1. Read headers (default prefix `X-Health`):
134+
`signature`, `key_id`, `timestamp`, `nonce`.
135+
2. **Optional – replay protection:**
136+
Reject if `timestamp` is too old (e.g. outside last 5 minutes).
137+
Reject if `nonce` has been seen before (e.g. cache or DB) and treat as replay.
138+
3. Look up the shared secret for `key_id` (e.g. from config or secrets store – same value as in SSM for Guardian).
139+
4. Build the canonical string from the incoming request:
140+
- Use request method, path, and query.
141+
- Use `timestamp` and `nonce` from the headers.
142+
- For body: compute SHA-256 hex of the raw request body (empty string for no body).
143+
5. Compute `expected = HMAC-SHA256(secret, canonical_string)` and compare to `signature` (constant-time).
144+
6. If equal, treat the request as authenticated.
145+
146+
**Pseudo code example:**
147+
148+
```python
149+
import hmac
150+
import hashlib
151+
152+
def verify_health_request(request, secret_by_key_id, header_prefix="X-Health", max_age_seconds=300):
153+
sig_h = f"{header_prefix}-Signature"
154+
key_h = f"{header_prefix}-Key-Id"
155+
ts_h = f"{header_prefix}-Timestamp"
156+
nonce_h = f"{header_prefix}-Nonce"
157+
158+
signature = request.headers.get(sig_h)
159+
key_id = request.headers.get(key_h)
160+
timestamp = request.headers.get(ts_h)
161+
nonce = request.headers.get(nonce_h)
162+
163+
if not all([signature, key_id, timestamp, nonce]):
164+
return False
165+
166+
# Replay: reject old timestamps
167+
if abs(int(timestamp) - time.time()) > max_age_seconds:
168+
return False
169+
# Replay: reject duplicate nonce (check your cache/DB)
170+
if nonce_already_used(nonce):
171+
return False
172+
173+
secret = secret_by_key_id.get(key_id)
174+
if not secret:
175+
return False
176+
177+
body_hash = hashlib.sha256(request.body or b"").hexdigest()
178+
canonical = "\n".join([
179+
request.method,
180+
request.path,
181+
timestamp,
182+
nonce,
183+
request.query_string or "",
184+
body_hash,
185+
])
186+
expected = hmac.new(secret.encode(), canonical.encode(), hashlib.sha256).hexdigest()
187+
return hmac.compare_digest(expected, signature)
188+
```
189+
190+
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.

lib/cfnguardian/models/check.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@ def initialize(resource)
4242
@name = 'HttpCheck'
4343
@package = 'http-check'
4444
@handler = 'handler.http_check'
45-
@version = '077c726ed691a1176caf95497b8b02f05f00e0cb'
45+
@version = '685c17ced2429954fccbf8b8b8f2132b37b3f4ff'
4646
@runtime = 'python3.11'
4747
end
4848
end

lib/cfnguardian/models/event.rb

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,10 @@ def initialize(resource)
5555
@user_agent = resource.fetch('UserAgent',nil)
5656
@payload = resource.fetch('Payload',nil)
5757
@compressed = resource.fetch('Compressed',false)
58+
@hmac_secret_ssm = resource.fetch('HmacSecretSsm',nil)
59+
@hmac_key_id = resource.fetch('HmacKeyId','default')
60+
@hmac_header_prefix = resource.fetch('HmacHeaderPrefix','X-Health')
61+
@report_response_body = resource.fetch('ReportResponseBody',false)
5862
end
5963

6064
def payload
@@ -69,8 +73,18 @@ def payload
6973
payload['USER_AGENT'] = @user_agent unless @user_agent.nil?
7074
payload['PAYLOAD'] = @payload unless @payload.nil?
7175
payload['COMPRESSED'] = '1' if @compressed
76+
payload['REPORT_RESPONSE_BODY'] = '1' if @report_response_body
77+
unless @hmac_secret_ssm.nil?
78+
payload['HMAC_SECRET_SSM'] = @hmac_secret_ssm
79+
payload['HMAC_KEY_ID'] = @hmac_key_id
80+
payload['HMAC_HEADER_PREFIX'] = @hmac_header_prefix
81+
end
7282
return payload.to_json
7383
end
84+
85+
def ssm_parameters
86+
@hmac_secret_ssm.nil? ? [] : [@hmac_secret_ssm]
87+
end
7488
end
7589

7690
class WebSocketEvent < BaseEvent

lib/cfnguardian/version.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
11
module CfnGuardian
2-
VERSION = "0.12.0"
2+
VERSION = "0.12.1"
33
CHANGE_SET_VERSION = VERSION.gsub('.', '-').freeze
44
end

0 commit comments

Comments
 (0)