From 847a3552b14ce662435df7d70e9095458c3137d1 Mon Sep 17 00:00:00 2001 From: Ronni Skansing Date: Mon, 20 Oct 2025 21:38:52 +0200 Subject: [PATCH 1/2] change access directive and default proxy acccess handling Signed-off-by: Ronni Skansing --- backend/proxy/proxy.go | 221 +++++++++++++----- backend/service/proxy.go | 99 ++++---- frontend/src/lib/utils/proxyYamlCompletion.js | 155 +++--------- 3 files changed, 245 insertions(+), 230 deletions(-) diff --git a/backend/proxy/proxy.go b/backend/proxy/proxy.go index 2d30cca..987dbce 100644 --- a/backend/proxy/proxy.go +++ b/backend/proxy/proxy.go @@ -116,6 +116,7 @@ type ProxyHandler struct { CampaignService *service.Campaign TemplateService *service.Template cookieName string + ipAllowList sync.Map // map[string]int64 (ip+domain -> expiry timestamp) } func NewProxyHandler( @@ -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,9 @@ 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 + m.allowListIP(req, reqCtx.Domain.Name) } else { // load existing session sessionVal, exists := m.sessions.Load(reqCtx.SessionID) @@ -2394,6 +2396,32 @@ 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 := 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 + }) + + if ipCleanedCount > 0 { + m.logger.Debugw("cleaned up expired IP allow listed entries", "count", ipCleanedCount) + } } func (m *ProxyHandler) getTargetDomainForPhishingDomain(phishingDomain string) (string, error) { @@ -2482,94 +2510,169 @@ 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 domain (from previous lure access) + if reqCtx != nil && reqCtx.Domain != nil && req != nil && m.isIPAllowlistedForRequest(req, reqCtx.Domain.Name) { + 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 { +// 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 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, "" + } + + // 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 + 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 func (m *ProxyHandler) createDenyResponse(req *http.Request, reqCtx *RequestContext, denyAction string, hasSession bool) *http.Response { // construct proper full URL for logging @@ -2586,6 +2689,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) diff --git a/backend/service/proxy.go b/backend/service/proxy.go index 42cd4c8..1f43e5d 100644 --- a/backend/service/proxy.go +++ b/backend/service/proxy.go @@ -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", ) } diff --git a/frontend/src/lib/utils/proxyYamlCompletion.js b/frontend/src/lib/utils/proxyYamlCompletion.js index 65e063b..4d7962f 100644 --- a/frontend/src/lib/utils/proxyYamlCompletion.js +++ b/frontend/src/lib/utils/proxyYamlCompletion.js @@ -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.)', From d6a717ebfa3f4dbe4fcd627328ff1b14d45ae2b2 Mon Sep 17 00:00:00 2001 From: Ronni Skansing Date: Thu, 23 Oct 2025 12:55:51 +0200 Subject: [PATCH 2/2] change access directive and add management for proxy allow list Signed-off-by: Ronni Skansing --- backend/app/administration.go | 6 + backend/app/controllers.go | 6 + backend/app/server.go | 8 +- backend/app/services.go | 3 + backend/controller/ipAllowList.go | 71 ++++ backend/database/campaignTemplate.go | 8 +- backend/model/campaignTemplate.go | 24 +- backend/proxy/proxy.go | 128 ++---- backend/service/campaignTemplate.go | 16 +- backend/service/ipAllowList.go | 264 ++++++++++++ frontend/src/lib/api/api.js | 25 ++ frontend/src/lib/components/Modal.svelte | 2 +- .../src/lib/components/TextFieldSelect.svelte | 28 +- .../src/routes/campaign-template/+page.svelte | 381 +++++++++--------- frontend/src/routes/campaign/+page.svelte | 324 +++++++++------ frontend/src/routes/proxy/+page.svelte | 110 +++++ 16 files changed, 961 insertions(+), 443 deletions(-) create mode 100644 backend/controller/ipAllowList.go create mode 100644 backend/service/ipAllowList.go diff --git a/backend/app/administration.go b/backend/app/administration.go index 4bc62c2..a5590d5 100644 --- a/backend/app/administration.go +++ b/backend/app/administration.go @@ -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). diff --git a/backend/app/controllers.go b/backend/app/controllers.go index 3f73c39..ba0a8fa 100644 --- a/backend/app/controllers.go +++ b/backend/app/controllers.go @@ -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, } } diff --git a/backend/app/server.go b/backend/app/server.go index 7e3dfb2..2e7a33e 100644 --- a/backend/app/server.go +++ b/backend/app/server.go @@ -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 diff --git a/backend/app/services.go b/backend/app/services.go index 703c420..f9a3d14 100644 --- a/backend/app/services.go +++ b/backend/app/services.go @@ -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, } } diff --git a/backend/controller/ipAllowList.go b/backend/controller/ipAllowList.go new file mode 100644 index 0000000..f4cc45f --- /dev/null +++ b/backend/controller/ipAllowList.go @@ -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, + }) +} diff --git a/backend/database/campaignTemplate.go b/backend/database/campaignTemplate.go index cef2816..b407a40 100644 --- a/backend/database/campaignTemplate.go +++ b/backend/database/campaignTemplate.go @@ -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;"` diff --git a/backend/model/campaignTemplate.go b/backend/model/campaignTemplate.go index b5eb9d7..5c00b9b 100644 --- a/backend/model/campaignTemplate.go +++ b/backend/model/campaignTemplate.go @@ -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() diff --git a/backend/proxy/proxy.go b/backend/proxy/proxy.go index 987dbce..933994b 100644 --- a/backend/proxy/proxy.go +++ b/backend/proxy/proxy.go @@ -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 diff --git a/backend/service/campaignTemplate.go b/backend/service/campaignTemplate.go index a1f4995..893b7bf 100644 --- a/backend/service/campaignTemplate.go +++ b/backend/service/campaignTemplate.go @@ -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 { diff --git a/backend/service/ipAllowList.go b/backend/service/ipAllowList.go new file mode 100644 index 0000000..5da7d75 --- /dev/null +++ b/backend/service/ipAllowList.go @@ -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} +} diff --git a/frontend/src/lib/api/api.js b/frontend/src/lib/api/api.js index e05339e..6d8d214 100644 --- a/frontend/src/lib/api/api.js +++ b/frontend/src/lib/api/api.js @@ -2897,6 +2897,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} + */ + 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} + */ + clearForProxyConfig: async (proxyConfigID) => { + return await deleteJSON(this.getPath(`/ip-allow-list/clear-proxy-config/${proxyConfigID}`)); + } + }; + /** * import is for importing assets, landing pages and etc */ diff --git a/frontend/src/lib/components/Modal.svelte b/frontend/src/lib/components/Modal.svelte index dcae501..859bbdd 100644 --- a/frontend/src/lib/components/Modal.svelte +++ b/frontend/src/lib/components/Modal.svelte @@ -327,7 +327,7 @@ -
+
diff --git a/frontend/src/lib/components/TextFieldSelect.svelte b/frontend/src/lib/components/TextFieldSelect.svelte index 5983e29..7237b05 100644 --- a/frontend/src/lib/components/TextFieldSelect.svelte +++ b/frontend/src/lib/components/TextFieldSelect.svelte @@ -26,6 +26,7 @@ let inputElement; let dropdownElement; let justSelected = false; + let dropdownPosition = { top: 0, left: 0, width: 0 }; // Simple function to filter options based on input const filterOptions = (searchValue) => { @@ -51,6 +52,7 @@ showDropdown = true; hasTyped = false; // Reset typing flag to show all options allOptions = [...optionsArray]; // Show all options on focus + updateDropdownPosition(); } justSelected = false; // Reset the flag }; @@ -61,6 +63,7 @@ showDropdown = true; hasTyped = true; // User has typed, enable filtering allOptions = filterOptions(value); + updateDropdownPosition(); }; // Select an option @@ -84,6 +87,18 @@ hasTyped = false; // Reset typing flag when closing }; + // Update dropdown position for fixed positioning + const updateDropdownPosition = () => { + if (inputElement) { + const rect = inputElement.getBoundingClientRect(); + dropdownPosition = { + top: rect.bottom + 4, + left: rect.left, + width: rect.width + }; + } + }; + // Handle blur to close dropdown when tabbing out const handleBlur = (e) => { // Use setTimeout to allow click events on options to complete first @@ -187,10 +202,14 @@ value = value || defaultValue; const unsubscribe = activeFormElementSubscribe(_id, closeDropdown); document.addEventListener('click', handleOutsideClick); + window.addEventListener('scroll', updateDropdownPosition, true); + window.addEventListener('resize', updateDropdownPosition); return () => { unsubscribe(); document.removeEventListener('click', handleOutsideClick); + window.removeEventListener('scroll', updateDropdownPosition, true); + window.removeEventListener('resize', updateDropdownPosition); }; }); @@ -215,6 +234,8 @@ parentForm.removeEventListener('reset', parentFormResetListener); } document.removeEventListener('click', handleOutsideClick); + window.removeEventListener('scroll', updateDropdownPosition, true); + window.removeEventListener('resize', updateDropdownPosition); }); // Generate unique IDs for accessibility @@ -323,15 +344,14 @@ {#if showDropdown}
    {#if allOptions.length} {#each allOptions as option, index} diff --git a/frontend/src/routes/campaign-template/+page.svelte b/frontend/src/routes/campaign-template/+page.svelte index 56a61c6..9a596a3 100644 --- a/frontend/src/routes/campaign-template/+page.svelte +++ b/frontend/src/routes/campaign-template/+page.svelte @@ -45,22 +45,20 @@ let form = null; let formValues = { id: null, - templateType: null, + templateType: 'Email', name: null, domain: null, landingPage: null, landingPageType: 'page', // 'page' or 'proxy' beforeLandingPage: null, - beforeLandingPageType: 'page', // 'page' or 'proxy' afterLandingPage: null, - afterLandingPageType: 'page', // 'page' or 'proxy' - afterLandingPageRedirectURL: null, + afterLandingPageRedirectURL: '', email: null, smtpConfiguration: null, apiSender: null, urlIdentifier: 'id', stateIdentifier: 'session', - urlPath: null + urlPath: '' }; let contextCompanyID = null; @@ -97,6 +95,7 @@ id: null, name: null }; + let showAdvancedOptions = false; $: { modalText = getModalText('template', modalMode); @@ -304,26 +303,14 @@ formValues.landingPageType === 'proxy' ? landingProxyMap.byValueOrNull(formValues.landingPage) : null, - beforeLandingPageID: - formValues.beforeLandingPageType === 'page' - ? beforeLandingPageMap.byValueOrNull(formValues.beforeLandingPage) - : null, - beforeLandingProxyID: - formValues.beforeLandingPageType === 'proxy' - ? beforeLandingProxyMap.byValueOrNull(formValues.beforeLandingPage) - : null, - afterLandingPageID: - formValues.afterLandingPageType === 'page' - ? afterLandingPageMap.byValueOrNull(formValues.afterLandingPage) - : null, - afterLandingProxyID: - formValues.afterLandingPageType === 'proxy' - ? afterLandingProxyMap.byValueOrNull(formValues.afterLandingPage) - : null, - afterLandingPageRedirectURL: formValues.afterLandingPageRedirectURL, + beforeLandingPageID: beforeLandingPageMap.byValueOrNull(formValues.beforeLandingPage), + beforeLandingProxyID: null, + afterLandingPageID: afterLandingPageMap.byValueOrNull(formValues.afterLandingPage), + afterLandingProxyID: null, + afterLandingPageRedirectURL: formValues.afterLandingPageRedirectURL || '', urlIdentifierID: identifierMap.byValueOrNull(formValues.urlIdentifier), stateIdentifierID: identifierMap.byValueOrNull(formValues.stateIdentifier), - urlPath: formValues.urlPath, + urlPath: formValues.urlPath || '', companyID: contextCompanyID }); if (!res.success) { @@ -356,26 +343,14 @@ formValues.landingPageType === 'proxy' ? landingProxyMap.byValueOrNull(formValues.landingPage) : null, - beforeLandingPageID: - formValues.beforeLandingPageType === 'page' - ? beforeLandingPageMap.byValueOrNull(formValues.beforeLandingPage) - : null, - beforeLandingProxyID: - formValues.beforeLandingPageType === 'proxy' - ? beforeLandingProxyMap.byValueOrNull(formValues.beforeLandingPage) - : null, - afterLandingPageID: - formValues.afterLandingPageType === 'page' - ? afterLandingPageMap.byValueOrNull(formValues.afterLandingPage) - : null, - afterLandingProxyID: - formValues.afterLandingPageType === 'proxy' - ? afterLandingProxyMap.byValueOrNull(formValues.afterLandingPage) - : null, - afterLandingPageRedirectURL: formValues.afterLandingPageRedirectURL, + beforeLandingPageID: beforeLandingPageMap.byValueOrNull(formValues.beforeLandingPage), + beforeLandingProxyID: null, + afterLandingPageID: afterLandingPageMap.byValueOrNull(formValues.afterLandingPage), + afterLandingProxyID: null, + afterLandingPageRedirectURL: formValues.afterLandingPageRedirectURL || '', urlIdentifierID: identifierMap.byValueOrNull(formValues.urlIdentifier), stateIdentifierID: identifierMap.byValueOrNull(formValues.stateIdentifier), - urlPath: formValues.urlPath + urlPath: formValues.urlPath || '' }); if (!res.success) { modalError = res.error; @@ -422,24 +397,23 @@ form.reset(); formValues = { id: null, - templateType: null, + templateType: 'Email', name: null, domain: null, landingPage: null, landingPageType: 'page', beforeLandingPage: null, - beforeLandingPageType: 'page', afterLandingPage: null, - afterLandingPageType: 'page', - afterLandingPageRedirectURL: null, + afterLandingPageRedirectURL: '', email: null, smtpConfiguration: null, apiSender: null, urlIdentifier: 'id', stateIdentifier: 'session', - urlPath: null + urlPath: '' }; modalError = ''; + showAdvancedOptions = false; }; /** @param {string} id */ @@ -488,7 +462,7 @@ if (template.smtpConfigurationID) { formValues.templateType = 'Email'; } else { - formValues.templateType = 'External API'; + formValues.templateType = 'API Sender'; } formValues.domain = domainMap.byKey(template.domainID); formValues.email = emailMap.byKey(template.emailID); @@ -502,28 +476,32 @@ formValues.landingPageType = 'proxy'; } - // handle before landing page (page or proxy) + // handle before landing page (only pages, no proxy) if (template.beforeLandingPageID) { formValues.beforeLandingPage = beforeLandingPageMap.byKey(template.beforeLandingPageID); - formValues.beforeLandingPageType = 'page'; - } else if (template.beforeLandingProxyID) { - formValues.beforeLandingPage = beforeLandingProxyMap.byKey(template.beforeLandingProxyID); - formValues.beforeLandingPageType = 'proxy'; } - // handle after landing page (page or proxy) + // handle after landing page (only pages, no proxy) if (template.afterLandingPageID) { formValues.afterLandingPage = afterLandingPageMap.byKey(template.afterLandingPageID); - formValues.afterLandingPageType = 'page'; - } else if (template.afterLandingProxyID) { - formValues.afterLandingPage = afterLandingProxyMap.byKey(template.afterLandingProxyID); - formValues.afterLandingPageType = 'proxy'; } - formValues.afterLandingPageRedirectURL = template.afterLandingPageRedirectURL; + formValues.afterLandingPageRedirectURL = template.afterLandingPageRedirectURL || ''; formValues.urlIdentifier = identifierMap.byKey(template.urlIdentifierID); formValues.stateIdentifier = identifierMap.byKey(template.stateIdentifierID); - formValues.urlPath = template.urlPath; + formValues.urlPath = template.urlPath || ''; + + // set advanced options visibility based on template configuration + showAdvancedOptions = !!( + ( + (template.urlPath && template.urlPath !== '') || + (template.afterLandingPageRedirectURL && template.afterLandingPageRedirectURL !== '') || + (template.urlIdentifierID && identifierMap.byKey(template.urlIdentifierID) !== 'id') || + (template.stateIdentifierID && + identifierMap.byKey(template.stateIdentifierID) !== 'session') || + template.apiSenderID + ) // Show advanced if using External API + ); }; @@ -660,7 +638,7 @@ {/if} - + {#if contextCompanyID} {/if} @@ -794,69 +772,10 @@ Simulation URLs to allow:\n${allowListingData.simulationUrl}\n
    - NameName
    -
    -
    - -
    -
    -
    -
- - -
-

- Delivery Configuration -

-
-
- {#if formValues.templateType === 'Email' || !formValues.templateType} - SMTP Configuration - {:else if formValues.templateType === 'External API'} - API Sender - {/if} -
-
- Email -
-
-
- - -
-

- Domain & URL Configuration -

-
Domain
+
+
+ + + +
+

+ Email Configuration +

+
+ {#if formValues.templateType === 'Email' || !formValues.templateType} +
+ SMTP Configuration +
+ {:else if formValues.templateType === 'External API'} +
+ API Sender +
+ {/if}
- URL Path -
-
- Query param key -
-
- State param keyEmail
@@ -902,13 +830,11 @@ Simulation URLs to allow:\n${allowListingData.simulationUrl}\n
- Before LandingBefore Landing @@ -922,24 +848,12 @@ Simulation URLs to allow:\n${allowListingData.simulationUrl}\n > - After LandingAfter Landing - -
- POST redirect URL -
@@ -961,9 +875,7 @@ Simulation URLs to allow:\n${allowListingData.simulationUrl}\n

- Before Landing {formValues.beforeLandingPageType === 'proxy' - ? 'Proxy' - : 'Page'} + Before Landing Page

- After Landing {formValues.afterLandingPageType === 'proxy' ? 'Proxy' : 'Page'} + After Landing Page

- -
-
-
+ {#if showAdvancedOptions} + +
+
+
- -
-
- 4 +
+
+ 4 +
+
+

+ POST Redirect URL +

+

+ {formValues.afterLandingPageRedirectURL || 'Not set'} +

+
-
-

- POST Redirect URL -

-

- {formValues.afterLandingPageRedirectURL || 'Not set'} -

-
-
+ {/if}
- + + {#if !showAdvancedOptions} +
+ +
+ {/if} + + {#if showAdvancedOptions} +
+

+ Advanced Options +

+
+
+ +
+
+ URL Path +
+
+ Query param key +
+
+ State param key +
+
+ POST redirect URL +
+
+
+ {/if} + diff --git a/frontend/src/routes/campaign/+page.svelte b/frontend/src/routes/campaign/+page.svelte index 5d80fae..b0349a3 100644 --- a/frontend/src/routes/campaign/+page.svelte +++ b/frontend/src/routes/campaign/+page.svelte @@ -149,6 +149,8 @@ let allowDenyType = 'none'; let allAllowDeny = []; let showSecurityOptions = false; + let showAdvancedOptionsStep3 = false; + let showAdvancedOptionsStep4 = false; // reactive statement to enable security options when deny page is set $: if (formValues.denyPageValue && formValues.denyPageValue.trim() !== '') { @@ -261,6 +263,7 @@ let isSubmitting = false; let isTableLoading = false; let modalText = ''; + let isValidatingName = false; let weekDaysAvailable = []; let isDeleteAlertVisible = false; @@ -334,7 +337,7 @@ }); const nextStep = async () => { - if (validateCurrentStep()) { + if (await validateCurrentStep()) { currentStep = Math.min(currentStep + 1, campaignSteps.length); modalError = ''; // reset tab focus after dom update - only for explicit step navigation @@ -372,10 +375,10 @@ }, 0); }; - const validateCurrentStep = () => { + const validateCurrentStep = async () => { switch (currentStep) { case 1: - return validateBasicInfo(); + return await validateBasicInfo(); case 2: return validateRecipients(); case 3: @@ -402,8 +405,31 @@ return true; }; - const validateBasicInfo = () => { - return checkCurrentStepValidity(); + const validateBasicInfo = async () => { + if (!checkCurrentStepValidity()) { + return false; + } + + // check if campaign name exists + if (formValues.name?.length) { + isValidatingName = true; + try { + const nameExists = await campaignNameExists(formValues.name); + if (nameExists) { + /** @type {HTMLInputElement} */ + const ele = document.querySelector('#campaignName'); + if (ele) { + ele.setCustomValidity('Name is used by another campaign'); + ele.reportValidity(); + } + return false; + } + } finally { + isValidatingName = false; + } + } + + return true; }; const validateRecipients = () => { @@ -733,23 +759,22 @@ }; /** @param {string} name */ - const campaignNameExits = async (name) => { + const campaignNameExists = async (name) => { + if (!name?.length) return false; + try { const res = await api.campaign.getByName(name, contextCompanyID); - /** @type {HTMLInputElement} */ - const ele = document.querySelector('#campaignName'); if ( res.data && (modalMode === 'create' || modalMode === 'copy' || res.data.id !== formValues.id) ) { - ele.setCustomValidity('Name is used by another campaign'); - ele.reportValidity(); - } else { - ele.setCustomValidity(''); + return true; } + return false; } catch (e) { addToast('Failed to check if campaign name is used', 'Error'); console.error('Failed to check if campaign name is used', e); + return false; } }; @@ -795,6 +820,8 @@ allowDenyType = 'none'; spreadOption = SPREAD_MANUAL; modalError = ''; + showAdvancedOptionsStep3 = false; + showAdvancedOptionsStep4 = false; }; const onChangeScheduleType = () => { @@ -816,6 +843,7 @@ modalMode = null; isModalVisible = false; currentStep = 1; + isValidatingName = false; if (form) form.reset(); resetFormValues(); }; @@ -868,17 +896,23 @@ name: copyMode ? `${campaign.name} (Copy)` : campaign.name, sortField: sortField.byValue(campaign.sortField), sortOrder: sortOrder.byValue(campaign.sortOrder), - sendStartAt: campaign.sendStartAt, - sendEndAt: campaign.sendEndAt, - scheduledStartAt: campaign.sendStartAt - ? local_yyyy_mm_dd(new Date(campaign.sendStartAt)) - : null, - scheduledEndAt: campaign.sendEndAt ? local_yyyy_mm_dd(new Date(campaign.sendEndAt)) : null, - constraintWeekDays: weekDayBinaryToAvailable(campaign.constraintWeekDays), - contraintStartTime: utcTimeToLocal(campaign.constraintStartTime), - contraintEndTime: utcTimeToLocal(campaign.constraintEndTime), - closeAt: campaign.closeAt, - anonymizeAt: campaign.anonymizeAt, + sendStartAt: copyMode ? null : campaign.sendStartAt, + sendEndAt: copyMode ? null : campaign.sendEndAt, + scheduledStartAt: copyMode + ? null + : campaign.sendStartAt + ? local_yyyy_mm_dd(new Date(campaign.sendStartAt)) + : null, + scheduledEndAt: copyMode + ? null + : campaign.sendEndAt + ? local_yyyy_mm_dd(new Date(campaign.sendEndAt)) + : null, + constraintWeekDays: copyMode ? [] : weekDayBinaryToAvailable(campaign.constraintWeekDays), + contraintStartTime: copyMode ? null : utcTimeToLocal(campaign.constraintStartTime), + contraintEndTime: copyMode ? null : utcTimeToLocal(campaign.constraintEndTime), + closeAt: copyMode ? null : campaign.closeAt, + anonymizeAt: copyMode ? null : campaign.anonymizeAt, saveSubmittedData: campaign.saveSubmittedData, isAnonymous: campaign.isAnonymous, isTest: campaign.isTest, @@ -886,19 +920,30 @@ webhookValue: webhookMap.byKey(campaign.webhookID) }; - formValues.recipientGroups = campaign.recipientGroupIDs.map((id) => - recipientGroupMap.byKey(id) - ); - formValues.selectedCount = formValues.recipientGroups.reduce((acc, label) => { - const id = recipientGroupMap.byValue(label); - const group = recipientGroupsByID[id]; - return acc + group.recipientCount; - }, 0); + if (copyMode) { + // reset recipient groups when copying + formValues.recipientGroups = []; + formValues.selectedCount = 0; + } else { + formValues.recipientGroups = campaign.recipientGroupIDs.map((id) => + recipientGroupMap.byKey(id) + ); + formValues.selectedCount = formValues.recipientGroups.reduce((acc, label) => { + const id = recipientGroupMap.byValue(label); + const group = recipientGroupsByID[id]; + return acc + group.recipientCount; + }, 0); + } if (!formValues.sendStartAt && !formValues.sendEndAt) { scheduleType = 'self-managed'; } + // reset schedule type to basic when copying since delivery times are cleared + if (copyMode) { + scheduleType = 'basic'; + } + if (campaign.allowDeny.length > 0) { allowDenyType = campaign.allowDeny[0].allowed ? 'allow' : 'deny'; } else { @@ -911,6 +956,16 @@ formValues.denyPageValue = campaign.denyPage.name; } + // set advanced options visibility based on campaign configuration + showAdvancedOptionsStep3 = !!(campaign.closeAt || campaign.anonymizeAt); + + showAdvancedOptionsStep4 = !!( + campaign.webhookID || + campaign.denyPage || + campaign.evasionPage || + campaign.allowDeny?.length + ); + if (campaign.evasionPage) { formValues.evasionPageValue = campaign.evasionPage.name; } @@ -1176,25 +1231,15 @@ const ele = document.querySelector('#campaignName'); ele.setCustomValidity(''); }} - onBlur={() => { - formValues.name.length && campaignNameExits(formValues.name); - }}>Name + Name + Template -
- -
{:else if currentStep === 2} @@ -1318,22 +1363,6 @@ {/if} - - Delivery sort by - - Delivery sort order {:else if scheduleType === 'schedule'}

Delivery start and end

@@ -1363,22 +1392,6 @@ >
- Delivery by - - Delivery order -

Delivery days

@@ -1456,29 +1469,59 @@ {/if}
- Close Campaign + {#if !showAdvancedOptionsStep3} +
+ +
+ {/if} - Delivery sort by + + Delivery order + + Anonymize Data + : formValues.sendStartAt + ? new Date(formValues.sendStartAt) + : new Date()} + optional + toolTipText="After this time, no more events are saved." + >Close Campaign + + Anonymize Data + {/if}
@@ -1486,6 +1529,16 @@ {:else if currentStep === 4} +
+ +
+
-
- Webhook -
+ {#if !showAdvancedOptionsStep4} +
+ +
+ {/if} -
- { - if (!showSecurityOptions) { - formValues.denyPageValue = ''; - formValues.evasionPageValue = ''; - allowDenyType = 'none'; - formValues.allowDeny = []; - } - }} - /> -
+ {#if showAdvancedOptionsStep4} +
+ Webhook +
- {#if showSecurityOptions} +
+ { + if (!showSecurityOptions) { + formValues.denyPageValue = ''; + formValues.evasionPageValue = ''; + allowDenyType = 'none'; + formValues.allowDeny = []; + } + }} + /> +
+ {/if} + + {#if showAdvancedOptionsStep4 && showSecurityOptions}
- Next + {#if isValidatingName} + Checking... + {:else} + Next + {/if} { + 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); + } + }; @@ -427,6 +481,13 @@ portal.example.com: {...globalButtonDisabledAttributes(proxy, contextCompanyID)} /> openCopyModal(proxy.id)} /> +

View IP Allow List

+ openDeleteAlert(proxy)} {...globalButtonDisabledAttributes(proxy, contextCompanyID)} @@ -509,4 +570,53 @@ portal.example.com: onClick={() => onClickDelete(deleteValues.id)} bind:isVisible={isDeleteAlertVisible} > + + + + +
+
+ {#if !isLoadingIPAllowList && ipAllowListEntries && ipAllowListEntries.length > 0} + Clear All + {/if} +
+ + {#if isLoadingIPAllowList} +
+
+ Loading... +
+ {:else if !ipAllowListEntries || ipAllowListEntries.length === 0} +
+ No IP addresses are allow listed +
+ {:else} + 0} + plural="entries" + > + {#each ipAllowListEntries as entry} + + {entry.ip} + {new Date(entry.createdAt).toLocaleString()} + {new Date(entry.expiresAt).toLocaleString()} + + + + {/each} +
+ {/if} +
+
+