Skip to content

Commit 2de7558

Browse files
authored
Merge pull request #52 from github/copilot/fix-51
Enhance HMAC auth plugin to support structured signature headers (Tailscale-style)
2 parents 80d59a7 + bf47ead commit 2de7558

File tree

13 files changed

+551
-44
lines changed

13 files changed

+551
-44
lines changed

.rubocop.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ AllCops:
1515
GitHub/InsecureHashAlgorithm:
1616
Exclude:
1717
- "spec/unit/lib/hooks/plugins/auth/hmac_spec.rb"
18+
- "spec/acceptance/acceptance_tests.rb"
1819

1920
GitHub/AvoidObjectSendWithDynamicMethod:
2021
Exclude:

Gemfile.lock

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
PATH
22
remote: .
33
specs:
4-
hooks-ruby (0.2.0)
4+
hooks-ruby (0.2.1)
55
dry-schema (~> 1.14, >= 1.14.1)
66
grape (~> 2.3)
77
puma (~> 6.6)

docs/auth_plugins.md

Lines changed: 96 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -82,7 +82,49 @@ The maximum age (in seconds) allowed for timestamped requests. Only used when `t
8282

8383
A template for constructing the payload used in signature generation when timestamp validation is enabled. Use placeholders like `{version}`, `{timestamp}`, and `{body}`.
8484

85-
**Example:** `{version}:{timestamp}:{body}`
85+
**Example:** `{version}:{timestamp}:{body}` (Slack-style), `{timestamp}.{body}` (Tailscale-style)
86+
87+
##### `header_format` (optional)
88+
89+
The format of the signature header content. Use "structured" for headers containing comma-separated key-value pairs.
90+
91+
**Default:** `simple`
92+
**Valid values:**
93+
94+
- `simple` - Standard single-value headers like "sha256=abc123..." or "abc123..."
95+
- `structured` - Comma-separated key-value pairs like "t=1663781880,v1=abc123..."
96+
97+
##### `signature_key` (optional)
98+
99+
When `header_format` is "structured", this specifies the key name for the signature value in the header.
100+
101+
**Default:** `v1`
102+
**Example:** `signature`
103+
104+
##### `timestamp_key` (optional)
105+
106+
When `header_format` is "structured", this specifies the key name for the timestamp value in the header.
107+
108+
**Default:** `t`
109+
**Example:** `timestamp`
110+
111+
##### `structured_header_separator` (optional)
112+
113+
When `header_format` is "structured", this specifies the separator used between the unique keys in the structured header.
114+
115+
For example, if the header is `t=1663781880,v1=abc123`, the `structured_header_separator` would be `,`. It defaults to `,` but can be changed if needed.
116+
117+
**Example:** `.`
118+
**Default:** `,`
119+
120+
##### `key_value_separator` (optional)
121+
122+
When `header_format` is "structured", this specifies the separator used between the key and value in the structured header.
123+
124+
For example, in the header `t=1663781880,v1=abc123`, the `key_value_separator` would be `=`. It defaults to `=` but can be changed if needed.
125+
126+
**Example:** `:`
127+
**Default:** `=`
86128

87129
#### HMAC Examples
88130

@@ -218,6 +260,59 @@ curl -X POST "$WEBHOOK_URL" \
218260

219261
This approach provides strong security through timestamp validation while using a simpler format than the Slack-style implementation. The signing payload becomes `1609459200:{"event":"deployment","status":"success"}` and the resulting signature format is `sha256=computed_hmac_hash`.
220262

263+
**Tailscale-style HMAC with structured headers:**
264+
265+
This configuration supports providers like Tailscale that include both timestamp and signature in a single header using comma-separated key-value pairs.
266+
267+
```yaml
268+
auth:
269+
type: hmac
270+
secret_env_key: TAILSCALE_WEBHOOK_SECRET
271+
header: Tailscale-Webhook-Signature
272+
algorithm: sha256
273+
format: "signature_only" # produces "abc123..." (no prefix)
274+
header_format: "structured" # enables parsing of "t=123,v1=abc" format
275+
signature_key: "v1" # key for signature in structured header
276+
timestamp_key: "t" # key for timestamp in structured header
277+
payload_template: "{timestamp}.{body}" # dot-separated format
278+
timestamp_tolerance: 300 # 5 minutes
279+
```
280+
281+
**How it works:**
282+
283+
1. The signature header contains both timestamp and signature: `Tailscale-Webhook-Signature: t=1663781880,v1=0123456789abcdef`
284+
2. The timestamp and signature are extracted from the structured header
285+
3. The HMAC is calculated over the payload using the template: `{timestamp}.{body}`
286+
4. For example, if timestamp is "1663781880" and body is `{"event":"test"}`, the signed payload becomes: `1663781880.{"event":"test"}`
287+
5. The signature is validated as a raw hex string (no prefix)
288+
289+
**Example curl request:**
290+
291+
```bash
292+
#!/bin/bash
293+
294+
# Configuration
295+
WEBHOOK_URL="https://your-hooks-server.com/webhooks/tailscale"
296+
SECRET="your_tailscale_webhook_secret"
297+
TIMESTAMP=$(date +%s)
298+
PAYLOAD='{"nodeId":"n123","event":"nodeCreated"}'
299+
300+
# Construct the signing payload (timestamp.body format)
301+
SIGNING_PAYLOAD="${TIMESTAMP}.${PAYLOAD}"
302+
303+
# Generate HMAC signature
304+
SIGNATURE=$(echo -n "$SIGNING_PAYLOAD" | openssl dgst -sha256 -hmac "$SECRET" -hex | cut -d' ' -f2)
305+
STRUCTURED_SIGNATURE="t=${TIMESTAMP},v1=${SIGNATURE}"
306+
307+
# Send the request
308+
curl -X POST "$WEBHOOK_URL" \
309+
-H "Content-Type: application/json" \
310+
-H "Tailscale-Webhook-Signature: $STRUCTURED_SIGNATURE" \
311+
-d "$PAYLOAD"
312+
```
313+
314+
This format is particularly useful for providers that want to include multiple pieces of metadata in a single header while maintaining strong security through timestamp validation.
315+
221316
### Shared Secret Authentication
222317

223318
The SharedSecret plugin provides simple secret-based authentication by comparing a secret value sent in an HTTP header. While simpler than HMAC, it provides less security since the secret is transmitted directly in the request header.

docs/design.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -100,7 +100,7 @@ Core configuration options can be provided via environment variables:
100100
export HOOKS_CONFIG=./config/config.yaml
101101

102102
# Runtime settings (override config file)
103-
export HOOKS_REQUEST_LIMIT=1048576
103+
export HOOKS_REQUEST_LIMIT=1048576 # 1 MB
104104
export HOOKS_REQUEST_TIMEOUT=15
105105
export HOOKS_GRACEFUL_SHUTDOWN_TIMEOUT=30
106106
export HOOKS_ROOT_PATH="/webhooks"

lib/hooks/core/config_validator.rb

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,11 @@ class ValidationError < StandardError; end
4545
optional(:format).filled(:string)
4646
optional(:version_prefix).filled(:string)
4747
optional(:payload_template).filled(:string)
48+
optional(:header_format).filled(:string)
49+
optional(:signature_key).filled(:string)
50+
optional(:timestamp_key).filled(:string)
51+
optional(:structured_header_separator).filled(:string)
52+
optional(:key_value_separator).filled(:string)
4853
end
4954

5055
optional(:opts).hash

lib/hooks/plugins/auth/base.rb

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,10 @@ module Auth
1414
class Base
1515
extend Hooks::Core::ComponentAccess
1616

17+
# Security constants shared across auth validators
18+
MAX_HEADER_VALUE_LENGTH = ENV.fetch("HOOKS_MAX_HEADER_VALUE_LENGTH", 1024).to_i # Prevent DoS attacks via large header values
19+
MAX_PAYLOAD_SIZE = ENV.fetch("HOOKS_MAX_PAYLOAD_SIZE", 10 * 1024 * 1024).to_i # 10MB limit for payload validation
20+
1721
# Validate request
1822
#
1923
# @param payload [String] Raw request body
@@ -67,6 +71,61 @@ def self.find_header_value(headers, header_name)
6771
end
6872
nil
6973
end
74+
75+
# Validate headers object for security issues
76+
#
77+
# @param headers [Object] Headers to validate
78+
# @return [Boolean] true if headers are valid
79+
def self.valid_headers?(headers)
80+
unless headers.respond_to?(:each)
81+
log.warn("Auth validation failed: Invalid headers object")
82+
return false
83+
end
84+
true
85+
end
86+
87+
# Validate payload size for security issues
88+
#
89+
# @param payload [String] Payload to validate
90+
# @return [Boolean] true if payload is valid
91+
def self.valid_payload_size?(payload)
92+
return true if payload.nil?
93+
94+
if payload.bytesize > MAX_PAYLOAD_SIZE
95+
log.warn("Auth validation failed: Payload size exceeds maximum limit of #{MAX_PAYLOAD_SIZE} bytes")
96+
return false
97+
end
98+
true
99+
end
100+
101+
# Validate header value for security issues
102+
#
103+
# @param header_value [String] Header value to validate
104+
# @param header_name [String] Header name for logging
105+
# @return [Boolean] true if header value is valid
106+
def self.valid_header_value?(header_value, header_name)
107+
return false if header_value.nil? || header_value.empty?
108+
109+
# Check length to prevent DoS
110+
if header_value.length > MAX_HEADER_VALUE_LENGTH
111+
log.warn("Auth validation failed: #{header_name} exceeds maximum length")
112+
return false
113+
end
114+
115+
# Check for whitespace tampering
116+
if header_value != header_value.strip
117+
log.warn("Auth validation failed: #{header_name} contains leading/trailing whitespace")
118+
return false
119+
end
120+
121+
# Check for control characters
122+
if header_value.match?(/[\u0000-\u001f\u007f-\u009f]/)
123+
log.warn("Auth validation failed: #{header_name} contains control characters")
124+
return false
125+
end
126+
127+
true
128+
end
70129
end
71130
end
72131
end

0 commit comments

Comments
 (0)