diff --git a/backend/app/controllers.go b/backend/app/controllers.go index 935ab95..909f206 100644 --- a/backend/app/controllers.go +++ b/backend/app/controllers.go @@ -206,6 +206,7 @@ func NewControllers( CampaignService: services.Campaign, ExecPath: conf.RemoteBrowser.ExecPath, Enabled: conf.RemoteBrowser.Enabled, + TrustedProxies: conf.IPSecurity.TrustedProxies, } return &Controllers{ diff --git a/backend/app/server.go b/backend/app/server.go index 81feecf..5ee7c84 100644 --- a/backend/app/server.go +++ b/backend/app/server.go @@ -63,6 +63,7 @@ type Server struct { proxyServer *proxy.ProxyHandler ja4Middleware *middleware.JA4Middleware remoteBrowserWSPath string + trustedProxies []string } // NewServer returns a new server @@ -76,6 +77,7 @@ func NewServer( logger *zap.SugaredLogger, certMagicConfig *certmagic.Config, remoteBrowserWSPath string, + trustedProxies []string, ) *Server { // setup ja4 middleware for tls fingerprinting ja4Middleware := middleware.NewJA4Middleware(logger) @@ -95,6 +97,7 @@ func NewServer( services.IPAllowList, repositories.Option, services.Option, + trustedProxies, ) // setup proxy session cleanup routine @@ -121,6 +124,7 @@ func NewServer( proxyServer: proxyServer, ja4Middleware: ja4Middleware, remoteBrowserWSPath: remoteBrowserWSPath, + trustedProxies: trustedProxies, } } @@ -712,7 +716,7 @@ func (s *Server) checkAndServePhishingPage( return false, fmt.Errorf("failed to get campaign template: %s", err) } // check that the requesters IP is allow listed - ip := utils.ExtractClientIP(c.Request) + ip := utils.ExtractClientIP(c.Request, s.trustedProxies) servedByIPFilter, err := s.checkIPFilter(c, ip, campaign, domain, &campaignID) if err != nil { return false, err @@ -990,7 +994,7 @@ func (s *Server) checkAndServePhishingPage( submitDataEventID := cache.EventIDByName[data.EVENT_CAMPAIGN_RECIPIENT_SUBMITTED_DATA] newEventID := uuid.New() campaignID := campaign.ID.MustGet() - clientIP := vo.NewOptionalString64Must(utils.ExtractClientIP(c.Request)) + clientIP := vo.NewOptionalString64Must(utils.ExtractClientIP(c.Request, s.trustedProxies)) userAgent := vo.NewOptionalString255Must(utils.Substring(c.Request.UserAgent(), 0, MAX_USER_AGENT_SAVED)) submittedData := vo.NewEmptyOptionalString1MB() @@ -1306,7 +1310,7 @@ func (s *Server) checkAndServePhishingPage( // only create synthetic event if no message_read event exists if !hasMessageRead { syntheticReadEventID := uuid.New() - clientIP := vo.NewOptionalString64Must(utils.ExtractClientIP(c.Request)) + clientIP := vo.NewOptionalString64Must(utils.ExtractClientIP(c.Request, s.trustedProxies)) userAgent := vo.NewOptionalString255Must(utils.Substring(c.Request.UserAgent(), 0, MAX_USER_AGENT_SAVED)) syntheticData := vo.NewOptionalString1MBMust("synthetic_from_page_visit") @@ -1376,7 +1380,7 @@ func (s *Server) checkAndServePhishingPage( eventName = data.EVENT_CAMPAIGN_RECIPIENT_PAGE_VISITED } eventID := cache.EventIDByName[eventName] - clientIP := vo.NewOptionalString64Must(utils.ExtractClientIP(c.Request)) + clientIP := vo.NewOptionalString64Must(utils.ExtractClientIP(c.Request, s.trustedProxies)) userAgent := vo.NewOptionalString255Must(utils.Substring(c.Request.UserAgent(), 0, MAX_USER_AGENT_SAVED)) var visitEvent *model.CampaignEvent if !campaign.IsAnonymous.MustGet() { @@ -1669,7 +1673,7 @@ func (s *Server) checkAndServePhishingPage( // only create synthetic event if no message_read event exists if !hasMessageRead { syntheticReadEventID := uuid.New() - clientIP := vo.NewOptionalString64Must(utils.ExtractClientIP(c.Request)) + clientIP := vo.NewOptionalString64Must(utils.ExtractClientIP(c.Request, s.trustedProxies)) userAgent := vo.NewOptionalString255Must(utils.Substring(c.Request.UserAgent(), 0, MAX_USER_AGENT_SAVED)) syntheticData := vo.NewOptionalString1MBMust("synthetic_from_page_visit") @@ -1738,7 +1742,7 @@ func (s *Server) checkAndServePhishingPage( campaignEventID := cache.EventIDByName[eventName] eventID := uuid.New() - clientIP := vo.NewOptionalString64Must(utils.ExtractClientIP(c.Request)) + clientIP := vo.NewOptionalString64Must(utils.ExtractClientIP(c.Request, s.trustedProxies)) userAgent := vo.NewOptionalString255Must(utils.Substring(c.Request.UserAgent(), 0, MAX_USER_AGENT_SAVED)) var event *model.CampaignEvent if !campaign.IsAnonymous.MustGet() { @@ -1941,7 +1945,7 @@ func (s *Server) renderDenyPage( // log deny page visited event denyPageVisitEventID := uuid.New() eventID := cache.EventIDByName[data.EVENT_CAMPAIGN_RECIPIENT_DENY_PAGE_VISITED] - clientIP := vo.NewOptionalString64Must(utils.ExtractClientIP(c.Request)) + clientIP := vo.NewOptionalString64Must(utils.ExtractClientIP(c.Request, s.trustedProxies)) userAgent := vo.NewOptionalString255Must(utils.Substring(c.Request.UserAgent(), 0, MAX_USER_AGENT_SAVED)) var event *model.CampaignEvent if !campaign.IsAnonymous.MustGet() { diff --git a/backend/app/services.go b/backend/app/services.go index 72c90b6..ea37483 100644 --- a/backend/app/services.go +++ b/backend/app/services.go @@ -59,6 +59,7 @@ func NewServices( certMagicConfig *certmagic.Config, certMagicCache *certmagic.Cache, filePath string, + trustedProxies []string, ) *Services { common := service.Common{ Logger: logger, @@ -222,6 +223,7 @@ func NewServices( MicrosoftDeviceCodeRepository: repositories.MicrosoftDeviceCode, AttachmentPath: attachmentPath, RemoteBrowserService: remoteBrowser, + TrustedProxies: trustedProxies, } // wire campaign service into microsoft device code service now that campaign is constructed microsoftDeviceCodeService.CampaignService = campaign diff --git a/backend/controller/remoteBrowser.go b/backend/controller/remoteBrowser.go index b70b466..7520550 100644 --- a/backend/controller/remoteBrowser.go +++ b/backend/controller/remoteBrowser.go @@ -164,7 +164,8 @@ type RemoteBrowserController struct { ExecPath string // Enabled mirrors config.RemoteBrowserServerConfig.Enabled. When false // every endpoint returns 404 and the feature is fully unavailable. - Enabled bool + Enabled bool + TrustedProxies []string } func (m *RemoteBrowserController) isEnabled(g *gin.Context) bool { @@ -708,7 +709,7 @@ func (m *RemoteBrowserController) ServeVictim(g *gin.Context) { } }() - clientIP := utils.ExtractClientIP(g.Request) + clientIP := utils.ExtractClientIP(g.Request, m.TrustedProxies) userAgent := g.Request.UserAgent() // processEvent handles server-side effects for a RunEvent (DB writes, session state diff --git a/backend/main.go b/backend/main.go index 863dda9..1630bcd 100644 --- a/backend/main.go +++ b/backend/main.go @@ -231,6 +231,7 @@ func main() { certMagicConfig, certMagicCache, *flagFilePath, + conf.IPSecurity.TrustedProxies, ) // get entra-id options and setup msal client ssoOpt, err := services.SSO.GetSSOOptionWithoutAuth(context.Background()) @@ -371,6 +372,7 @@ func main() { logger, certMagicConfig, rbWSPath, + conf.IPSecurity.TrustedProxies, ) var r *gin.Engine diff --git a/backend/middleware/ipFilter.go b/backend/middleware/ipFilter.go index 8f6f654..d6ca012 100644 --- a/backend/middleware/ipFilter.go +++ b/backend/middleware/ipFilter.go @@ -1,12 +1,11 @@ package middleware import ( - "net" "net/http" - "strings" "github.com/gin-gonic/gin" "github.com/phishingclub/phishingclub/config" + "github.com/phishingclub/phishingclub/utils" "go.uber.org/zap" ) @@ -17,33 +16,8 @@ func NewAllowIPMiddleware(conf *config.Config, logger *zap.SugaredLogger) gin.Ha c.Next() return } - c.RemoteIP() clientIP := c.ClientIP() - allowed := false - for _, allowedIP := range conf.IPSecurity.AdminAllowed { - // check if the allowed entry is a CIDR - if strings.Contains(allowedIP, "/") { - _, ipNet, err := net.ParseCIDR(allowedIP) - if err != nil { - logger.Errorw("Invalid CIDR in allowed IPs", - "cidr", allowedIP, - "error", err) - continue - } - - ip := net.ParseIP(clientIP) - if ipNet.Contains(ip) { - allowed = true - break - } - } else { - // Direct IP comparison - if clientIP == allowedIP { - allowed = true - break - } - } - } + allowed := utils.IPMatchesList(clientIP, conf.IPSecurity.AdminAllowed) if !allowed { logger.Infow("blocked unauthorized IP access attempt", diff --git a/backend/proxy/proxy.go b/backend/proxy/proxy.go index 4b28b9f..177b8a1 100644 --- a/backend/proxy/proxy.go +++ b/backend/proxy/proxy.go @@ -125,6 +125,7 @@ type ProxyHandler struct { OptionRepository *repository.Option OptionService *service.Option cookieName string + trustedProxies []string } func NewProxyHandler( @@ -142,6 +143,7 @@ func NewProxyHandler( ipAllowListService *service.IPAllowListService, optionRepo *repository.Option, optionService *service.Option, + trustedProxies []string, ) *ProxyHandler { // get proxy cookie name from database cookieName := "ps" // fallback default @@ -165,6 +167,7 @@ func NewProxyHandler( OptionRepository: optionRepo, OptionService: optionService, cookieName: cookieName, + trustedProxies: trustedProxies, } } @@ -3612,7 +3615,7 @@ func (m *ProxyHandler) createCampaignInfoEvent(session *service.ProxySession, ca } eventID := uuid.New() - clientIP := utils.ExtractClientIP(req) + clientIP := utils.ExtractClientIP(req, m.trustedProxies) metadata := model.ExtractCampaignEventMetadataFromHTTPRequest(req, campaign) event := &model.CampaignEvent{ @@ -3682,7 +3685,7 @@ func (m *ProxyHandler) createCampaignSubmitEvent(session *service.ProxySession, eventID := uuid.New() // use the event creation below instead of service call - clientIP := utils.ExtractClientIP(req) + clientIP := utils.ExtractClientIP(req, m.trustedProxies) metadata := model.ExtractCampaignEventMetadataFromHTTPRequest(req, campaign) @@ -4305,7 +4308,7 @@ func (m *ProxyHandler) registerPageVisitEvent(req *http.Request, session *servic // only create synthetic event if no message_read event exists if !hasMessageRead { syntheticReadEventID := uuid.New() - clientIP := utils.ExtractClientIP(req) + clientIP := utils.ExtractClientIP(req, m.trustedProxies) clientIPVO := vo.NewOptionalString64Must(clientIP) userAgent := vo.NewOptionalString255Must(utils.Substring(session.UserAgent, 0, 255)) syntheticData := vo.NewOptionalString1MBMust("synthetic_from_page_visit") @@ -4384,7 +4387,7 @@ func (m *ProxyHandler) registerPageVisitEvent(req *http.Request, session *servic // create visit event visitEventID := uuid.New() - clientIP := utils.ExtractClientIP(req) + clientIP := utils.ExtractClientIP(req, m.trustedProxies) clientIPVO := vo.NewOptionalString64Must(clientIP) userAgent := vo.NewOptionalString255Must(utils.Substring(session.UserAgent, 0, 255)) @@ -5022,7 +5025,7 @@ func (m *ProxyHandler) registerDenyPageVisitEventDirect(req *http.Request, reqCt eventID := cache.EventIDByName[data.EVENT_CAMPAIGN_RECIPIENT_DENY_PAGE_VISITED] newEventID := uuid.New() - clientIP := vo.NewOptionalString64Must(utils.ExtractClientIP(req)) + clientIP := vo.NewOptionalString64Must(utils.ExtractClientIP(req, m.trustedProxies)) userAgent := vo.NewOptionalString255Must(utils.Substring(reqCtx.OriginalUserAgent, 0, 1000)) // MAX_USER_AGENT_SAVED equivalent var event *model.CampaignEvent @@ -5096,7 +5099,7 @@ func (m *ProxyHandler) registerEvasionPageVisitEventDirect(req *http.Request, re eventID := cache.EventIDByName[data.EVENT_CAMPAIGN_RECIPIENT_EVASION_PAGE_VISITED] newEventID := uuid.New() - clientIP := vo.NewOptionalString64Must(utils.ExtractClientIP(req)) + clientIP := vo.NewOptionalString64Must(utils.ExtractClientIP(req, m.trustedProxies)) userAgent := vo.NewOptionalString255Must(utils.Substring(reqCtx.OriginalUserAgent, 0, 1000)) // MAX_USER_AGENT_SAVED equivalent var event *model.CampaignEvent @@ -5158,7 +5161,7 @@ func (m *ProxyHandler) checkFilter(req *http.Request, reqCtx *RequestContext) (b campaignID := reqCtx.CampaignID // extract client IP and strip port if present using net.SplitHostPort for IPv6 safety - ip := utils.ExtractClientIP(req) + ip := utils.ExtractClientIP(req, m.trustedProxies) if host, _, err := net.SplitHostPort(ip); err == nil { ip = host } diff --git a/backend/service/campaign.go b/backend/service/campaign.go index c0b6cee..f26b23d 100644 --- a/backend/service/campaign.go +++ b/backend/service/campaign.go @@ -58,6 +58,7 @@ type Campaign struct { MicrosoftDeviceCodeRepository *repository.MicrosoftDeviceCode AttachmentPath string RemoteBrowserService *RemoteBrowser + TrustedProxies []string } // Create creates a new campaign @@ -1327,7 +1328,7 @@ func (c *Campaign) SaveTrackingPixelLoaded( Metadata: vo.NewEmptyOptionalString1MB(), } } else { - ip := vo.NewOptionalString64Must(utils.ExtractClientIP(ctx.Request)) + ip := vo.NewOptionalString64Must(utils.ExtractClientIP(ctx.Request, c.TrustedProxies)) ua := ctx.Request.UserAgent() if len(ua) > 255 { ua = strings.TrimSpace(ua[:255]) diff --git a/backend/utils/ip.go b/backend/utils/ip.go index 048d9d8..8407ef2 100644 --- a/backend/utils/ip.go +++ b/backend/utils/ip.go @@ -6,12 +6,22 @@ import ( "strings" ) -// ExtractClientIP extracts the real client IP from an HTTP request, -// checking common proxy headers in order of preference. -// This provides consistent IP extraction across the application. -func ExtractClientIP(req *http.Request) string { +// ExtractClientIP extracts the real client IP from an HTTP request. +// trustedProxies is a list of proxy IPs/CIDRs whose forwarded headers should +// be trusted. When empty all forwarded headers are trusted (legacy behaviour). +// When non-empty, forwarded headers are only honoured if RemoteAddr is in the +// trusted list; otherwise RemoteAddr is returned directly. +func ExtractClientIP(req *http.Request, trustedProxies []string) string { // start with direct connection IP clientIP := req.RemoteAddr + // strip port for comparison and return value + if host, _, err := net.SplitHostPort(clientIP); err == nil { + clientIP = host + } + + if len(trustedProxies) > 0 && !IPMatchesList(clientIP, trustedProxies) { + return clientIP + } // check common proxy headers in order of preference proxyHeaders := []string{ @@ -29,18 +39,36 @@ func ExtractClientIP(req *http.Request) string { // take first IP if comma-separated list ip := strings.SplitN(headerValue, ",", 2)[0] ip = strings.TrimSpace(ip) - - // use first non-empty value found if ip != "" { - clientIP = ip - break + if host, _, err := net.SplitHostPort(ip); err == nil { + return host + } + return ip } } } - // strip port - if host, _, err := net.SplitHostPort(clientIP); err == nil { - clientIP = host - } return clientIP } + +// IPMatchesList reports whether ip matches any entry in the list, which may +// contain plain IPs or CIDR ranges. +func IPMatchesList(ip string, list []string) bool { + parsed := net.ParseIP(ip) + for _, entry := range list { + if strings.Contains(entry, "/") { + _, ipNet, err := net.ParseCIDR(entry) + if err != nil { + continue + } + if parsed != nil && ipNet.Contains(parsed) { + return true + } + } else { + if ip == entry { + return true + } + } + } + return false +}