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
133 changes: 131 additions & 2 deletions docs/custom_checks/http.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.). |

Comment on lines +60 to +61
Copy link

Copilot AI Mar 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ReportResponseBody is supported by HttpEvent (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.

Suggested change
| `HmacHeaderPrefix` | No | Prefix for all HMAC header names. Default: `X-Health` (yields `X-Health-Signature`, `X-Health-Key-Id`, etc.). |
| `HmacHeaderPrefix` | No | Prefix for all HMAC header names. Default: `X-Health` (yields `X-Health-Signature`, `X-Health-Key-Id`, etc.). |
| `ReportResponseBody` | No | When `true`, the HTTP check includes the raw HTTP response body in the emitted `HttpEvent` for the request. This body content is stored with other Guardian events/logs and may contain sensitive data (such as application data, PII, or secrets), so enable only for endpoints whose responses are safe to record. |
**Security note:** `ReportResponseBody` causes the checker to record the raw HTTP response body as part of each `HttpEvent`. This may include any data your endpoint returns, including personal data or credentials, so use this option only on endpoints whose responses are explicitly designed to be non-sensitive.

Copilot uses AI. Check for mistakes.
**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`
Expand All @@ -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
Copy link

Copilot AI Mar 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The BODY_HASH description says it is an empty string for GET, but the pseudo code later computes a SHA-256 hex digest even when the body is empty. This inconsistency could cause implementers to compute a different signature; please align the text with the actual algorithm (e.g., always hash the raw body, using an empty body for requests without one).

Copilot uses AI. Check for mistakes.
**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
Copy link

Copilot AI Mar 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The Python verification example uses time.time() but doesn’t import time, so the snippet won’t run as written. Add the missing import (or adjust the example to avoid referencing time).

Copilot uses AI. Check for mistakes.
# 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.
2 changes: 1 addition & 1 deletion lib/cfnguardian/models/check.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
14 changes: 14 additions & 0 deletions lib/cfnguardian/models/event.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Copy link

Copilot AI Mar 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

resource.fetch('HmacKeyId', 'default') / fetch('HmacHeaderPrefix', ...) won’t apply the default if the YAML key is present but set to null (it will propagate nil into the payload as JSON null). To match the documented defaults and avoid generating invalid/empty header names, coerce nil/blank values back to the defaults (and consider validating HmacHeaderPrefix is non-empty when HMAC is enabled).

Suggested change
@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?

Copilot uses AI. Check for mistakes.
@report_response_body = resource.fetch('ReportResponseBody',false)
end

def payload
Expand All @@ -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
Expand Down
2 changes: 1 addition & 1 deletion lib/cfnguardian/version.rb
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