diff --git a/docs/custom_checks/http.md b/docs/custom_checks/http.md index 174f6fc..8c3c80e 100644 --- a/docs/custom_checks/http.md +++ b/docs/custom_checks/http.md @@ -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 -``` \ No newline at end of file +``` + +## 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). + +**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 + # 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. \ No newline at end of file diff --git a/lib/cfnguardian/models/check.rb b/lib/cfnguardian/models/check.rb index 392b7c7..41616a9 100644 --- a/lib/cfnguardian/models/check.rb +++ b/lib/cfnguardian/models/check.rb @@ -42,7 +42,7 @@ def initialize(resource) @name = 'HttpCheck' @package = 'http-check' @handler = 'handler.http_check' - @version = '077c726ed691a1176caf95497b8b02f05f00e0cb' + @version = '685c17ced2429954fccbf8b8b8f2132b37b3f4ff' @runtime = 'python3.11' end end diff --git a/lib/cfnguardian/models/event.rb b/lib/cfnguardian/models/event.rb index 19d3f62..79d269c 100644 --- a/lib/cfnguardian/models/event.rb +++ b/lib/cfnguardian/models/event.rb @@ -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') + @report_response_body = resource.fetch('ReportResponseBody',false) end def payload @@ -69,8 +73,18 @@ def payload payload['USER_AGENT'] = @user_agent unless @user_agent.nil? payload['PAYLOAD'] = @payload unless @payload.nil? payload['COMPRESSED'] = '1' if @compressed + payload['REPORT_RESPONSE_BODY'] = '1' if @report_response_body + unless @hmac_secret_ssm.nil? + payload['HMAC_SECRET_SSM'] = @hmac_secret_ssm + payload['HMAC_KEY_ID'] = @hmac_key_id + payload['HMAC_HEADER_PREFIX'] = @hmac_header_prefix + end return payload.to_json end + + def ssm_parameters + @hmac_secret_ssm.nil? ? [] : [@hmac_secret_ssm] + end end class WebSocketEvent < BaseEvent diff --git a/lib/cfnguardian/version.rb b/lib/cfnguardian/version.rb index a67a3c3..68a9580 100644 --- a/lib/cfnguardian/version.rb +++ b/lib/cfnguardian/version.rb @@ -1,4 +1,4 @@ module CfnGuardian - VERSION = "0.12.0" + VERSION = "0.12.1" CHANGE_SET_VERSION = VERSION.gsub('.', '-').freeze end