change access directive and add management for proxy allow list

Signed-off-by: Ronni Skansing <rskansing@gmail.com>
This commit is contained in:
Ronni Skansing
2025-10-23 12:55:51 +02:00
parent 847a3552b1
commit d6a717ebfa
16 changed files with 961 additions and 443 deletions
+6
View File
@@ -94,6 +94,9 @@ const (
ROUTE_V1_PROXY = "/api/v1/proxy"
ROUTE_V1_PROXY_OVERVIEW = "/api/v1/proxy/overview"
ROUTE_V1_PROXY_ID = "/api/v1/proxy/:id"
// ip allow list
ROUTE_V1_IP_ALLOW_LIST_PROXY_CONFIG = "/api/v1/ip-allow-list/proxy-config/:id"
ROUTE_V1_IP_ALLOW_LIST_CLEAR_PROXY_CONFIG = "/api/v1/ip-allow-list/clear-proxy-config/:id"
// recipient and groups
ROUTE_V1_RECIPIENT = "/api/v1/recipient"
ROUTE_V1_RECIPIENT_IMPORT = "/api/v1/recipient/import"
@@ -339,6 +342,9 @@ func setupRoutes(
POST(ROUTE_V1_PROXY, middleware.SessionHandler, controllers.Proxy.Create).
PATCH(ROUTE_V1_PROXY_ID, middleware.SessionHandler, controllers.Proxy.UpdateByID).
DELETE(ROUTE_V1_PROXY_ID, middleware.SessionHandler, controllers.Proxy.DeleteByID).
// ip allow list
GET(ROUTE_V1_IP_ALLOW_LIST_PROXY_CONFIG, middleware.SessionHandler, controllers.IPAllowList.GetEntriesForProxyConfig).
DELETE(ROUTE_V1_IP_ALLOW_LIST_CLEAR_PROXY_CONFIG, middleware.SessionHandler, controllers.IPAllowList.ClearForProxyConfig).
// smtp configuration
GET(ROUTE_V1_SMTP_CONFIGURATION, middleware.SessionHandler, controllers.SMTPConfiguration.GetAll).
GET(ROUTE_V1_SMTP_CONFIGURATION_ID, middleware.SessionHandler, controllers.SMTPConfiguration.GetByID).
+6
View File
@@ -36,6 +36,7 @@ type Controllers struct {
Update *controller.Update
Import *controller.Import
Backup *controller.Backup
IPAllowList *controller.IPAllowList
}
// NewControllers creates a collection of controllers
@@ -179,6 +180,10 @@ func NewControllers(
Common: common,
BackupService: services.Backup,
}
ipAllowList := &controller.IPAllowList{
Common: common,
IPAllowListService: services.IPAllowList,
}
return &Controllers{
Asset: asset,
@@ -209,5 +214,6 @@ func NewControllers(
Update: update,
Import: importController,
Backup: backup,
IPAllowList: ipAllowList,
}
}
+1 -7
View File
@@ -67,12 +67,6 @@ func NewServer(
logger *zap.SugaredLogger,
certMagicConfig *certmagic.Config,
) *Server {
// setup proxy cookie tracking
cookieName := ""
if option, err := repositories.Option.GetByKey(context.Background(), data.OptionKeyProxyCookieName); err == nil && option != nil {
cookieName = option.Value.String()
}
// setup goproxy-based proxy server
proxyServer := proxy.NewProxyHandler(
logger,
@@ -85,7 +79,7 @@ func NewServer(
repositories.Identifier,
services.Campaign,
services.Template,
cookieName,
services.IPAllowList,
)
// setup proxy session cleanup routine
+3
View File
@@ -36,6 +36,7 @@ type Services struct {
Update *service.Update
Import *service.Import
Backup *service.Backup
IPAllowList *service.IPAllowListService
}
// NewServices creates a collection of services
@@ -164,6 +165,7 @@ func NewServices(
CampaignTemplateService: campaignTemplate,
DomainService: domain,
}
ipAllowListService := service.NewIPAllowListService(logger, repositories.Proxy)
email := &service.Email{
Common: common,
AttachmentPath: attachmentPath,
@@ -272,5 +274,6 @@ func NewServices(
Update: updateService,
Import: importService,
Backup: backupService,
IPAllowList: ipAllowListService,
}
}
+71
View File
@@ -0,0 +1,71 @@
package controller
import (
"github.com/gin-gonic/gin"
"github.com/phishingclub/phishingclub/service"
)
// IPAllowList is the controller for IP allow list management
type IPAllowList struct {
Common
IPAllowListService *service.IPAllowListService
}
// GetEntriesForProxyConfig returns IP allow list entries for a specific proxy configuration
func (c *IPAllowList) GetEntriesForProxyConfig(g *gin.Context) {
// handle session
session, _, ok := c.handleSession(g)
if !ok {
return
}
// parse proxy config ID from URL params
proxyConfigID, ok := c.handleParseIDParam(g)
if !ok {
return
}
// get entries for proxy config
entries, err := c.IPAllowListService.GetEntriesForProxyConfig(
g.Request.Context(),
session,
proxyConfigID,
)
// handle response
if ok := c.handleErrors(g, err); !ok {
return
}
c.Response.OK(g, entries)
}
// ClearForProxyConfig removes all entries for a specific proxy configuration
func (c *IPAllowList) ClearForProxyConfig(g *gin.Context) {
// handle session
session, _, ok := c.handleSession(g)
if !ok {
return
}
// parse proxy config ID from URL params
proxyConfigID, ok := c.handleParseIDParam(g)
if !ok {
return
}
// clear entries for proxy config
count, err := c.IPAllowListService.ClearForProxyConfig(
g.Request.Context(),
session,
proxyConfigID,
)
// handle response
if ok := c.handleErrors(g, err); !ok {
return
}
c.Response.OK(g, map[string]interface{}{
"message": "Entries cleared for proxy configuration",
"cleared_count": count,
})
}
+4 -4
View File
@@ -31,7 +31,7 @@ type CampaignTemplate struct {
// landing page can also be a proxy
LandingProxyID *uuid.UUID `gorm:"type:uuid;index;"`
LandingProxy *Proxy `gorm:"foreignKey:LandingProxyID;references:ID;"`
LandingProxy *Proxy `gorm:"foreignKey:LandingProxyID;references:ID;"`
DomainID *uuid.UUID `gorm:"type:uuid;index;"`
Domain *Domain `gorm:"foreignKey:DomainID"`
@@ -48,16 +48,16 @@ type CampaignTemplate struct {
// before landing page can also be a proxy
BeforeLandingProxyID *uuid.UUID `gorm:"type:uuid;index"`
BeforeLandingProxy *Proxy `gorm:"foreignKey:BeforeLandingProxyID;references:ID"`
BeforeLandingProxy *Proxy `gorm:"foreignKey:BeforeLandingProxyID;references:ID"`
AfterLandingPageID *uuid.UUID `gorm:"type:uuid;index"`
AfterLandingPage *Page `gorm:"foreignKey:AfterLandingPageID;references:ID"`
// after landing page can also be a proxy
AfterLandingProxyID *uuid.UUID `gorm:"type:uuid;index"`
AfterLandingProxy *Proxy `gorm:"foreignKey:AfterLandingProxyID;references:ID"`
AfterLandingProxy *Proxy `gorm:"foreignKey:AfterLandingProxyID;references:ID"`
AfterLandingPageRedirectURL string `gorm:"not null;"`
AfterLandingPageRedirectURL string `gorm:"not null;default:'';"`
EmailID *uuid.UUID `gorm:"type:uuid;index;"`
Email *Email `gorm:"foreignKey:EmailID;references:ID;"`
+17 -7
View File
@@ -89,9 +89,7 @@ func (c *CampaignTemplate) Validate() error {
}
}
}
if err := validate.NullableFieldRequired("urlPath", c.URLPath); err != nil {
return err
}
// URLPath is optional, no validation needed
// validate that only one type is set per stage
// before landing page: can have neither (optional), or one type, but not both
@@ -198,9 +196,13 @@ func (c *CampaignTemplate) ToDBMap() map[string]any {
}
if c.AfterLandingPageRedirectURL.IsSpecified() {
if c.AfterLandingPageRedirectURL.IsNull() {
m["after_landing_page_redirect_url"] = nil
m["after_landing_page_redirect_url"] = ""
} else {
m["after_landing_page_redirect_url"] = c.AfterLandingPageRedirectURL.MustGet().String()
if v, err := c.AfterLandingPageRedirectURL.Get(); err == nil {
m["after_landing_page_redirect_url"] = v.String()
} else {
m["after_landing_page_redirect_url"] = ""
}
}
}
@@ -239,8 +241,16 @@ func (c *CampaignTemplate) ToDBMap() map[string]any {
if v, err := c.StateIdentifierID.Get(); err == nil {
m["state_identifier_id"] = v
}
if v, err := c.URLPath.Get(); err == nil {
m["url_path"] = v.String()
if c.URLPath.IsSpecified() {
if c.URLPath.IsNull() {
m["url_path"] = ""
} else {
if v, err := c.URLPath.Get(); err == nil {
m["url_path"] = v.String()
} else {
m["url_path"] = ""
}
}
}
_, errDomain := c.DomainID.Get()
+35 -93
View File
@@ -115,36 +115,36 @@ type ProxyHandler struct {
IdentifierRepository *repository.Identifier
CampaignService *service.Campaign
TemplateService *service.Template
IPAllowListService *service.IPAllowListService
cookieName string
ipAllowList sync.Map // map[string]int64 (ip+domain -> expiry timestamp)
}
func NewProxyHandler(
logger *zap.SugaredLogger,
pageRepository *repository.Page,
campaignRecipientRepository *repository.CampaignRecipient,
campaignRepository *repository.Campaign,
campaignTemplateRepository *repository.CampaignTemplate,
domainRepository *repository.Domain,
proxyRepository *repository.Proxy,
identifierRepository *repository.Identifier,
pageRepo *repository.Page,
campaignRecipientRepo *repository.CampaignRecipient,
campaignRepo *repository.Campaign,
campaignTemplateRepo *repository.CampaignTemplate,
domainRepo *repository.Domain,
proxyRepo *repository.Proxy,
identifierRepo *repository.Identifier,
campaignService *service.Campaign,
templateService *service.Template,
cookieName string,
ipAllowListService *service.IPAllowListService,
) *ProxyHandler {
return &ProxyHandler{
logger: logger,
sessions: sync.Map{},
PageRepository: pageRepository,
CampaignRecipientRepository: campaignRecipientRepository,
CampaignRepository: campaignRepository,
CampaignTemplateRepository: campaignTemplateRepository,
DomainRepository: domainRepository,
ProxyRepository: proxyRepository,
IdentifierRepository: identifierRepository,
PageRepository: pageRepo,
CampaignRecipientRepository: campaignRecipientRepo,
CampaignRepository: campaignRepo,
CampaignTemplateRepository: campaignTemplateRepo,
DomainRepository: domainRepo,
ProxyRepository: proxyRepo,
IdentifierRepository: identifierRepo,
CampaignService: campaignService,
TemplateService: templateService,
cookieName: cookieName,
IPAllowListService: ipAllowListService,
cookieName: "ps",
}
}
@@ -364,7 +364,10 @@ func (m *ProxyHandler) resolveSessionContext(req *http.Request, reqCtx *RequestC
m.registerPageVisitEvent(req, newSession)
// allow list IP for tunnel mode access
m.allowListIP(req, reqCtx.Domain.Name)
clientIP := m.getClientIP(req)
if clientIP != "" {
m.IPAllowListService.AddIP(clientIP, reqCtx.Domain.ProxyID.String(), 10*time.Minute)
}
} else {
// load existing session
sessionVal, exists := m.sessions.Load(reqCtx.SessionID)
@@ -2401,24 +2404,7 @@ func (m *ProxyHandler) CleanupExpiredSessions() {
}
// cleanup expired IP allow listed entries
ipCleanedCount := 0
currentTime := now.Unix()
m.ipAllowList.Range(func(key, value interface{}) bool {
expiry, ok := value.(int64)
if !ok {
m.ipAllowList.Delete(key)
ipCleanedCount++
return true
}
if currentTime >= expiry {
m.ipAllowList.Delete(key)
ipCleanedCount++
}
return true
})
ipCleanedCount := m.IPAllowListService.ClearExpired()
if ipCleanedCount > 0 {
m.logger.Debugw("cleaned up expired IP allow listed entries", "count", ipCleanedCount)
}
@@ -2561,9 +2547,12 @@ func (m *ProxyHandler) checkAccessRules(path string, accessControl *service.Prox
return true, ""
}
// check if IP is allowlisted for this domain (from previous lure access)
if reqCtx != nil && reqCtx.Domain != nil && req != nil && m.isIPAllowlistedForRequest(req, reqCtx.Domain.Name) {
return true, ""
// check if IP is allowlisted for this proxy config (from previous lure access)
if reqCtx != nil && reqCtx.Domain != nil && req != nil {
clientIP := m.getClientIP(req)
if clientIP != "" && m.IPAllowListService.IsIPAllowed(clientIP, reqCtx.Domain.ProxyID.String()) {
return true, ""
}
}
// no lure request and IP not allow listed - deny access
@@ -2580,65 +2569,18 @@ func (m *ProxyHandler) applyDefaultPrivateMode(reqCtx *RequestContext, req *http
return true, ""
}
// check if IP is allow listed for this domain (from previous lure access)
if reqCtx != nil && reqCtx.Domain != nil && req != nil && m.isIPAllowlistedForRequest(req, reqCtx.Domain.Name) {
return true, ""
// check if IP is allowlisted for this proxy config (from previous lure access)
if reqCtx != nil && reqCtx.Domain != nil && req != nil {
clientIP := m.getClientIP(req)
if clientIP != "" && m.IPAllowListService.IsIPAllowed(clientIP, reqCtx.Domain.ProxyID.String()) {
return true, ""
}
}
// no lure request and IP not allow listed - deny with default action
return false, "404"
}
// allowListIP adds an IP address to the allow list for private mode access
func (m *ProxyHandler) allowListIP(req *http.Request, domain string) {
clientIP := m.getClientIP(req)
if clientIP == "" {
return
}
key := clientIP + "-" + domain
// allowlisted for 10 minutes
expiry := time.Now().Add(10 * time.Minute).Unix()
m.ipAllowList.Store(key, expiry)
m.logger.Debugw("IP allow listed for private mode",
"ip", clientIP,
"domain", domain,
"expires_at", time.Unix(expiry, 0).Format(time.RFC3339),
)
}
// isIPAllowlistedForRequest checks if an IP is allowlisted for a specific domain
func (m *ProxyHandler) isIPAllowlistedForRequest(req *http.Request, domain string) bool {
clientIP := m.getClientIP(req)
if clientIP == "" {
return false
}
key := clientIP + "-" + domain
if expiryVal, exists := m.ipAllowList.Load(key); exists {
expiry := expiryVal.(int64)
if time.Now().Unix() < expiry {
m.logger.Debugw("IP found in allow list for private mode",
"ip", clientIP,
"domain", domain,
"expires_at", time.Unix(expiry, 0).Format(time.RFC3339),
)
return true
}
// expired, remove it
m.ipAllowList.Delete(key)
m.logger.Debugw("IP allow listed entry expired and removed",
"ip", clientIP,
"domain", domain,
)
}
return false
}
// getClientIP extracts the real client IP from request headers
func (m *ProxyHandler) getClientIP(req *http.Request) string {
// check common proxy headers first
+13 -3
View File
@@ -85,6 +85,10 @@ func (c *CampaignTemplate) Create(
if !campaignTemplate.URLPath.IsSpecified() || campaignTemplate.URLPath.IsNull() {
campaignTemplate.URLPath = nullable.NewNullableWithValue(*vo.NewURLPathMust(""))
}
// if no afterLandingPageRedirectURL set to ''
if !campaignTemplate.AfterLandingPageRedirectURL.IsSpecified() || campaignTemplate.AfterLandingPageRedirectURL.IsNull() {
campaignTemplate.AfterLandingPageRedirectURL = nullable.NewNullableWithValue(*vo.NewOptionalString255Must(""))
}
// validate
if err := campaignTemplate.Validate(); err != nil {
c.Logger.Errorw("failed to validate campaign template", "error", err)
@@ -636,7 +640,8 @@ func (c *CampaignTemplate) UpdateByID(
if v, err := campaignTemplate.AfterLandingPageRedirectURL.Get(); err == nil {
incoming.AfterLandingPageRedirectURL.Set(v)
} else {
incoming.AfterLandingPageRedirectURL.SetNull()
// if AfterLandingPageRedirectURL is null, set to empty string
incoming.AfterLandingPageRedirectURL.Set(*vo.NewOptionalString255Must(""))
}
}
if v, err := campaignTemplate.URLIdentifierID.Get(); err == nil {
@@ -645,8 +650,13 @@ func (c *CampaignTemplate) UpdateByID(
if v, err := campaignTemplate.StateIdentifierID.Get(); err == nil {
incoming.StateIdentifierID.Set(v)
}
if v, err := campaignTemplate.URLPath.Get(); err == nil {
incoming.URLPath.Set(v)
if campaignTemplate.URLPath.IsSpecified() {
if v, err := campaignTemplate.URLPath.Get(); err == nil {
incoming.URLPath.Set(v)
} else {
// if URLPath is null, set to empty string
incoming.URLPath.Set(*vo.NewURLPathMust(""))
}
}
// validate
if err := incoming.Validate(); err != nil {
+264
View File
@@ -0,0 +1,264 @@
package service
import (
"context"
"sync"
"time"
"github.com/go-errors/errors"
"github.com/google/uuid"
"github.com/phishingclub/phishingclub/data"
"github.com/phishingclub/phishingclub/errs"
"github.com/phishingclub/phishingclub/model"
"github.com/phishingclub/phishingclub/repository"
"go.uber.org/zap"
)
// IPAllowListEntry represents a single IP allow list entry
type IPAllowListEntry struct {
IP string `json:"ip"`
ProxyConfigID string `json:"proxyConfigID"`
ExpiresAt time.Time `json:"expiresAt"`
CreatedAt time.Time `json:"createdAt"`
}
// IPAllowListService manages IP allow listing for proxy configurations
type IPAllowListService struct {
Common
logger *zap.SugaredLogger
allowList sync.Map // map[string]int64 (ip+proxyConfigID -> expiry timestamp)
mu sync.RWMutex
cleanupDone chan bool
ProxyRepository *repository.Proxy
}
// NewIPAllowListService creates a new IP allow list service
func NewIPAllowListService(logger *zap.SugaredLogger, proxyRepo *repository.Proxy) *IPAllowListService {
common := Common{
Logger: logger,
}
service := &IPAllowListService{
Common: common,
logger: logger,
cleanupDone: make(chan bool),
ProxyRepository: proxyRepo,
}
// Start cleanup goroutine
go service.periodicCleanup()
return service
}
// AddIP adds an IP to the allow list for a specific proxy configuration
// must only be called internally and not exposed via. API
func (s *IPAllowListService) AddIP(ip string, proxyConfigID string, duration time.Duration) {
if ip == "" || proxyConfigID == "" {
return
}
key := ip + "-" + proxyConfigID
expiry := time.Now().Add(duration).Unix()
s.allowList.Store(key, expiry)
s.logger.Debugw("IP allow listed",
"ip", ip,
"proxy_config_id", proxyConfigID,
"expires_at", time.Unix(expiry, 0).Format(time.RFC3339),
)
}
// IsIPAllowed checks if an IP is allowed for a specific proxy configuration
// must only be called internally and not exposed via. API
func (s *IPAllowListService) IsIPAllowed(ip string, proxyConfigID string) bool {
if ip == "" || proxyConfigID == "" {
return false
}
key := ip + "-" + proxyConfigID
if expiryVal, exists := s.allowList.Load(key); exists {
expiry := expiryVal.(int64)
if time.Now().Unix() < expiry {
s.logger.Debugw("IP found in allow list",
"ip", ip,
"proxy_config_id", proxyConfigID,
"expires_at", time.Unix(expiry, 0).Format(time.RFC3339),
)
return true
}
// Expired, remove it
s.allowList.Delete(key)
s.logger.Debugw("IP allow list entry expired and removed",
"ip", ip,
"proxy_config_id", proxyConfigID,
)
}
return false
}
// GetEntriesForProxyConfig returns allow list entries for a specific proxy configuration
func (s *IPAllowListService) GetEntriesForProxyConfig(
ctx context.Context,
session *model.Session,
proxyConfigID *uuid.UUID,
) ([]IPAllowListEntry, error) {
ae := NewAuditEvent("IPAllowList.GetEntriesForProxyConfig", session)
// check permissions
isAuthorized, err := IsAuthorized(session, data.PERMISSION_ALLOW_GLOBAL)
if err != nil && !errors.Is(err, errs.ErrAuthorizationFailed) {
s.LogAuthError(err)
return nil, errs.Wrap(err)
}
if !isAuthorized {
s.AuditLogNotAuthorized(ae)
return nil, errs.ErrAuthorizationFailed
}
var entries []IPAllowListEntry
now := time.Now()
proxyConfigIDStr := proxyConfigID.String()
s.allowList.Range(func(key, value interface{}) bool {
keyStr := key.(string)
expiry := value.(int64)
expiryTime := time.Unix(expiry, 0)
// Skip expired entries
if now.Unix() >= expiry {
s.allowList.Delete(key)
return true
}
// Parse key to extract IP and proxy config ID
parts := parseAllowListKey(keyStr)
if len(parts) == 2 && parts[1] == proxyConfigIDStr {
entries = append(entries, IPAllowListEntry{
IP: parts[0],
ProxyConfigID: parts[1],
ExpiresAt: expiryTime,
CreatedAt: expiryTime.Add(-10 * time.Minute), // Assume 10 minute duration
})
}
return true
})
ae.Details["proxy_config_id"] = proxyConfigID.String()
if userId, err := session.User.ID.Get(); err == nil {
ae.Details["user_id"] = userId.String()
}
return entries, nil
}
// ClearExpired removes all expired entries from the allow list
func (s *IPAllowListService) ClearExpired() int {
count := 0
now := time.Now().Unix()
s.allowList.Range(func(key, value interface{}) bool {
expiry := value.(int64)
if now >= expiry {
s.allowList.Delete(key)
count++
}
return true
})
if count > 0 {
s.logger.Debugw("Cleaned up expired IP allow list entries", "count", count)
}
return count
}
// ClearForProxyConfig removes all entries for a specific proxy configuration
func (s *IPAllowListService) ClearForProxyConfig(
ctx context.Context,
session *model.Session,
proxyConfigID *uuid.UUID,
) (int, error) {
ae := NewAuditEvent("IPAllowList.ClearForProxyConfig", session)
// check permissions
isAuthorized, err := IsAuthorized(session, data.PERMISSION_ALLOW_GLOBAL)
if err != nil && !errors.Is(err, errs.ErrAuthorizationFailed) {
s.LogAuthError(err)
return 0, errs.Wrap(err)
}
if !isAuthorized {
s.AuditLogNotAuthorized(ae)
return 0, errs.ErrAuthorizationFailed
}
count := 0
proxyConfigIDStr := proxyConfigID.String()
s.allowList.Range(func(key, value interface{}) bool {
keyStr := key.(string)
parts := parseAllowListKey(keyStr)
if len(parts) == 2 && parts[1] == proxyConfigIDStr {
s.allowList.Delete(key)
count++
}
return true
})
ae.Details["proxy_config_id"] = proxyConfigID.String()
if userId, err := session.User.ID.Get(); err == nil {
ae.Details["user_id"] = userId.String()
}
return count, nil
}
// periodicCleanup runs periodic cleanup of expired entries
func (s *IPAllowListService) periodicCleanup() {
ticker := time.NewTicker(5 * time.Minute) // Clean up every 5 minutes
defer ticker.Stop()
for {
select {
case <-ticker.C:
s.ClearExpired()
case <-s.cleanupDone:
return
}
}
}
// Stop stops the background cleanup goroutine
func (s *IPAllowListService) Stop() {
close(s.cleanupDone)
}
// parseAllowListKey parses the allow list key format "ip-proxyConfigID"
func parseAllowListKey(key string) []string {
// Find the last occurrence of "-" to handle IPv6 addresses
lastIndex := -1
for i := len(key) - 1; i >= 0; i-- {
if key[i] == '-' {
// Check if this looks like a UUID separator (36 chars after this point)
remaining := key[i+1:]
if len(remaining) == 36 {
// Validate it looks like a UUID
if _, err := uuid.Parse(remaining); err == nil {
lastIndex = i
break
}
}
}
}
if lastIndex == -1 {
return []string{key} // Fallback if parsing fails
}
ip := key[:lastIndex]
proxyConfigID := key[lastIndex+1:]
return []string{ip, proxyConfigID}
}