diff --git a/contrib/screener-api/screener/auth_middleware_test.go b/contrib/screener-api/screener/auth_middleware_test.go new file mode 100644 index 0000000000..07d5d87e29 --- /dev/null +++ b/contrib/screener-api/screener/auth_middleware_test.go @@ -0,0 +1,36 @@ +package screener + +import ( + "testing" + "time" +) + +func TestSignatureTimestampFresh(t *testing.T) { + t.Parallel() + + now := time.Unix(1_700_000_000, 0) + maxSkew := 5 * time.Minute + + tests := []struct { + name string + timestamp string + want bool + }{ + {name: "exact now", timestamp: "1700000000", want: true}, + {name: "within skew past", timestamp: "1699999900", want: true}, + {name: "within skew future", timestamp: "1700000100", want: true}, + {name: "stale past hour", timestamp: "1699996400", want: false}, + {name: "far future", timestamp: "1700003600", want: false}, + {name: "non numeric", timestamp: "not-a-unix-ts", want: false}, + {name: "empty", timestamp: "", want: false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + if got := signatureTimestampFresh(tt.timestamp, now, maxSkew); got != tt.want { + t.Fatalf("signatureTimestampFresh(%q) = %v, want %v", tt.timestamp, got, tt.want) + } + }) + } +} diff --git a/contrib/screener-api/screener/screener.go b/contrib/screener-api/screener/screener.go index b996e21a89..0d441ee928 100644 --- a/contrib/screener-api/screener/screener.go +++ b/contrib/screener-api/screener/screener.go @@ -4,11 +4,13 @@ package screener import ( "bytes" "context" + "crypto/hmac" "encoding/json" "errors" "fmt" "io" "net/http" + "strconv" "strings" "sync" "time" @@ -42,6 +44,10 @@ const ( okResponse = "OK" errResponse = "ERROR" meterName = "github.com/synapsecns/sanguine/contrib/screener-api" + // maxSignatureSkew is how far X-Signature-timestamp may drift from server time. + // Timestamp and nonce are part of the signed message; without a freshness check + // a captured sync request can be replayed indefinitely. + maxSignatureSkew = 5 * time.Minute ) // Screener is the interface for the screener. @@ -363,6 +369,19 @@ func (s *screenerImpl) blacklistAddress(c *gin.Context) { } } +// signatureTimestampFresh reports whether timestamp (unix seconds) is within maxSkew of now. +func signatureTimestampFresh(timestamp string, now time.Time, maxSkew time.Duration) bool { + ts, err := strconv.ParseInt(timestamp, 10, 64) + if err != nil { + return false + } + skew := now.Sub(time.Unix(ts, 0)) + if skew < 0 { + skew = -skew + } + return skew <= maxSkew +} + // This function takes the HTTP headers and the body of the request and reconstructs the signature to // compare it with the signature provided. If they match, the request is allowed to pass through. // nolint: canonicalheader @@ -377,6 +396,20 @@ func (s *screenerImpl) authMiddleware(cfg config.Config) gin.HandlerFunc { signature := c.Request.Header.Get("X-Signature-signature") queryString := c.Request.URL.RawQuery + if appID == "" || appID != cfg.AppID { + span.AddEvent("error", trace.WithAttributes(attribute.String("error", "Invalid app id"))) + c.JSON(http.StatusUnauthorized, gin.H{"error": errResponse}) + c.Abort() + return + } + + if !signatureTimestampFresh(timestamp, time.Now(), maxSignatureSkew) { + span.AddEvent("error", trace.WithAttributes(attribute.String("error", "Stale or invalid signature timestamp"))) + c.JSON(http.StatusUnauthorized, gin.H{"error": errResponse}) + c.Abort() + return + } + bodyBz, err := io.ReadAll(c.Request.Body) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": errResponse}) @@ -419,7 +452,7 @@ func (s *screenerImpl) authMiddleware(cfg config.Config) gin.HandlerFunc { attribute.String("message", message), ) - if expectedSignature != signature { + if !hmac.Equal([]byte(expectedSignature), []byte(signature)) { span.AddEvent( "error", trace.WithAttributes(attribute.String("error", "Invalid signature"+expectedSignature)),