Skip to content

Commit a00bbd6

Browse files
authored
fix(api_server): require CallbackToken for Merkle callback endpoint (#76) (#112)
handleCallback no longer skips bearer-token validation when CallbackToken is empty; it now always demands a valid bearer. Config validation rejects an empty CallbackToken when MerkleService is configured, so operators can't accidentally deploy an unauthenticated callback receiver. Token comparison uses constant-time compare to remove the timing side channel. Closes F-018.
1 parent fbe1783 commit a00bbd6

6 files changed

Lines changed: 219 additions & 26 deletions

File tree

config.example.yaml

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,12 @@ log_level: info
2020
network: mainnet
2121

2222
# callback_url: "https://example.com/callback"
23+
# Bearer token the Merkle Service must present in the Authorization header on
24+
# every POST /api/v1/merkle-service/callback. REQUIRED when merkle_service.url
25+
# is set: arcade refuses to start otherwise, since an unset token would let any
26+
# unauthenticated caller submit forged status updates for any txid. Generate a
27+
# high-entropy random value (e.g. `openssl rand -hex 32`) and configure the
28+
# matching token on the Merkle Service. See issue #76 / finding F-018.
2329
# callback_token: "your-callback-token"
2430

2531
api:
@@ -73,6 +79,10 @@ teranode:
7379
auth_token: ""
7480

7581
merkle_service:
82+
# Merkle Service base URL. Leaving this empty disables the Merkle integration
83+
# (standalone profiles). When set, the top-level `callback_token` becomes
84+
# REQUIRED — arcade fails to start otherwise so that the inbound callback
85+
# endpoint never runs unauthenticated. See issue #76 / finding F-018.
7686
url: ""
7787
auth_token: ""
7888

config/config.go

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -552,6 +552,16 @@ func validate(cfg *Config) error {
552552
// of the client. The documented standalone and zero-dependency profiles
553553
// (config.example.standalone.yaml) ship with merkle_service.url: "" for
554554
// exactly this reason. See issue #59 / finding F-001.
555+
//
556+
// When the Merkle integration IS enabled (URL set), callback_token is
557+
// mandatory. The /api/v1/merkle-service/callback endpoint accepts forged
558+
// status updates for any txid in the system if it runs without bearer-token
559+
// auth, so we fail-closed here at config load rather than silently exposing
560+
// the unauthenticated receiver. See issue #76 / finding F-018.
561+
if cfg.MerkleService.URL != "" && cfg.CallbackToken == "" {
562+
return fmt.Errorf("callback_token is required when merkle_service.url is set " +
563+
"(unauthenticated /api/v1/merkle-service/callback would accept forged callbacks; see issue #76)")
564+
}
555565
if cfg.Network == "" {
556566
cfg.Network = NetworkMainnet
557567
}

config/config_test.go

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,10 @@ import (
77

88
// baseValidConfig returns a Config populated with the minimum fields other
99
// validate() branches require so each test can focus on the network branch.
10+
//
11+
// Sets a non-empty CallbackToken because validate() now refuses to start
12+
// when MerkleService.URL is set without a bearer token (issue #76 / F-018).
13+
// Tests that need to exercise that branch override CallbackToken explicitly.
1014
func baseValidConfig() *Config {
1115
cfg := &Config{}
1216
cfg.Mode = "all"
@@ -15,6 +19,7 @@ func baseValidConfig() *Config {
1519
cfg.Store.Pebble.Path = "/tmp/arcade-test"
1620
cfg.Network = NetworkMainnet
1721
cfg.MerkleService.URL = "http://merkle.local"
22+
cfg.CallbackToken = "test-callback-token"
1823
return cfg
1924
}
2025

@@ -43,6 +48,37 @@ func TestValidate_AcceptsPopulatedMerkleServiceURL(t *testing.T) {
4348
}
4449
}
4550

51+
// Issue #76 / finding F-018: when the Merkle integration is wired up, the
52+
// inbound /api/v1/merkle-service/callback endpoint MUST be authenticated. We
53+
// fail fast at config load when MerkleService.URL is set without a
54+
// callback_token rather than silently exposing an unauthenticated receiver
55+
// that any unauthenticated caller could use to submit forged status updates.
56+
func TestValidate_RequiresCallbackTokenWhenMerkleEnabled(t *testing.T) {
57+
cfg := baseValidConfig()
58+
cfg.MerkleService.URL = "http://merkle.local"
59+
cfg.CallbackToken = ""
60+
err := validate(cfg)
61+
if err == nil {
62+
t.Fatal("expected error when merkle_service.url is set without callback_token")
63+
}
64+
if !strings.Contains(err.Error(), "callback_token") {
65+
t.Errorf("error should mention callback_token, got: %v", err)
66+
}
67+
}
68+
69+
// Standalone profiles ship with merkle_service.url: "" and frequently leave
70+
// callback_token empty too — there is no Merkle Service issuing callbacks, so
71+
// no token is required. The validation must pass cleanly so the standalone
72+
// binary keeps booting (issue #59 fix from #104 must continue to hold).
73+
func TestValidate_AllowsEmptyCallbackTokenInStandaloneMode(t *testing.T) {
74+
cfg := baseValidConfig()
75+
cfg.MerkleService.URL = ""
76+
cfg.CallbackToken = ""
77+
if err := validate(cfg); err != nil {
78+
t.Fatalf("standalone (no merkle, no callback_token) should validate, got: %v", err)
79+
}
80+
}
81+
4682
// Each canonical network name must validate cleanly. The empty string is also
4783
// accepted — validate() normalizes it to mainnet so CLI users can omit the key.
4884
// Regtest validates without bootstrap_peers here because baseValidConfig leaves

services/api_server/handlers.go

Lines changed: 21 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package api_server
33
import (
44
"context"
55
"crypto/rand"
6+
"crypto/subtle"
67
"encoding/hex"
78
"html/template"
89
"io"
@@ -225,14 +226,27 @@ func (s *Server) handleReady(c *gin.Context) {
225226

226227
// handleCallback processes inbound callbacks from Merkle Service.
227228
// Uses CallbackMessage format with Type field.
229+
//
230+
// Bearer-token authentication is mandatory. config.validate refuses to start
231+
// the binary when MerkleService is configured without a CallbackToken (finding
232+
// F-018 / issue #76), so reaching this handler with an empty configured token
233+
// means a misconfigured deployment outside the supported envelope. We still
234+
// fail closed here as a defense-in-depth measure: an empty/missing bearer or
235+
// any mismatch is rejected with 401 before any callback processing runs.
228236
func (s *Server) handleCallback(c *gin.Context) {
229-
// Bearer token validation
230-
if s.cfg.CallbackToken != "" {
231-
auth := c.GetHeader("Authorization")
232-
if !strings.HasPrefix(auth, "Bearer ") || auth[len("Bearer "):] != s.cfg.CallbackToken {
233-
c.JSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"})
234-
return
235-
}
237+
// Bearer token validation — always enforced, never skipped on empty
238+
// configured token. subtle.ConstantTimeCompare removes the timing side
239+
// channel that a plain == on the secret would expose.
240+
auth := c.GetHeader("Authorization")
241+
const bearerPrefix = "Bearer "
242+
configured := []byte(s.cfg.CallbackToken)
243+
var presented []byte
244+
if strings.HasPrefix(auth, bearerPrefix) {
245+
presented = []byte(auth[len(bearerPrefix):])
246+
}
247+
if len(configured) == 0 || subtle.ConstantTimeCompare(configured, presented) != 1 {
248+
c.JSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"})
249+
return
236250
}
237251

238252
var msg models.CallbackMessage

services/api_server/handlers_test.go

Lines changed: 141 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -165,11 +165,17 @@ func mustMarshalJSON(t *testing.T, v any) []byte {
165165
return b
166166
}
167167

168+
// testCallbackToken is the bearer secret every callback test installs into the
169+
// Server.cfg so handleCallback's mandatory auth check passes. Real deployments
170+
// supply a high-entropy value via config.callback_token; tests just need a
171+
// stable non-empty string.
172+
const testCallbackToken = "test-callback-token"
173+
168174
func setupServerWithStore(broker *kafka.RecordingBroker, ms *mockStore) (*Server, *gin.Engine) {
169175
gin.SetMode(gin.TestMode)
170176
producer := kafka.NewProducer(broker)
171177
srv := &Server{
172-
cfg: &config.Config{},
178+
cfg: &config.Config{CallbackToken: testCallbackToken},
173179
logger: zap.NewNop(),
174180
producer: producer,
175181
store: ms,
@@ -183,7 +189,7 @@ func setupServer(broker *kafka.RecordingBroker) (*Server, *gin.Engine) {
183189
gin.SetMode(gin.TestMode)
184190
producer := kafka.NewProducer(broker)
185191
srv := &Server{
186-
cfg: &config.Config{},
192+
cfg: &config.Config{CallbackToken: testCallbackToken},
187193
logger: zap.NewNop(),
188194
producer: producer,
189195
}
@@ -192,6 +198,17 @@ func setupServer(broker *kafka.RecordingBroker) (*Server, *gin.Engine) {
192198
return srv, router
193199
}
194200

201+
// authedCallbackRequest builds a callback POST with the canonical bearer
202+
// header so the mandatory auth check inside handleCallback accepts it. Tests
203+
// that exercise the auth check itself construct their own requests instead.
204+
func authedCallbackRequest(t *testing.T, body []byte) *http.Request {
205+
t.Helper()
206+
req := httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/api/v1/merkle-service/callback", bytes.NewReader(body))
207+
req.Header.Set("Content-Type", "application/json")
208+
req.Header.Set("Authorization", "Bearer "+testCallbackToken)
209+
return req
210+
}
211+
195212
// totalMessages returns the combined count of single-message Sends and
196213
// batched entries — matching the old Sarama mock's flat-message semantics.
197214
func totalMessages(broker *kafka.RecordingBroker) int {
@@ -319,8 +336,7 @@ func TestHandleCallback_SeenMultipleNodes_UpdatesStatus(t *testing.T) {
319336
}
320337
body := mustMarshalJSON(t, payload)
321338

322-
req := httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/api/v1/merkle-service/callback", bytes.NewReader(body))
323-
req.Header.Set("Content-Type", "application/json")
339+
req := authedCallbackRequest(t, body)
324340
w := httptest.NewRecorder()
325341

326342
router.ServeHTTP(w, req)
@@ -360,8 +376,7 @@ func TestHandleCallback_Stump_StorageError_Returns500(t *testing.T) {
360376
}
361377
body := mustMarshalJSON(t, payload)
362378

363-
req := httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/api/v1/merkle-service/callback", bytes.NewReader(body))
364-
req.Header.Set("Content-Type", "application/json")
379+
req := authedCallbackRequest(t, body)
365380
w := httptest.NewRecorder()
366381

367382
router.ServeHTTP(w, req)
@@ -384,8 +399,7 @@ func TestHandleCallback_SeenMultipleNodes_EmptyTxIDs(t *testing.T) {
384399
}
385400
body := mustMarshalJSON(t, payload)
386401

387-
req := httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/api/v1/merkle-service/callback", bytes.NewReader(body))
388-
req.Header.Set("Content-Type", "application/json")
402+
req := authedCallbackRequest(t, body)
389403
w := httptest.NewRecorder()
390404

391405
router.ServeHTTP(w, req)
@@ -463,8 +477,7 @@ func TestHandleCallback_FullBlockFlow_20Subtrees(t *testing.T) {
463477
errCh <- fmt.Errorf("marshal subtree %d: %w", i, err)
464478
return
465479
}
466-
req := httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/api/v1/merkle-service/callback", bytes.NewReader(body))
467-
req.Header.Set("Content-Type", "application/json")
480+
req := authedCallbackRequest(t, body)
468481
// Match merkle-service's delivery headers exactly.
469482
req.Header.Set("X-Idempotency-Key", fmt.Sprintf("%s:%d:STUMP", blockHash, i))
470483
w := httptest.NewRecorder()
@@ -527,8 +540,7 @@ func TestHandleCallback_FullBlockFlow_20Subtrees(t *testing.T) {
527540
if err != nil {
528541
t.Fatalf("marshal BLOCK_PROCESSED: %v", err)
529542
}
530-
req := httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/api/v1/merkle-service/callback", bytes.NewReader(body))
531-
req.Header.Set("Content-Type", "application/json")
543+
req := authedCallbackRequest(t, body)
532544
req.Header.Set("X-Idempotency-Key", blockHash+":BLOCK_PROCESSED")
533545
w := httptest.NewRecorder()
534546
router.ServeHTTP(w, req)
@@ -577,8 +589,7 @@ func TestHandleCallback_FullBlockFlow_20Subtrees(t *testing.T) {
577589
Stump: stumpPayloads[0],
578590
}
579591
retryBody := mustMarshalJSON(t, retryPayload)
580-
retryReq := httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/api/v1/merkle-service/callback", bytes.NewReader(retryBody))
581-
retryReq.Header.Set("Content-Type", "application/json")
592+
retryReq := authedCallbackRequest(t, retryBody)
582593
retryW := httptest.NewRecorder()
583594
router.ServeHTTP(retryW, retryReq)
584595
if retryW.Code != http.StatusOK {
@@ -662,8 +673,7 @@ func TestHandleCallback_FullBlockFlow_PartialStumpFailure(t *testing.T) {
662673
Stump: stumpPayloads[i],
663674
}
664675
body := mustMarshalJSON(t, payload)
665-
req := httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/api/v1/merkle-service/callback", bytes.NewReader(body))
666-
req.Header.Set("Content-Type", "application/json")
676+
req := authedCallbackRequest(t, body)
667677
w := httptest.NewRecorder()
668678
router.ServeHTTP(w, req)
669679
resCh <- result{idx: i, status: w.Code}
@@ -711,8 +721,7 @@ func TestHandleCallback_FullBlockFlow_PartialStumpFailure(t *testing.T) {
711721
BlockHash: blockHash,
712722
}
713723
body := mustMarshalJSON(t, blockMsg)
714-
req := httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/api/v1/merkle-service/callback", bytes.NewReader(body))
715-
req.Header.Set("Content-Type", "application/json")
724+
req := authedCallbackRequest(t, body)
716725
w := httptest.NewRecorder()
717726
router.ServeHTTP(w, req)
718727
if w.Code != http.StatusOK {
@@ -867,3 +876,117 @@ func TestHandleSubmitTransactions_TxID_IsCanonical(t *testing.T) {
867876
t.Errorf("submissions: want %v, got %v", want, got)
868877
}
869878
}
879+
880+
// TestHandleCallback_RejectsUnauthenticated locks down the F-018 fix: the
881+
// /api/v1/merkle-service/callback receiver MUST refuse any request missing
882+
// or presenting the wrong bearer token, and MUST refuse all requests when
883+
// the configured token is empty (fail-closed). Pre-fix, the handler skipped
884+
// the entire bearer check when CallbackToken == "", letting any unauthenticated
885+
// caller submit forged Merkle status updates.
886+
//
887+
// This test exercises the runtime check directly. The "config rejects empty
888+
// token when Merkle is enabled" half of the fix is covered in
889+
// config/config_test.go (TestValidate_RequiresCallbackTokenWhenMerkleEnabled).
890+
func TestHandleCallback_RejectsUnauthenticated(t *testing.T) {
891+
payload := mustMarshalJSON(t, models.CallbackMessage{
892+
Type: models.CallbackSeenMultipleNodes,
893+
TxIDs: []string{"tx1"},
894+
})
895+
896+
cases := []struct {
897+
name string
898+
token string // configured CallbackToken on the Server.
899+
header string // Authorization header sent on the request, "" = none.
900+
wantOK bool
901+
wantErr int
902+
}{
903+
{
904+
name: "no auth header is rejected",
905+
token: testCallbackToken,
906+
header: "",
907+
wantOK: false,
908+
wantErr: http.StatusUnauthorized,
909+
},
910+
{
911+
name: "wrong bearer is rejected",
912+
token: testCallbackToken,
913+
header: "Bearer not-the-real-token",
914+
wantOK: false,
915+
wantErr: http.StatusUnauthorized,
916+
},
917+
{
918+
name: "non-bearer scheme is rejected",
919+
token: testCallbackToken,
920+
header: "Basic " + testCallbackToken,
921+
wantOK: false,
922+
wantErr: http.StatusUnauthorized,
923+
},
924+
{
925+
// Defense-in-depth. Config validation now refuses to start with an
926+
// empty CallbackToken when Merkle is enabled, but if a misconfigured
927+
// process somehow reaches the handler with cfg.CallbackToken == ""
928+
// every request — including one presenting an empty bearer — must
929+
// still be refused.
930+
name: "empty configured token rejects all callers",
931+
token: "",
932+
header: "Bearer ",
933+
wantOK: false,
934+
wantErr: http.StatusUnauthorized,
935+
},
936+
{
937+
// Same scenario as above with no auth header — must also be 401.
938+
name: "empty configured token rejects unauthenticated caller",
939+
token: "",
940+
header: "",
941+
wantOK: false,
942+
wantErr: http.StatusUnauthorized,
943+
},
944+
{
945+
name: "correct bearer is accepted",
946+
token: testCallbackToken,
947+
header: "Bearer " + testCallbackToken,
948+
wantOK: true,
949+
},
950+
}
951+
952+
for _, tc := range cases {
953+
t.Run(tc.name, func(t *testing.T) {
954+
ms := &mockStore{}
955+
gin.SetMode(gin.TestMode)
956+
producer := kafka.NewProducer(&kafka.RecordingBroker{})
957+
srv := &Server{
958+
cfg: &config.Config{CallbackToken: tc.token},
959+
logger: zap.NewNop(),
960+
producer: producer,
961+
store: ms,
962+
}
963+
router := gin.New()
964+
srv.registerRoutes(router)
965+
966+
req := httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/api/v1/merkle-service/callback", bytes.NewReader(payload))
967+
req.Header.Set("Content-Type", "application/json")
968+
if tc.header != "" {
969+
req.Header.Set("Authorization", tc.header)
970+
}
971+
w := httptest.NewRecorder()
972+
router.ServeHTTP(w, req)
973+
974+
if tc.wantOK {
975+
if w.Code != http.StatusOK {
976+
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
977+
}
978+
if len(ms.updateStatusCalls) != 1 {
979+
t.Errorf("expected store update on accepted callback, got %d", len(ms.updateStatusCalls))
980+
}
981+
return
982+
}
983+
if w.Code != tc.wantErr {
984+
t.Fatalf("expected %d, got %d: %s", tc.wantErr, w.Code, w.Body.String())
985+
}
986+
// Rejected requests must not reach the dispatch path.
987+
if len(ms.updateStatusCalls) != 0 {
988+
t.Errorf("rejected callback must not write to the store, got %d UpdateStatus calls", len(ms.updateStatusCalls))
989+
}
990+
})
991+
}
992+
}

services/api_server/routes.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,7 @@ var routeDocs = []RouteDoc{
5555
Method: "POST",
5656
Path: "/api/v1/merkle-service/callback",
5757
Description: "Receive callbacks from Merkle Service",
58-
RequestFormat: "JSON CallbackMessage with type field. Optional Bearer token auth.",
58+
RequestFormat: "JSON CallbackMessage with type field. Bearer token auth required (Authorization: Bearer <callback_token>).",
5959
ResponseFormat: "200 OK",
6060
},
6161
{

0 commit comments

Comments
 (0)