mirror of
https://github.com/phishingclub/phishingclub.git
synced 2026-07-04 11:27:57 +02:00
Merge branch 'fix-change-access-directive' into develop
This commit is contained in:
@@ -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"
|
||||
@@ -343,6 +346,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).
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
+126
-75
@@ -115,35 +115,36 @@ type ProxyHandler struct {
|
||||
IdentifierRepository *repository.Identifier
|
||||
CampaignService *service.Campaign
|
||||
TemplateService *service.Template
|
||||
IPAllowListService *service.IPAllowListService
|
||||
cookieName string
|
||||
}
|
||||
|
||||
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",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -258,16 +259,14 @@ func (m *ProxyHandler) initializeRequestContext(ctx context.Context, req *http.R
|
||||
// check for campaign recipient id
|
||||
campaignRecipientID, paramName := m.getCampaignRecipientIDFromURLParams(req)
|
||||
|
||||
reqCtx := &RequestContext{
|
||||
return &RequestContext{
|
||||
PhishDomain: req.Host,
|
||||
TargetDomain: targetDomain,
|
||||
Domain: domain,
|
||||
ProxyConfig: proxyConfig,
|
||||
CampaignRecipientID: campaignRecipientID,
|
||||
ParamName: paramName,
|
||||
}
|
||||
|
||||
return reqCtx, nil
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (m *ProxyHandler) processRequestWithContext(req *http.Request, reqCtx *RequestContext) (*http.Request, *http.Response) {
|
||||
@@ -314,7 +313,7 @@ func (m *ProxyHandler) processRequestWithContext(req *http.Request, reqCtx *Requ
|
||||
// check access control before proceeding
|
||||
hasSession := reqCtx.SessionID != ""
|
||||
|
||||
if allowed, denyAction := m.evaluatePathAccess(req.URL.Path, reqCtx, hasSession); !allowed {
|
||||
if allowed, denyAction := m.evaluatePathAccess(req.URL.Path, reqCtx, hasSession, req); !allowed {
|
||||
return req, m.createDenyResponse(req, reqCtx, denyAction, hasSession)
|
||||
}
|
||||
|
||||
@@ -363,6 +362,12 @@ func (m *ProxyHandler) resolveSessionContext(req *http.Request, reqCtx *RequestC
|
||||
|
||||
// register page visit event for MITM landing
|
||||
m.registerPageVisitEvent(req, newSession)
|
||||
|
||||
// allow list IP for tunnel mode access
|
||||
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)
|
||||
@@ -2394,6 +2399,15 @@ func (m *ProxyHandler) CleanupExpiredSessions() {
|
||||
return true
|
||||
})
|
||||
|
||||
if cleanedCount > 0 {
|
||||
m.logger.Debugw("cleaned up expired sessions", "count", cleanedCount)
|
||||
}
|
||||
|
||||
// cleanup expired IP allow listed entries
|
||||
ipCleanedCount := m.IPAllowListService.ClearExpired()
|
||||
if ipCleanedCount > 0 {
|
||||
m.logger.Debugw("cleaned up expired IP allow listed entries", "count", ipCleanedCount)
|
||||
}
|
||||
}
|
||||
|
||||
func (m *ProxyHandler) getTargetDomainForPhishingDomain(phishingDomain string) (string, error) {
|
||||
@@ -2482,92 +2496,123 @@ func (m *ProxyHandler) writeResponse(w http.ResponseWriter, resp *http.Response)
|
||||
}
|
||||
|
||||
// evaluatePathAccess checks if a path is allowed based on access control rules
|
||||
func (m *ProxyHandler) evaluatePathAccess(path string, reqCtx *RequestContext, hasSession bool) (bool, string) {
|
||||
// check for nil request context
|
||||
if reqCtx == nil {
|
||||
m.logger.Errorw("request context is nil in evaluatePathAccess")
|
||||
return true, "" // default allow to prevent panic
|
||||
}
|
||||
|
||||
func (m *ProxyHandler) evaluatePathAccess(path string, reqCtx *RequestContext, hasSession bool, req *http.Request) (bool, string) {
|
||||
// check domain-specific rules first
|
||||
if reqCtx.Domain != nil && reqCtx.ProxyConfig != nil && reqCtx.ProxyConfig.Hosts != nil {
|
||||
|
||||
// find the domain config where the "to" field matches our phishing domain
|
||||
for _, domainConfig := range reqCtx.ProxyConfig.Hosts {
|
||||
if domainConfig != nil && domainConfig.To == reqCtx.PhishDomain && domainConfig.Access != nil {
|
||||
allowed, action := m.checkAccessRules(path, domainConfig.Access, hasSession)
|
||||
// domain rule found - return its decision (allow or deny)
|
||||
return allowed, action
|
||||
if domainConfig != nil && domainConfig.To == reqCtx.PhishDomain {
|
||||
if domainConfig.Access != nil {
|
||||
allowed, action := m.checkAccessRules(path, domainConfig.Access, hasSession, reqCtx, req)
|
||||
// domain rule found - return its decision (allow or deny)
|
||||
return allowed, action
|
||||
}
|
||||
// domain found but no access section - fall through to check global rules
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// no domain rule found - check global rules
|
||||
// check global rules (either no domain found, or domain found but no access section)
|
||||
if reqCtx.ProxyConfig != nil && reqCtx.ProxyConfig.Global != nil && reqCtx.ProxyConfig.Global.Access != nil {
|
||||
|
||||
if allowed, action := m.checkAccessRules(path, reqCtx.ProxyConfig.Global.Access, hasSession); !allowed {
|
||||
|
||||
return false, action
|
||||
}
|
||||
allowed, action := m.checkAccessRules(path, reqCtx.ProxyConfig.Global.Access, hasSession, reqCtx, req)
|
||||
return allowed, action
|
||||
}
|
||||
|
||||
// default allow if no rules match
|
||||
return true, ""
|
||||
// no configuration at all - use private mode default
|
||||
return m.applyDefaultPrivateMode(reqCtx, req)
|
||||
}
|
||||
|
||||
// checkAccessRules evaluates access control rules for a given path
|
||||
func (m *ProxyHandler) checkAccessRules(path string, accessControl *service.ProxyServiceAccessControl, hasSession bool) (bool, string) {
|
||||
func (m *ProxyHandler) checkAccessRules(path string, accessControl *service.ProxyServiceAccessControl, hasSession bool, reqCtx *RequestContext, req *http.Request) (bool, string) {
|
||||
if accessControl == nil {
|
||||
return true, "" // no access control = allow everything
|
||||
}
|
||||
|
||||
matches := m.matchesAnyAccessPath(path, accessControl.Paths)
|
||||
|
||||
var action string
|
||||
if hasSession {
|
||||
action = accessControl.OnDeny.WithSession
|
||||
} else {
|
||||
action = accessControl.OnDeny.WithoutSession
|
||||
}
|
||||
|
||||
// default actions if not specified
|
||||
action := accessControl.OnDeny
|
||||
if action == "" {
|
||||
action = "404"
|
||||
action = "404" // default action
|
||||
}
|
||||
|
||||
switch accessControl.Mode {
|
||||
case "allow":
|
||||
if matches {
|
||||
return true, "" // path matches allow list
|
||||
case "public":
|
||||
return true, "" // allow all traffic (traditional proxy mode)
|
||||
case "private":
|
||||
// private mode: strict access control like evilginx2
|
||||
|
||||
// if this is a lure request (has campaign recipient id), allow it
|
||||
if reqCtx != nil && reqCtx.CampaignRecipientID != nil {
|
||||
return true, ""
|
||||
}
|
||||
// path doesn't match allow list - check if we should allow anyway
|
||||
if action == "allow" {
|
||||
return true, "" // override deny with allow
|
||||
|
||||
// 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, ""
|
||||
}
|
||||
}
|
||||
return false, action // deny with specified action
|
||||
case "deny":
|
||||
if !matches {
|
||||
return true, "" // path doesn't match deny list
|
||||
}
|
||||
// path matches deny list - check if we should allow anyway
|
||||
if action == "allow" {
|
||||
return true, "" // override deny with allow
|
||||
}
|
||||
return false, action // deny with specified action
|
||||
|
||||
// no lure request and IP not allow listed - deny access
|
||||
return false, action
|
||||
default:
|
||||
return true, "" // safe default
|
||||
}
|
||||
}
|
||||
|
||||
// matchesAnyAccessPath checks if a path matches any of the provided regex patterns
|
||||
func (m *ProxyHandler) matchesAnyAccessPath(path string, patterns []string) bool {
|
||||
for _, pattern := range patterns {
|
||||
if matched, err := regexp.MatchString(pattern, path); err == nil && matched {
|
||||
return true
|
||||
// applyDefaultPrivateMode applies private mode behavior when no access control is specified
|
||||
func (m *ProxyHandler) applyDefaultPrivateMode(reqCtx *RequestContext, req *http.Request) (bool, string) {
|
||||
// if this is a lure request (has campaign recipient id), allow it
|
||||
if reqCtx != nil && reqCtx.CampaignRecipientID != nil {
|
||||
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, ""
|
||||
}
|
||||
}
|
||||
return false
|
||||
|
||||
// no lure request and IP not allow listed - deny with default action
|
||||
return false, "404"
|
||||
}
|
||||
|
||||
// getClientIP extracts the real client IP from request headers
|
||||
func (m *ProxyHandler) getClientIP(req *http.Request) string {
|
||||
// check common proxy headers first
|
||||
proxyHeaders := []string{
|
||||
"X-Forwarded-For",
|
||||
"X-Real-IP",
|
||||
"X-Client-IP",
|
||||
"CF-Connecting-IP",
|
||||
"True-Client-IP",
|
||||
}
|
||||
|
||||
for _, header := range proxyHeaders {
|
||||
ip := req.Header.Get(header)
|
||||
if ip != "" {
|
||||
// X-Forwarded-For can contain multiple IPs, take the first
|
||||
if strings.Contains(ip, ",") {
|
||||
ip = strings.TrimSpace(strings.Split(ip, ",")[0])
|
||||
}
|
||||
return ip
|
||||
}
|
||||
}
|
||||
|
||||
// fallback to remote addr
|
||||
if req.RemoteAddr != "" {
|
||||
ip, _, err := net.SplitHostPort(req.RemoteAddr)
|
||||
if err != nil {
|
||||
return req.RemoteAddr // might not have port
|
||||
}
|
||||
return ip
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
// createDenyResponse creates an appropriate response for denied access
|
||||
@@ -2586,6 +2631,12 @@ func (m *ProxyHandler) createDenyResponse(req *http.Request, reqCtx *RequestCont
|
||||
"user_agent", req.Header.Get("User-Agent"),
|
||||
)
|
||||
|
||||
// auto-detect URLs for redirect (no prefix needed)
|
||||
if strings.HasPrefix(denyAction, "http://") || strings.HasPrefix(denyAction, "https://") {
|
||||
return m.createRedirectResponse(denyAction)
|
||||
}
|
||||
|
||||
// backwards compatibility for old redirect: syntax
|
||||
if strings.HasPrefix(denyAction, "redirect:") {
|
||||
url := strings.TrimPrefix(denyAction, "redirect:")
|
||||
return m.createRedirectResponse(url)
|
||||
|
||||
@@ -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}
|
||||
}
|
||||
+45
-54
@@ -58,16 +58,13 @@ type ProxyServiceRules struct {
|
||||
|
||||
// ProxyServiceAccessControl represents access control configuration
|
||||
type ProxyServiceAccessControl struct {
|
||||
Mode string `yaml:"mode"` // "allow" | "deny"
|
||||
Paths []string `yaml:"paths"`
|
||||
OnDeny ProxyServiceDenyResponse `yaml:"on_deny"`
|
||||
Mode string `yaml:"mode"` // "public" | "private"
|
||||
OnDeny string `yaml:"on_deny,omitempty"` // "404" | "redirect:URL" | status code (only used for private mode)
|
||||
}
|
||||
|
||||
// ProxyServiceDenyResponse represents response configuration when access is denied
|
||||
type ProxyServiceDenyResponse struct {
|
||||
WithSession string `yaml:"with_session"` // "allow" | "redirect:URL" | status code
|
||||
WithoutSession string `yaml:"without_session"` // "allow" | "redirect:URL" | status code
|
||||
}
|
||||
// Access control modes:
|
||||
// - "public": Allow all traffic (traditional proxy mode) - on_deny is ignored
|
||||
// - "private": Strict IP-based mode like evilginx2 - whitelist IP after lure access, deny all others (DEFAULT)
|
||||
|
||||
// CompilePathPatterns compiles regex patterns for all capture and response rules
|
||||
func CompilePathPatterns(config *ProxyServiceConfigYAML) error {
|
||||
@@ -254,15 +251,7 @@ type ProxyServiceResponseRule struct {
|
||||
// version: "0.0"
|
||||
// global:
|
||||
//
|
||||
// access:
|
||||
// mode: "deny"
|
||||
// paths:
|
||||
// - "^/admin/"
|
||||
// - "^/wp-admin/"
|
||||
// - "^/\\.git/"
|
||||
// on_deny:
|
||||
// with_session: 403
|
||||
// without_session: 404
|
||||
// # No access section = private mode by default (secure by default)
|
||||
// capture:
|
||||
// - name: "global_navigation"
|
||||
// path: "/important"
|
||||
@@ -276,15 +265,14 @@ type ProxyServiceResponseRule struct {
|
||||
// example.com:
|
||||
//
|
||||
// to: "phishing-example.com"
|
||||
// access:
|
||||
// mode: "allow"
|
||||
// paths:
|
||||
// - "^/login"
|
||||
// - "^/api/public/"
|
||||
// - "^/assets/"
|
||||
// on_deny:
|
||||
// with_session: "redirect:https://phishing-example.com/"
|
||||
// without_session: 503
|
||||
// # No access section = private mode by default (secure by default)
|
||||
// # To override with public mode or custom deny action:
|
||||
// # access:
|
||||
// # mode: "public" # Traditional proxy mode
|
||||
// # Or:
|
||||
// # access:
|
||||
// # mode: "private"
|
||||
// # on_deny: "https://example.com" # Clean redirect syntax
|
||||
// response:
|
||||
// - path: "^/robots\\.txt$"
|
||||
// headers:
|
||||
@@ -1112,46 +1100,38 @@ func (m *Proxy) validateReplaceRules(replaceRules []ProxyServiceReplaceRule) err
|
||||
// validateAccessControl validates access control configuration
|
||||
func (m *Proxy) validateAccessControl(accessControl *ProxyServiceAccessControl) error {
|
||||
if accessControl == nil {
|
||||
return nil // access control is optional
|
||||
return nil // access control will be set to defaults
|
||||
}
|
||||
|
||||
// set default mode if empty
|
||||
if accessControl.Mode == "" {
|
||||
accessControl.Mode = "private"
|
||||
}
|
||||
|
||||
// validate mode
|
||||
if accessControl.Mode != "allow" && accessControl.Mode != "deny" {
|
||||
if accessControl.Mode != "public" && accessControl.Mode != "private" {
|
||||
return validate.WrapErrorWithField(
|
||||
errors.New("access control mode must be either 'allow' or 'deny'"),
|
||||
errors.New("access control mode must be either 'public' or 'private' - private mode uses IP whitelisting like evilginx2"),
|
||||
"proxyConfig",
|
||||
)
|
||||
}
|
||||
|
||||
// validate paths are valid regex patterns
|
||||
for i, path := range accessControl.Paths {
|
||||
if path == "" {
|
||||
return validate.WrapErrorWithField(
|
||||
errors.New(fmt.Sprintf("access control path %d cannot be empty", i)),
|
||||
"proxyConfig",
|
||||
)
|
||||
// validate deny action (only required for private mode)
|
||||
if accessControl.Mode == "private" {
|
||||
// set default deny action if empty
|
||||
if accessControl.OnDeny == "" {
|
||||
accessControl.OnDeny = "404"
|
||||
}
|
||||
if _, err := regexp.Compile(path); err != nil {
|
||||
return validate.WrapErrorWithField(
|
||||
errors.New(fmt.Sprintf("invalid regex pattern in access control path '%s': %s", path, err.Error())),
|
||||
"proxyConfig",
|
||||
)
|
||||
if err := m.validateDenyAction(accessControl.OnDeny); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// validate deny response actions
|
||||
if err := m.validateDenyAction(accessControl.OnDeny.WithSession, true); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := m.validateDenyAction(accessControl.OnDeny.WithoutSession, false); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// validateDenyAction validates a deny action string
|
||||
func (m *Proxy) validateDenyAction(action string, withSession bool) error {
|
||||
func (m *Proxy) validateDenyAction(action string) error {
|
||||
if action == "" {
|
||||
return nil // action is optional, will use default
|
||||
}
|
||||
@@ -1161,16 +1141,27 @@ func (m *Proxy) validateDenyAction(action string, withSession bool) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// check for redirect action
|
||||
// check for redirect action (auto-detect URLs or old redirect: syntax)
|
||||
if strings.HasPrefix(action, "http://") || strings.HasPrefix(action, "https://") {
|
||||
if len(action) < 10 { // minimum valid URL length
|
||||
return validate.WrapErrorWithField(
|
||||
errors.New("redirect URL is too short, must be a valid URL like 'https://example.com'"),
|
||||
"proxyConfig",
|
||||
)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// check for old redirect: syntax (backwards compatibility)
|
||||
if strings.HasPrefix(action, "redirect:") {
|
||||
url := strings.TrimPrefix(action, "redirect:")
|
||||
if url == "" {
|
||||
return validate.WrapErrorWithField(
|
||||
errors.New("redirect action must include URL: 'redirect:https://example.com'"),
|
||||
errors.New("redirect action must include URL: 'redirect:https://example.com' or just 'https://example.com'"),
|
||||
"proxyConfig",
|
||||
)
|
||||
}
|
||||
// basic URL validation
|
||||
// basic URL validation for old syntax
|
||||
if !strings.HasPrefix(url, "http://") && !strings.HasPrefix(url, "https://") {
|
||||
return validate.WrapErrorWithField(
|
||||
errors.New("redirect URL must start with http:// or https://"),
|
||||
@@ -1192,7 +1183,7 @@ func (m *Proxy) validateDenyAction(action string, withSession bool) error {
|
||||
}
|
||||
|
||||
return validate.WrapErrorWithField(
|
||||
errors.New("invalid deny action: must be 'allow', 'redirect:URL', or a status code"),
|
||||
errors.New("deny action must be a valid HTTP status code (e.g., '404') or redirect URL (e.g., 'https://example.com')"),
|
||||
"proxyConfig",
|
||||
)
|
||||
}
|
||||
|
||||
@@ -2961,6 +2961,31 @@ export class API {
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* ipAllowList is the API for IP Allow List related operations.
|
||||
*/
|
||||
ipAllowList = {
|
||||
/**
|
||||
* Get IP allow list entries for a specific proxy configuration.
|
||||
*
|
||||
* @param {string} proxyConfigID
|
||||
* @returns {Promise<ApiResponse>}
|
||||
*/
|
||||
getForProxyConfig: async (proxyConfigID) => {
|
||||
return await getJSON(this.getPath(`/ip-allow-list/proxy-config/${proxyConfigID}`));
|
||||
},
|
||||
|
||||
/**
|
||||
* Clear all entries for a specific proxy configuration.
|
||||
*
|
||||
* @param {string} proxyConfigID
|
||||
* @returns {Promise<ApiResponse>}
|
||||
*/
|
||||
clearForProxyConfig: async (proxyConfigID) => {
|
||||
return await deleteJSON(this.getPath(`/ip-allow-list/clear-proxy-config/${proxyConfigID}`));
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* import is for importing assets, landing pages and etc
|
||||
*/
|
||||
|
||||
@@ -96,16 +96,13 @@ export class ProxyYamlCompletionProvider {
|
||||
if (linePrefix.match(/\s*method:\s*$/)) {
|
||||
return this.getMethodSuggestions(range);
|
||||
}
|
||||
if (linePrefix.match(/\s*(with_session|without_session):\s*$/)) {
|
||||
return this.getActionSuggestions(range);
|
||||
if (linePrefix.match(/\s*on_deny:\s*$/)) {
|
||||
return this.getOnDenySuggestions(range);
|
||||
}
|
||||
|
||||
// Handle array items
|
||||
if (linePrefix.match(/^\s*-\s*$/)) {
|
||||
const context = this.findParentSection(linesAbove, currentIndent);
|
||||
if (context === 'paths') {
|
||||
return this.getPathPatternSuggestions(range);
|
||||
}
|
||||
if (context === 'capture') {
|
||||
return this.getNewCaptureSuggestions(range);
|
||||
}
|
||||
@@ -131,8 +128,6 @@ export class ProxyYamlCompletionProvider {
|
||||
return this.getDomainSuggestions(range);
|
||||
case 'access':
|
||||
return this.getAccessSuggestions(range);
|
||||
case 'on_deny':
|
||||
return this.getOnDenySuggestions(range);
|
||||
case 'capture':
|
||||
return this.getCaptureSuggestions(range);
|
||||
case 'rewrite':
|
||||
@@ -253,7 +248,7 @@ export class ProxyYamlCompletionProvider {
|
||||
label: 'access',
|
||||
kind: this.monaco.languages.CompletionItemKind.Module,
|
||||
insertText: 'access:',
|
||||
documentation: 'Domain access control',
|
||||
documentation: 'Domain access control (optional - defaults to secure private mode)',
|
||||
range
|
||||
},
|
||||
{
|
||||
@@ -285,22 +280,17 @@ export class ProxyYamlCompletionProvider {
|
||||
{
|
||||
label: 'mode',
|
||||
kind: this.monaco.languages.CompletionItemKind.Property,
|
||||
insertText: 'mode: "allow"',
|
||||
documentation: 'Access control mode: allow or deny',
|
||||
range
|
||||
},
|
||||
{
|
||||
label: 'paths',
|
||||
kind: this.monaco.languages.CompletionItemKind.Property,
|
||||
insertText: 'paths:',
|
||||
documentation: 'Array of path patterns',
|
||||
insertText: 'mode: "private"',
|
||||
documentation:
|
||||
'Access control mode: public (allow all) or private (IP whitelist after lure). Default: private',
|
||||
range
|
||||
},
|
||||
{
|
||||
label: 'on_deny',
|
||||
kind: this.monaco.languages.CompletionItemKind.Module,
|
||||
insertText: 'on_deny:',
|
||||
documentation: 'Response when access denied',
|
||||
kind: this.monaco.languages.CompletionItemKind.Property,
|
||||
insertText: 'on_deny: "404"',
|
||||
documentation:
|
||||
'Response for blocked requests in private mode (e.g., "404", "https://example.com")',
|
||||
range
|
||||
}
|
||||
];
|
||||
@@ -309,17 +299,24 @@ export class ProxyYamlCompletionProvider {
|
||||
getOnDenySuggestions(range) {
|
||||
return [
|
||||
{
|
||||
label: 'with_session',
|
||||
kind: this.monaco.languages.CompletionItemKind.Property,
|
||||
insertText: 'with_session: 403',
|
||||
documentation: 'Response for users with sessions',
|
||||
label: '"404"',
|
||||
kind: this.monaco.languages.CompletionItemKind.Value,
|
||||
insertText: '"404"',
|
||||
documentation: 'Return 404 Not Found status',
|
||||
range
|
||||
},
|
||||
{
|
||||
label: 'without_session',
|
||||
kind: this.monaco.languages.CompletionItemKind.Property,
|
||||
insertText: 'without_session: 404',
|
||||
documentation: 'Response for users without sessions',
|
||||
label: '"403"',
|
||||
kind: this.monaco.languages.CompletionItemKind.Value,
|
||||
insertText: '"403"',
|
||||
documentation: 'Return 403 Forbidden status',
|
||||
range
|
||||
},
|
||||
{
|
||||
label: '"https://example.com"',
|
||||
kind: this.monaco.languages.CompletionItemKind.Value,
|
||||
insertText: '"https://example.com"',
|
||||
documentation: 'Redirect to specified URL (auto-detected)',
|
||||
range
|
||||
}
|
||||
];
|
||||
@@ -573,17 +570,17 @@ export class ProxyYamlCompletionProvider {
|
||||
getModeSuggestions(range) {
|
||||
return [
|
||||
{
|
||||
label: '"allow"',
|
||||
label: '"private"',
|
||||
kind: this.monaco.languages.CompletionItemKind.Value,
|
||||
insertText: '"allow"',
|
||||
documentation: 'Allowlist mode - only specified paths allowed',
|
||||
insertText: '"private"',
|
||||
documentation: 'Private mode - IP-based whitelist after lure access (DEFAULT, secure)',
|
||||
range
|
||||
},
|
||||
{
|
||||
label: '"deny"',
|
||||
label: '"public"',
|
||||
kind: this.monaco.languages.CompletionItemKind.Value,
|
||||
insertText: '"deny"',
|
||||
documentation: 'Denylist mode - specified paths blocked',
|
||||
insertText: '"public"',
|
||||
documentation: 'Public mode - allow all traffic (traditional proxy behavior)',
|
||||
range
|
||||
}
|
||||
];
|
||||
@@ -782,86 +779,6 @@ export class ProxyYamlCompletionProvider {
|
||||
];
|
||||
}
|
||||
|
||||
getActionSuggestions(range) {
|
||||
return [
|
||||
{
|
||||
label: '"allow"',
|
||||
kind: this.monaco.languages.CompletionItemKind.Value,
|
||||
insertText: '"allow"',
|
||||
documentation: 'Allow access (override deny)',
|
||||
range
|
||||
},
|
||||
{
|
||||
label: '"redirect:https://example.com"',
|
||||
kind: this.monaco.languages.CompletionItemKind.Value,
|
||||
insertText: '"redirect:https://example.com"',
|
||||
documentation: 'Redirect to URL',
|
||||
range
|
||||
},
|
||||
{
|
||||
label: '404',
|
||||
kind: this.monaco.languages.CompletionItemKind.Value,
|
||||
insertText: '404',
|
||||
documentation: 'Return 404 Not Found',
|
||||
range
|
||||
},
|
||||
{
|
||||
label: '403',
|
||||
kind: this.monaco.languages.CompletionItemKind.Value,
|
||||
insertText: '403',
|
||||
documentation: 'Return 403 Forbidden',
|
||||
range
|
||||
},
|
||||
{
|
||||
label: '503',
|
||||
kind: this.monaco.languages.CompletionItemKind.Value,
|
||||
insertText: '503',
|
||||
documentation: 'Return 503 Service Unavailable',
|
||||
range
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
getPathPatternSuggestions(range) {
|
||||
return [
|
||||
{
|
||||
label: '"^/admin/"',
|
||||
kind: this.monaco.languages.CompletionItemKind.Value,
|
||||
insertText: '"^/admin/"',
|
||||
documentation: 'Admin panel paths',
|
||||
range
|
||||
},
|
||||
{
|
||||
label: '"^/login"',
|
||||
kind: this.monaco.languages.CompletionItemKind.Value,
|
||||
insertText: '"^/login"',
|
||||
documentation: 'Login page',
|
||||
range
|
||||
},
|
||||
{
|
||||
label: '"^/api/"',
|
||||
kind: this.monaco.languages.CompletionItemKind.Value,
|
||||
insertText: '"^/api/"',
|
||||
documentation: 'API endpoints',
|
||||
range
|
||||
},
|
||||
{
|
||||
label: '"^/assets/"',
|
||||
kind: this.monaco.languages.CompletionItemKind.Value,
|
||||
insertText: '"^/assets/"',
|
||||
documentation: 'Static assets',
|
||||
range
|
||||
},
|
||||
{
|
||||
label: '"^/\\.git/"',
|
||||
kind: this.monaco.languages.CompletionItemKind.Value,
|
||||
insertText: '"^/\\.git/"',
|
||||
documentation: 'Git repository',
|
||||
range
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
provideHover(model, position) {
|
||||
const word = model.getWordAtPosition(position);
|
||||
if (!word) return null;
|
||||
@@ -884,12 +801,10 @@ export class ProxyYamlCompletionProvider {
|
||||
const hoverData = {
|
||||
version: 'Configuration version. Currently supports "0.0"',
|
||||
global: 'Rules that apply to all domain mappings',
|
||||
access: 'Access control configuration - restricts which paths are accessible',
|
||||
mode: 'Access control mode: "allow" (allowlist) or "deny" (denylist)',
|
||||
paths: 'Array of regex patterns for path matching',
|
||||
on_deny: 'Response configuration when access is denied',
|
||||
with_session: 'Response for users with active proxy sessions (request with mitm cookie)',
|
||||
without_session: 'Response for requests without sessions',
|
||||
access: 'Access control configuration (optional - defaults to private mode for security)',
|
||||
mode: 'Access control mode: "public" (allow all traffic) or "private" (IP whitelist after lure access, DEFAULT)',
|
||||
on_deny:
|
||||
'Response when access is denied in private mode (e.g., "404", "https://example.com")',
|
||||
capture: 'Rules for capturing data from requests/responses',
|
||||
name: 'Unique identifier for the rule',
|
||||
method: 'HTTP method to match (GET, POST, PUT, DELETE, etc.)',
|
||||
|
||||
@@ -60,6 +60,11 @@
|
||||
name: null
|
||||
};
|
||||
|
||||
let isIPAllowListModalVisible = false;
|
||||
let ipAllowListEntries = [];
|
||||
let selectedProxyForIPList = null;
|
||||
let isLoadingIPAllowList = false;
|
||||
|
||||
const currentExample = `version: "0.0"
|
||||
proxy: "My Proxy Campaign"
|
||||
|
||||
@@ -363,6 +368,55 @@ portal.example.com:
|
||||
formValues.startURL = proxyItem.startURL;
|
||||
formValues.proxyConfig = proxyItem.proxyConfig;
|
||||
};
|
||||
|
||||
const openIPAllowListModal = async (proxy) => {
|
||||
selectedProxyForIPList = proxy;
|
||||
isLoadingIPAllowList = true;
|
||||
isIPAllowListModalVisible = true;
|
||||
|
||||
console.log('Opening IP allow list for proxy:', proxy.id);
|
||||
|
||||
try {
|
||||
const res = await api.ipAllowList.getForProxyConfig(proxy.id);
|
||||
console.log('API response:', res);
|
||||
if (res.success) {
|
||||
ipAllowListEntries = res.data || [];
|
||||
} else {
|
||||
console.error('API error:', res.error);
|
||||
addToast(`Failed to load IP allow list: ${res.error}`, 'Error');
|
||||
ipAllowListEntries = [];
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Network error:', e);
|
||||
addToast('Failed to load IP allow list', 'Error');
|
||||
ipAllowListEntries = [];
|
||||
} finally {
|
||||
isLoadingIPAllowList = false;
|
||||
}
|
||||
};
|
||||
|
||||
const closeIPAllowListModal = () => {
|
||||
isIPAllowListModalVisible = false;
|
||||
selectedProxyForIPList = null;
|
||||
ipAllowListEntries = [];
|
||||
};
|
||||
|
||||
const clearIPAllowList = async () => {
|
||||
if (!selectedProxyForIPList) return;
|
||||
|
||||
try {
|
||||
const res = await api.ipAllowList.clearForProxyConfig(selectedProxyForIPList.id);
|
||||
if (res.success) {
|
||||
addToast('IP allow list cleared', 'Success');
|
||||
ipAllowListEntries = [];
|
||||
} else {
|
||||
addToast('Failed to clear IP allow list', 'Error');
|
||||
}
|
||||
} catch (e) {
|
||||
addToast('Failed to clear IP allow list', 'Error');
|
||||
console.error('failed to clear IP allow list', e);
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<HeadTitle title="Proxies" />
|
||||
@@ -424,6 +478,13 @@ portal.example.com:
|
||||
{...globalButtonDisabledAttributes(proxy, contextCompanyID)}
|
||||
/>
|
||||
<TableCopyButton title={'Copy'} on:click={() => openCopyModal(proxy.id)} />
|
||||
<button
|
||||
class="w-full px py-1 text-slate-600 dark:text-gray-200 hover:bg-highlight-blue dark:hover:bg-highlight-blue/50 hover:text-white cursor-pointer text-left transition-colors duration-200"
|
||||
on:click={() => openIPAllowListModal(proxy)}
|
||||
title="View IP Allow List"
|
||||
>
|
||||
<p class="ml-2 text-left">View IP Allow List</p>
|
||||
</button>
|
||||
<TableDeleteButton
|
||||
on:click={() => openDeleteAlert(proxy)}
|
||||
{...globalButtonDisabledAttributes(proxy, contextCompanyID)}
|
||||
@@ -506,4 +567,53 @@ portal.example.com:
|
||||
onClick={() => onClickDelete(deleteValues.id)}
|
||||
bind:isVisible={isDeleteAlertVisible}
|
||||
></DeleteAlert>
|
||||
|
||||
<!-- IP Allow List Modal -->
|
||||
<Modal
|
||||
headerText={`IP Allow List - ${selectedProxyForIPList?.name || ''}`}
|
||||
visible={isIPAllowListModalVisible}
|
||||
onClose={closeIPAllowListModal}
|
||||
isSubmitting={false}
|
||||
>
|
||||
<FormGrid>
|
||||
<div class="col-span-3 w-full overflow-y-auto px-6 py-4 space-y-6">
|
||||
<div class="flex justify-between items-center">
|
||||
{#if !isLoadingIPAllowList && ipAllowListEntries && ipAllowListEntries.length > 0}
|
||||
<BigButton on:click={clearIPAllowList}>Clear All</BigButton>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if isLoadingIPAllowList}
|
||||
<div class="flex items-center justify-center py-8">
|
||||
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
|
||||
<span class="ml-2 text-gray-600 dark:text-gray-400">Loading...</span>
|
||||
</div>
|
||||
{:else if !ipAllowListEntries || ipAllowListEntries.length === 0}
|
||||
<div class="text-center py-8 text-gray-500 dark:text-gray-400">
|
||||
No IP addresses are allow listed
|
||||
</div>
|
||||
{:else}
|
||||
<Table
|
||||
columns={[
|
||||
{ column: 'IP Address', size: 'medium' },
|
||||
{ column: 'Added At', size: 'medium' },
|
||||
{ column: 'Expires At', size: 'medium' }
|
||||
]}
|
||||
hasData={ipAllowListEntries.length > 0}
|
||||
plural="entries"
|
||||
>
|
||||
{#each ipAllowListEntries as entry}
|
||||
<TableRow>
|
||||
<TableCell>{entry.ip}</TableCell>
|
||||
<TableCell>{new Date(entry.createdAt).toLocaleString()}</TableCell>
|
||||
<TableCell>{new Date(entry.expiresAt).toLocaleString()}</TableCell>
|
||||
<TableCellEmpty />
|
||||
<TableCellEmpty />
|
||||
</TableRow>
|
||||
{/each}
|
||||
</Table>
|
||||
{/if}
|
||||
</div>
|
||||
</FormGrid>
|
||||
</Modal>
|
||||
</main>
|
||||
|
||||
Reference in New Issue
Block a user