Skip to content
Open
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
36 changes: 36 additions & 0 deletions contrib/screener-api/screener/auth_middleware_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
})
}
}
35 changes: 34 additions & 1 deletion contrib/screener-api/screener/screener.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,13 @@ package screener
import (
"bytes"
"context"
"crypto/hmac"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"strconv"
"strings"
"sync"
"time"
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand All @@ -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})
Expand Down Expand Up @@ -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)),
Expand Down
Loading