Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
872badb
feat: implement domain whitelist validation for sender API endpoints
Joshua-Ogbonna Sep 24, 2025
2f85f43
chore: work
Joshua-Ogbonna Sep 24, 2025
e5bd4be
chore: implement balanced fallback stratgy when domain extraction fai…
Joshua-Ogbonna Sep 25, 2025
a80466e
feat: add kyb_rejection_comment column to kyb_profiles table
sundayonah Sep 25, 2025
5d86794
feat: add amount in usd field to the payment order tables
Joshua-Ogbonna Sep 26, 2025
cfe540c
fix: update hash for add_amount_in_usd_to_payment_tables migration an…
chibie Sep 26, 2025
49070fa
feat: rename transaction history method to fetch token transfers from…
chibie Sep 5, 2025
9166230
feat: normalize token transfer field names in Blockscout API response
chibie Sep 5, 2025
f9f1f5e
fix: resolve release workflow regex patterns for proper commit classi…
chibie Sep 11, 2025
8974cae
feat: enhance balance validation by loading provider and currency edges
chibie Sep 11, 2025
f8f09b8
feat: add transaction hash to payment order updates
chibie Sep 11, 2025
89b497d
fix: enhance account name validation in response handling
chibie Sep 15, 2025
c2f1a98
fix: add address non-empty check in priority queue creation
chibie Sep 16, 2025
6197215
fix: filter enabled tokens in provider profile query
chibie Sep 16, 2025
fff23ad
fix: streamline token query in provider profile retrieval
chibie Sep 17, 2025
43539f6
fix: improve Etherscan error grouping in Glitchtip
onahprosper Sep 17, 2025
6110dc1
fix: temporarily disable and re-enable trigger during amount_in_usd u…
chibie Sep 26, 2025
c774be6
chore: work PR comments from coderabbit
Joshua-Ogbonna Sep 26, 2025
21f4f38
Merge branch 'main' into 522-implement-and-enforce-domain-whitelist-v…
Joshua-Ogbonna Sep 26, 2025
5074107
Merge branch 'main' into 522-implement-and-enforce-domain-whitelist-v…
Dprof-in-tech Oct 8, 2025
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
6 changes: 4 additions & 2 deletions .github/workflows/create-release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -105,9 +105,11 @@ jobs:
echo "formatted_commits<<EOF" >> $GITHUB_OUTPUT
echo "### Breaking Changes"
if [ "$latest_tag" = "v0.0.0" ]; then
git log --pretty=format:"%B" | grep -E "^(feat!|BREAKING CHANGE)" || echo "None"
git log --pretty=format:"%B" | grep -E "^feat\\!" || echo "None"
git log --pretty=format:"%B" | grep -E "BREAKING CHANGE" || echo "None"
else
git log $latest_tag..HEAD --pretty=format:"%B" | grep -E "^(feat!|BREAKING CHANGE)" || echo "None"
git log $latest_tag..HEAD --pretty=format:"%B" | grep -E "^feat\\!" || echo "None"
git log $latest_tag..HEAD --pretty=format:"%B" | grep -E "BREAKING CHANGE" || echo "None"
fi
echo ""
echo "### Features"
Expand Down
68 changes: 68 additions & 0 deletions controllers/index.go
Original file line number Diff line number Diff line change
Expand Up @@ -1224,6 +1224,74 @@ func (ctrl *Controller) SlackInteractionHandler(ctx *gin.Context) {
return
}

if strings.HasPrefix(actionID, "approve_") {
if ctrl.isActionProcessed(kybProfileID, "approve") || ctrl.isActionProcessed(kybProfileID, "reject") {
logger.Warnf("Action already processed for KYB Profile %s", kybProfileID)
ctx.JSON(http.StatusOK, gin.H{"text": "This submission has already been processed."})
return
}

// Mark as processed immediately
ctrl.markActionProcessed(kybProfileID, "approve")

// Respond immediately to Slack to remove loading state
responseURL, _ := payload["response_url"].(string)
if responseURL != "" {
go func() {
message := map[string]interface{}{
"replace_original": true,
"text": fmt.Sprintf("✅ *APPROVED* - KYB submission for %s (%s) from %s has been approved.", firstName, email, kybProfile.CompanyName),
}
jsonPayload, _ := json.Marshal(message)
if _, err := http.Post(responseURL, "application/json", bytes.NewBuffer(jsonPayload)); err != nil {
logger.Errorf("Failed to post response to URL: %v", err)
}
}()
}

// Send immediate response to Slack
ctx.JSON(http.StatusOK, gin.H{"text": "Approving submission..."})

// Update User KYB status
_, err := storage.Client.User.
Update().
Where(user.IDEQ(kybProfile.Edges.User.ID)).
SetKybVerificationStatus(user.KybVerificationStatusApproved).
Save(ctx)
if err != nil {
logger.Errorf("Failed to approve KYB for user %s (KYB Profile %s): %v", email, kybProfileID, err)
ctx.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update user KYB status"})
return
}

// Update KYB Profile status and clear rejection comment
_, err = storage.Client.KYBProfile.
Update().
Where(kybprofile.IDEQ(kybProfileUUID)).
ClearKybRejectionComment().
Save(ctx)
if err != nil {
logger.Errorf("Failed to update KYB Profile status %s: %v", kybProfileID, err)
}

// Send approval email
resp, err := ctrl.emailService.SendKYBApprovalEmail(ctx, email, firstName)
if err != nil {
logger.Errorf("Failed to send KYB approval email to %s (KYB Profile %s): %v, response: %+v", email, kybProfileID, err, resp)
} else {
logger.Infof("KYB approval email sent successfully to %s (KYB Profile %s), message ID: %s", email, kybProfileID, resp.Id)
}

// Send Slack feedback notification
err = ctrl.slackService.SendActionFeedbackNotification(firstName, email, kybProfileID, "approve", "")
if err != nil {
logger.Warnf("Failed to send Slack feedback notification for KYB Profile %s: %v", kybProfileID, err)
}

logger.Infof("Processed Slack approve interaction in %v", time.Since(startTime))
return
}

ctx.JSON(http.StatusBadRequest, gin.H{"error": "Unknown action"})
return
}
Expand Down
2 changes: 1 addition & 1 deletion docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ services:
- postgres_db:postgres_db
volumes:
- .:/app
#- tmpfs-cache:/root/.cache/go-build # Add tmpfs volume for build cache
# - tmpfs-cache:/root/.cache/go-build # Add tmpfs volume for build cache
deploy:
resources:
limits:
Expand Down
3 changes: 3 additions & 0 deletions routers/index.go
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,9 @@ func senderRoutes(route *gin.Engine) {
v1.Use(middleware.DynamicAuthMiddleware)
v1.Use(middleware.OnlySenderMiddleware)

// Add domain whitelist middleware for sender routes
v1.Use(middleware.DomainWhitelistMiddleware())

v1.POST("orders", senderCtrl.InitiatePaymentOrder)
v1.GET("orders/:id", senderCtrl.GetPaymentOrderByID)
v1.GET("orders", senderCtrl.GetPaymentOrders)
Expand Down
98 changes: 90 additions & 8 deletions routers/middleware/cors.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,20 +2,28 @@ package middleware

import (
"log"
"strings"

"github.com/gin-gonic/gin"
"github.com/google/uuid"
"github.com/paycrest/aggregator/ent"
"github.com/paycrest/aggregator/ent/apikey"
"github.com/paycrest/aggregator/ent/senderprofile"
"github.com/paycrest/aggregator/storage"
u "github.com/paycrest/aggregator/utils"
)

// CORSMiddleware is a middleware that adds CORS headers to response
func CORSMiddleware() gin.HandlerFunc {
return func(ctx *gin.Context) {
ctx.Writer.Header().Set("Access-Control-Allow-Origin", "*")
ctx.Writer.Header().Set("Access-Control-Max-Age", "86400")
ctx.Writer.Header().Set("Access-Control-Allow-Methods", "POST, GET, OPTIONS, PUT, DELETE, UPDATE")
ctx.Writer.Header().Set("Access-Control-Allow-Headers", "Origin, Content-Type, api_key, Content-Length, Accept-Encoding, X-CSRF-Token, Authorization")
ctx.Writer.Header().Set("Access-Control-Expose-Headers", "Content-Length")
ctx.Writer.Header().Set("Access-Control-Allow-Credentials", "true")
ctx.Writer.Header().Set("Cache-Control", "no-cache")
origin := ctx.GetHeader("Origin")
allowedOrigin := "*"

if strings.Contains(ctx.Request.URL.Path, "/sender/") {
allowedOrigin = getCORSOrigin(ctx, origin)
}

// Set all CORS headers
setCORSHeaders(ctx, allowedOrigin)

if ctx.Request.Method == "OPTIONS" {
log.Println("OPTIONS")
Expand All @@ -25,3 +33,77 @@ func CORSMiddleware() gin.HandlerFunc {
}
}
}

func getCORSOrigin(ctx *gin.Context, requestOrigin string) string {
if requestOrigin == "" {
return "*"
}

// Try JWT auth first (sender in context)
if senderCtx, ok := ctx.Get("sender"); ok && senderCtx != nil {
return validateDomainForSender(senderCtx.(*ent.SenderProfile), requestOrigin)
}

// Fall back to API key auth
return validateDomainForAPIKey(ctx, requestOrigin)
}

func validateDomainForSender(sender *ent.SenderProfile, requestOrigin string) string {
requestDomain, err := u.ExtractDomainFromOrigin(requestOrigin)
if err != nil {
if len(sender.DomainWhitelist) > 0 {
return "null"
}
if requestOrigin != "" {
return requestOrigin
}
return "*"
}
if len(sender.DomainWhitelist) == 0 {
if requestOrigin != "" {
return requestOrigin
}
return "*"
}
if u.IsDomainAllowed(requestDomain, sender.DomainWhitelist) {
return requestOrigin
}
return "null"
}

func validateDomainForAPIKey(ctx *gin.Context, requestOrigin string) string {
apiKey := ctx.GetHeader("API-Key")
if apiKey == "" {
return "*"
}

apiKeyUUID, err := uuid.Parse(apiKey)
if err != nil {
return "*"
}

senderProfile, err := storage.Client.SenderProfile.
Query().
Where(senderprofile.HasAPIKeyWith(apikey.IDEQ(apiKeyUUID))).
Only(ctx)
if err != nil {
return "*"
}

return validateDomainForSender(senderProfile, requestOrigin)
}

func setCORSHeaders(ctx *gin.Context, allowedOrigin string) {
ctx.Writer.Header().Set("Access-Control-Allow-Origin", allowedOrigin)
ctx.Writer.Header().Set("Vary", "Origin")
ctx.Writer.Header().Set("Access-Control-Max-Age", "86400")
ctx.Writer.Header().Set("Access-Control-Allow-Methods", "GET, POST, OPTIONS, PUT, DELETE, PATCH, HEAD")
ctx.Writer.Header().Set("Access-Control-Allow-Headers", "Origin, Content-Type, api_key, Content-Length, Accept-Encoding, X-CSRF-Token, Authorization, API-Key, Client-Type")
ctx.Writer.Header().Set("Access-Control-Expose-Headers", "Content-Length")
if allowedOrigin != "*" && allowedOrigin != "" {
ctx.Writer.Header().Set("Access-Control-Allow-Credentials", "true")
} else {
ctx.Writer.Header().Del("Access-Control-Allow-Credentials")
}
ctx.Writer.Header().Set("Cache-Control", "no-cache")
}
79 changes: 79 additions & 0 deletions routers/middleware/domain_whitelist.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
package middleware

import (
"net/http"
"strings"

"github.com/gin-gonic/gin"
"github.com/paycrest/aggregator/ent"
u "github.com/paycrest/aggregator/utils"
"github.com/paycrest/aggregator/utils/logger"
)

func DomainWhitelistMiddleware() gin.HandlerFunc {
return func(c *gin.Context) {
if !strings.Contains(c.Request.URL.Path, "/sender/") {
c.Next()
return
}

senderCtx, ok := c.Get("sender")
if !ok || senderCtx == nil {
c.Next()
return
}

sender := senderCtx.(*ent.SenderProfile)

if !sender.IsActive {
c.Next()
return
}

origin := c.GetHeader("Origin")
referer := c.GetHeader("Referer")

requestDomain, err := u.ExtractDomainFromRequest(origin, referer)
if err != nil {
logger.WithFields(logger.Fields{
"origin": origin,
"referer": referer,
"error": err.Error(),
}).Warnf("Failed to extract domain from request headers")

if len(sender.DomainWhitelist) > 0 {
u.APIResponse(c, http.StatusBadRequest, "error",
"Invalid request origin", nil)
c.Abort()
return
}

c.Next()
return
}

if !u.IsDomainAllowed(requestDomain, sender.DomainWhitelist) {
logger.WithFields(logger.Fields{
"sender_id": sender.ID.String(),
"request_domain": requestDomain,
"whitelist": sender.DomainWhitelist,
"origin": origin,
"referer": referer,
}).Warnf("Request blocked due to domain whitelist violation")

u.APIResponse(c, http.StatusForbidden, "error",
"Access denied: Domain not whitelisted", map[string]interface{}{
"domain": requestDomain,
})
c.Abort()
return
}

logger.WithFields(logger.Fields{
"sender_id": sender.ID.String(),
"request_domain": requestDomain,
}).Debugf("Domain whitelist validation passed")

c.Next()
}
}
89 changes: 89 additions & 0 deletions routers/middleware/domain_whitelist_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
package middleware

import (
"net/http"
"net/http/httptest"
"testing"

"github.com/gin-gonic/gin"
"github.com/google/uuid"
"github.com/paycrest/aggregator/ent"
)

func TestDomainWhitelistMiddleware(t *testing.T) {
// Create a mock sender profile for testing
senderProfile := &ent.SenderProfile{
ID: uuid.New(),
DomainWhitelist: []string{},
IsActive: true,
}

gin.SetMode(gin.TestMode)
router := gin.New()

// Add middleware
router.Use(func(c *gin.Context) {
c.Set("sender", senderProfile)
c.Next()
})
router.Use(DomainWhitelistMiddleware())

router.GET("/sender/test", func(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"status": "success"})
})

tests := []struct {
name string
origin string
whitelist []string
expectedStatus int
expectedMessage string
}{
{
name: "Empty whitelist allows all",
origin: "https://example.com",
whitelist: []string{},
expectedStatus: http.StatusOK,
},
{
name: "Whitelisted domain allowed",
origin: "https://example.com",
whitelist: []string{"example.com"},
expectedStatus: http.StatusOK,
},
{
name: "Non-whitelisted domain blocked",
origin: "https://malicious.com",
whitelist: []string{"example.com"},
expectedStatus: http.StatusForbidden,
},
{
name: "Subdomain allowed",
origin: "https://api.example.com",
whitelist: []string{"example.com"},
expectedStatus: http.StatusOK,
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Update the mock sender profile whitelist
senderProfile.DomainWhitelist = tt.whitelist

// Create request
req := httptest.NewRequest("GET", "/sender/test", nil)
req.Header.Set("Origin", tt.origin)

// Create response recorder
w := httptest.NewRecorder()

// Perform request
router.ServeHTTP(w, req)

// Check status
if w.Code != tt.expectedStatus {
t.Errorf("Expected status %d, got %d", tt.expectedStatus, w.Code)
}
})
}
}
Loading
Loading