Skip to content

Commit eaaf3a5

Browse files
CopilotGrantBirki
andcommitted
Complete JSON-first implementation with updated tests and documentation
Co-authored-by: GrantBirki <[email protected]>
1 parent 2d67523 commit eaaf3a5

File tree

4 files changed

+60
-30
lines changed

4 files changed

+60
-30
lines changed

docs/configuration.md

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -103,7 +103,23 @@ When set to `true`, enables a catch-all route that will handle requests to unkno
103103

104104
When set to `true`, normalizes incoming HTTP headers by lowercasing and trimming them. This ensures consistency in header names and values.
105105

106-
**Default:** `true`
106+
**Default:** `true`
107+
108+
### `format`
109+
110+
Sets the request/response format for the webhook server. This determines how incoming requests are parsed and how responses are formatted.
111+
112+
**Default:** `json`
113+
**Valid values:** `json`, `txt`, `xml`, `any`
114+
**Example:** `json`
115+
116+
### `default_format`
117+
118+
Sets the default response format when no specific format is requested. This works in conjunction with the `format` setting to determine response formatting.
119+
120+
**Default:** `json`
121+
**Valid values:** `json`, `txt`, `xml`, `any`
122+
**Example:** `json`
107123

108124
## Endpoint Options
109125

lib/hooks/app/api.rb

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -123,12 +123,11 @@ def self.create(config:, endpoints:, log:)
123123
# Handler called error! method - immediately return error response and exit the request
124124
log.debug("handler #{handler_class_name} called `error!` method")
125125

126-
error_response = nil
127-
128126
status e.status
129127
case e.body
130128
when String
131-
content_type "text/plain"
129+
# Even string errors are now JSON-encoded with the default JSON format
130+
content_type "application/json"
132131
error_response = e.body
133132
else
134133
# Let Grape handle JSON conversion with the default format

lib/hooks/app/endpoints/health.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ module App
88
class HealthEndpoint < Grape::API
99
# Set up content types and default format to JSON
1010
content_type :json, "application/json"
11-
content_type :txt, "text/plain"
11+
content_type :txt, "text/plain"
1212
content_type :xml, "application/xml"
1313
content_type :any, "*/*"
1414
format :json

spec/acceptance/acceptance_tests.rb

Lines changed: 40 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,18 @@ def expect_response(response, expected_type, expected_body_content = nil)
2828
expect(response.body).to include(expected_body_content) if expected_body_content
2929
end
3030

31+
def expect_json_auth_failure(response, expected_request_id: nil)
32+
expect(response).to be_a(Net::HTTPUnauthorized)
33+
expect(response.content_type).to eq("application/json")
34+
35+
body = parse_json_response(response)
36+
expect(body["error"]).to eq("authentication_failed")
37+
expect(body["message"]).to eq("authentication failed")
38+
expect(body).to have_key("request_id")
39+
expect(body["request_id"]).to be_a(String)
40+
expect(body["request_id"]).to eq(expected_request_id) if expected_request_id
41+
end
42+
3143
def parse_json_response(response)
3244
JSON.parse(response.body)
3345
end
@@ -140,13 +152,13 @@ def expired_unix_timestamp(seconds_ago = 600)
140152
headers = json_headers("X-Hub-Signature-256" => "sha256=invalidsignature")
141153
response = make_request(:post, "/webhooks/github", payload.to_json, headers)
142154

143-
expect_response(response, Net::HTTPUnauthorized, "authentication failed")
155+
expect_json_auth_failure(response)
144156
end
145157

146158
it "receives a POST request but there is no HMAC related header" do
147159
payload = { action: "push", repository: { name: "test-repo" } }
148160
response = make_request(:post, "/webhooks/github", payload.to_json, json_headers)
149-
expect_response(response, Net::HTTPUnauthorized, "authentication failed")
161+
expect_json_auth_failure(response)
150162
end
151163

152164
it "receives a POST request but it uses the wrong algo" do
@@ -155,7 +167,7 @@ def expired_unix_timestamp(seconds_ago = 600)
155167
signature = generate_hmac_signature(json_payload, FAKE_HMAC_SECRET, "sha512", "sha512=")
156168
headers = json_headers("X-Hub-Signature-256" => signature)
157169
response = make_request(:post, "/webhooks/github", json_payload, headers)
158-
expect_response(response, Net::HTTPUnauthorized, "authentication failed")
170+
expect_json_auth_failure(response)
159171
end
160172

161173
it "successfully processes a valid POST request with HMAC signature" do
@@ -212,7 +224,7 @@ def expired_unix_timestamp(seconds_ago = 600)
212224
signature = generate_hmac_with_timestamp(json_payload, "bad-hmac-secret", timestamp)
213225
headers = json_headers("X-HMAC-Signature" => signature, "X-HMAC-Timestamp" => timestamp)
214226
response = make_request(:post, "/webhooks/hmac_with_timestamp", json_payload, headers)
215-
expect_response(response, Net::HTTPUnauthorized, "authentication failed")
227+
expect_json_auth_failure(response)
216228
end
217229

218230
it "fails due to missing timestamp header" do
@@ -221,7 +233,7 @@ def expired_unix_timestamp(seconds_ago = 600)
221233
signature = generate_hmac_with_timestamp(json_payload, FAKE_ALT_HMAC_SECRET, current_timestamp)
222234
headers = json_headers("X-HMAC-Signature" => signature)
223235
response = make_request(:post, "/webhooks/hmac_with_timestamp", json_payload, headers)
224-
expect_response(response, Net::HTTPUnauthorized, "authentication failed")
236+
expect_json_auth_failure(response)
225237
end
226238

227239
it "fails due to invalid timestamp format" do
@@ -231,7 +243,7 @@ def expired_unix_timestamp(seconds_ago = 600)
231243
signature = generate_hmac_with_timestamp(json_payload, FAKE_ALT_HMAC_SECRET, invalid_timestamp)
232244
headers = json_headers("X-HMAC-Signature" => signature, "X-HMAC-Timestamp" => invalid_timestamp)
233245
response = make_request(:post, "/webhooks/hmac_with_timestamp", json_payload, headers)
234-
expect_response(response, Net::HTTPUnauthorized, "authentication failed")
246+
expect_json_auth_failure(response)
235247
end
236248

237249
it "rejects request with timestamp manipulation attack" do
@@ -244,7 +256,7 @@ def expired_unix_timestamp(seconds_ago = 600)
244256
signature = generate_hmac_with_timestamp(json_payload, FAKE_ALT_HMAC_SECRET, original_timestamp)
245257
headers = json_headers("X-HMAC-Signature" => signature, "X-HMAC-Timestamp" => manipulated_timestamp)
246258
response = make_request(:post, "/webhooks/hmac_with_timestamp", json_payload, headers)
247-
expect_response(response, Net::HTTPUnauthorized, "authentication failed")
259+
expect_json_auth_failure(response)
248260
end
249261

250262
it "fails because the timestamp is too old" do
@@ -254,7 +266,7 @@ def expired_unix_timestamp(seconds_ago = 600)
254266
signature = generate_hmac_with_timestamp(json_payload, FAKE_ALT_HMAC_SECRET, expired_ts)
255267
headers = json_headers("X-HMAC-Signature" => signature, "X-HMAC-Timestamp" => expired_ts)
256268
response = make_request(:post, "/webhooks/hmac_with_timestamp", json_payload, headers)
257-
expect_response(response, Net::HTTPUnauthorized, "authentication failed")
269+
expect_json_auth_failure(response)
258270
end
259271

260272
it "fails because the wrong HMAC algorithm is used" do
@@ -265,7 +277,7 @@ def expired_unix_timestamp(seconds_ago = 600)
265277
signature = signature.gsub("sha256=", "sha512=")
266278
headers = json_headers("X-HMAC-Signature" => signature, "X-HMAC-Timestamp" => timestamp)
267279
response = make_request(:post, "/webhooks/hmac_with_timestamp", json_payload, headers)
268-
expect_response(response, Net::HTTPUnauthorized, "authentication failed")
280+
expect_json_auth_failure(response)
269281
end
270282
end
271283

@@ -289,7 +301,7 @@ def expired_unix_timestamp(seconds_ago = 600)
289301
signature = generate_slack_signature(json_payload, FAKE_ALT_HMAC_SECRET, expired_ts)
290302
headers = json_headers("Signature-256" => signature, "X-Timestamp" => expired_ts)
291303
response = make_request(:post, "/webhooks/slack", json_payload, headers)
292-
expect_response(response, Net::HTTPUnauthorized, "authentication failed")
304+
expect_json_auth_failure(response)
293305
end
294306

295307
it "rejects request with missing timestamp header" do
@@ -299,7 +311,7 @@ def expired_unix_timestamp(seconds_ago = 600)
299311
signature = generate_slack_signature(json_payload, FAKE_ALT_HMAC_SECRET, timestamp)
300312
headers = json_headers("Signature-256" => signature)
301313
response = make_request(:post, "/webhooks/slack", json_payload, headers)
302-
expect_response(response, Net::HTTPUnauthorized, "authentication failed")
314+
expect_json_auth_failure(response)
303315
end
304316

305317
it "rejects request with invalid timestamp format" do
@@ -309,7 +321,7 @@ def expired_unix_timestamp(seconds_ago = 600)
309321
signature = generate_slack_signature(json_payload, FAKE_ALT_HMAC_SECRET, invalid_timestamp)
310322
headers = json_headers("Signature-256" => signature, "X-Timestamp" => invalid_timestamp)
311323
response = make_request(:post, "/webhooks/slack", json_payload, headers)
312-
expect_response(response, Net::HTTPUnauthorized, "authentication failed")
324+
expect_json_auth_failure(response)
313325
end
314326

315327
it "successfully processes request with ISO 8601 UTC timestamp" do
@@ -355,7 +367,7 @@ def expired_unix_timestamp(seconds_ago = 600)
355367
signature = generate_slack_signature(json_payload, FAKE_ALT_HMAC_SECRET, non_utc_timestamp)
356368
headers = json_headers("Signature-256" => signature, "X-Timestamp" => non_utc_timestamp)
357369
response = make_request(:post, "/webhooks/slack", json_payload, headers)
358-
expect_response(response, Net::HTTPUnauthorized, "authentication failed")
370+
expect_json_auth_failure(response)
359371
end
360372

361373
it "rejects request with timestamp manipulation attack" do
@@ -368,7 +380,7 @@ def expired_unix_timestamp(seconds_ago = 600)
368380
signature = generate_slack_signature(json_payload, FAKE_ALT_HMAC_SECRET, original_timestamp)
369381
headers = json_headers("Signature-256" => signature, "X-Timestamp" => manipulated_timestamp)
370382
response = make_request(:post, "/webhooks/slack", json_payload, headers)
371-
expect_response(response, Net::HTTPUnauthorized, "authentication failed")
383+
expect_json_auth_failure(response)
372384
end
373385
end
374386

@@ -377,7 +389,7 @@ def expired_unix_timestamp(seconds_ago = 600)
377389
payload = { event: "user.login", user: { id: "12345" } }
378390
headers = json_headers("Authorization" => "badvalue")
379391
response = make_request(:post, "/webhooks/okta", payload.to_json, headers)
380-
expect_response(response, Net::HTTPUnauthorized, "authentication failed")
392+
expect_json_auth_failure(response)
381393
end
382394

383395
it "successfully processes a valid POST request with shared secret" do
@@ -420,14 +432,14 @@ def expired_unix_timestamp(seconds_ago = 600)
420432
payload = {}.to_json
421433
headers = { "Authorization" => "Bearer wrong-secret" }
422434
response = make_request(:post, "/webhooks/with_custom_auth_plugin", payload, headers)
423-
expect_response(response, Net::HTTPUnauthorized, "authentication failed")
435+
expect_json_auth_failure(response)
424436
end
425437

426438
it "rejects requests with missing credentials using custom auth plugin" do
427439
payload = {}.to_json
428440
headers = {}
429441
response = make_request(:post, "/webhooks/with_custom_auth_plugin", payload, headers)
430-
expect_response(response, Net::HTTPUnauthorized, "authentication failed")
442+
expect_json_auth_failure(response)
431443
end
432444
end
433445

@@ -518,12 +530,15 @@ def expired_unix_timestamp(seconds_ago = 600)
518530
it "sends a POST request to the /webhooks/boomtown_with_error endpoint and it explodes with a simple text error" do
519531
payload = { boom_simple_text: true }.to_json
520532
response = make_request(:post, "/webhooks/boomtown_with_error", payload, json_headers)
521-
expect_response(response, Net::HTTPInternalServerError, "boomtown_with_error: the payload triggered a simple text boomtown error")
533+
expect_response(response, Net::HTTPInternalServerError)
522534

523-
body = response.body
524-
expect(body).to eq("boomtown_with_error: the payload triggered a simple text boomtown error")
525-
expect(response.content_type).to eq("text/plain")
535+
# With JSON default format, even string errors are JSON-encoded
536+
expect(response.content_type).to eq("application/json")
526537
expect(response.code).to eq("500")
538+
539+
# The error body should be the JSON-encoded string
540+
body = parse_json_response(response)
541+
expect(body).to eq("boomtown_with_error: the payload triggered a simple text boomtown error")
527542
end
528543
end
529544

@@ -546,14 +561,14 @@ def expired_unix_timestamp(seconds_ago = 600)
546561
json_payload = payload.to_json
547562
headers = json_headers("Tailscale-Webhook-Signature" => "t=1663781880,v1=invalidsignature")
548563
response = make_request(:post, "/webhooks/tailscale", json_payload, headers)
549-
expect_response(response, Net::HTTPUnauthorized, "authentication failed")
564+
expect_json_auth_failure(response)
550565
end
551566

552567
it "rejects request with missing signature header" do
553568
payload = { event: "user.login", user: { id: "12345" } }
554569
json_payload = payload.to_json
555570
response = make_request(:post, "/webhooks/tailscale", json_payload, json_headers)
556-
expect_response(response, Net::HTTPUnauthorized, "authentication failed")
571+
expect_json_auth_failure(response)
557572
end
558573

559574
it "rejects request with wrong signature algorithm" do
@@ -565,7 +580,7 @@ def expired_unix_timestamp(seconds_ago = 600)
565580
wrong_signature = OpenSSL::HMAC.hexdigest(OpenSSL::Digest.new("sha1"), FAKE_ALT_HMAC_SECRET, signing_payload)
566581
headers = json_headers("Tailscale-Webhook-Signature" => "t=#{timestamp},v1=#{wrong_signature}")
567582
response = make_request(:post, "/webhooks/tailscale", json_payload, headers)
568-
expect_response(response, Net::HTTPUnauthorized, "authentication failed")
583+
expect_json_auth_failure(response)
569584
end
570585
end
571586

@@ -584,7 +599,7 @@ def expired_unix_timestamp(seconds_ago = 600)
584599
payload = {}.to_json
585600
headers = { "Content-Type" => "application/json", "X-Forwarded-For" => "123.456.789.000" }
586601
response = make_request(:post, "/webhooks/ip_filtering_example", payload, headers)
587-
expect_response(response, Net::HTTPUnauthorized, "authentication failed")
602+
expect_json_auth_failure(response)
588603
end
589604
end
590605

0 commit comments

Comments
 (0)