From 9c5acbed90e5f677b2d6ed8beb156ee240deb507 Mon Sep 17 00:00:00 2001 From: Ronni Skansing Date: Sun, 9 Nov 2025 11:19:32 +0100 Subject: [PATCH] Add geo IP checking --- backend/app/administration.go | 4 + backend/app/controllers.go | 5 + backend/app/server.go | 35 +++- backend/controller/geoip.go | 34 ++++ backend/database/allowDeny.go | 1 + backend/geoip/geoip.go | 185 ++++++++++++++++++ backend/model/allowDeny.go | 90 ++++++++- backend/proxy/proxy.go | 35 ++-- backend/repository/allowDeny.go | 2 + frontend/src/lib/api/api.js | 22 ++- .../src/routes/campaign/[id]/+page.svelte | 6 +- frontend/src/routes/filter/+page.svelte | 47 ++++- 12 files changed, 436 insertions(+), 30 deletions(-) create mode 100644 backend/controller/geoip.go create mode 100644 backend/geoip/geoip.go diff --git a/backend/app/administration.go b/backend/app/administration.go index a8314d4..a9fbb7a 100644 --- a/backend/app/administration.go +++ b/backend/app/administration.go @@ -179,6 +179,8 @@ const ( ROUTE_V1_ALLOW_DENY = "/api/v1/allow-deny" ROUTE_V1_ALLOW_DENY_OVERVIEW = "/api/v1/allow-deny/overview" ROUTE_V1_ALLOW_DENY_ID = "/api/v1/allow-deny/:id" + // geoip + ROUTE_V1_GEOIP_METADATA = "/api/v1/geoip/metadata" // web hooks ROUTE_V1_WEBHOOK = "/api/v1/webhook" ROUTE_V1_WEBHOOK_ID = "/api/v1/webhook/:id" @@ -442,6 +444,8 @@ func setupRoutes( POST(ROUTE_V1_ALLOW_DENY, middleware.SessionHandler, controllers.AllowDeny.Create). PATCH(ROUTE_V1_ALLOW_DENY_ID, middleware.SessionHandler, controllers.AllowDeny.UpdateByID). DELETE(ROUTE_V1_ALLOW_DENY_ID, middleware.SessionHandler, controllers.AllowDeny.DeleteByID). + // geoip + GET(ROUTE_V1_GEOIP_METADATA, middleware.SessionHandler, controllers.GeoIP.GetMetadata). // web hooks GET(ROUTE_V1_WEBHOOK, middleware.SessionHandler, controllers.Webhook.GetAll). GET(ROUTE_V1_WEBHOOK_ID, middleware.SessionHandler, controllers.Webhook.GetByID). diff --git a/backend/app/controllers.go b/backend/app/controllers.go index ba0a8fa..a63aa67 100644 --- a/backend/app/controllers.go +++ b/backend/app/controllers.go @@ -29,6 +29,7 @@ type Controllers struct { QR *controller.QRGenerator APISender *controller.APISender AllowDeny *controller.AllowDeny + GeoIP *controller.GeoIP Webhook *controller.Webhook Identifier *controller.Identifier Version *controller.Version @@ -184,6 +185,9 @@ func NewControllers( Common: common, IPAllowListService: services.IPAllowList, } + geoIP := &controller.GeoIP{ + Common: common, + } return &Controllers{ Asset: asset, @@ -207,6 +211,7 @@ func NewControllers( QR: qr, APISender: apiSender, AllowDeny: allowDeny, + GeoIP: geoIP, Webhook: webhook, Identifier: identifier, Version: version, diff --git a/backend/app/server.go b/backend/app/server.go index 96b2f92..cd5775b 100644 --- a/backend/app/server.go +++ b/backend/app/server.go @@ -29,6 +29,7 @@ import ( "github.com/phishingclub/phishingclub/data" "github.com/phishingclub/phishingclub/database" "github.com/phishingclub/phishingclub/errs" + "github.com/phishingclub/phishingclub/geoip" "github.com/phishingclub/phishingclub/middleware" "github.com/phishingclub/phishingclub/model" "github.com/phishingclub/phishingclub/proxy" @@ -1818,6 +1819,16 @@ func (s *Server) checkIPFilter( // get ja4 fingerprint from context ja4 := middleware.GetJA4FromContext(ctx) + // get country code from GeoIP lookup + var countryCode string + if geo, err := geoip.Instance(); err == nil { + countryCode, _ = geo.Lookup(ip) + } + s.logger.Debugw("checking geo ip", + "ip", ip, + "country", countryCode, + ) + allowDenyLEntries, err := s.repositories.Campaign.GetAllDenyByCampaignID(ctx, campaignID) if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { s.logger.Debugw("failed to get deny list for campaign", @@ -1852,14 +1863,17 @@ func (s *Server) checkIPFilter( return false, errs.Wrap(err) } - // for allow lists: both IP and JA4 must pass - // for deny lists: either IP or JA4 failing blocks the request + // check country code filter + countryOk := allowDeny.IsCountryAllowed(countryCode) + // for allow lists: all filters (IP, JA4, country) must pass + // for deny lists: any filter failing blocks the request if isAllowListing { - // allow list: both must be allowed - if ipOk && ja4Ok { - s.logger.Debugw("IP and JA4 are allow listed", + // allow list: all must be allowed + if ipOk && ja4Ok && countryOk { + s.logger.Debugw("IP, JA4, and country are allow listed", "ip", ip, "ja4", ja4, + "country", countryCode, "list name", allowDeny.Name.MustGet().String(), "list id", allowDeny.ID.MustGet().String(), ) @@ -1867,13 +1881,15 @@ func (s *Server) checkIPFilter( break } } else { - // deny list: if either IP or JA4 is denied, block the request - if !ipOk || !ja4Ok { - s.logger.Debugw("IP or JA4 is deny listed", + // deny list: if any filter denies, block the request + if !ipOk || !ja4Ok || !countryOk { + s.logger.Debugw("IP, JA4, or country is deny listed", "ip", ip, "ja4", ja4, + "country", countryCode, "ipOk", ipOk, "ja4Ok", ja4Ok, + "countryOk", countryOk, "list name", allowDeny.Name.MustGet().String(), "list id", allowDeny.ID.MustGet().String(), ) @@ -1883,9 +1899,10 @@ func (s *Server) checkIPFilter( } } if !allowed { - s.logger.Debugw("IP or JA4 is not allowed", + s.logger.Debugw("IP, JA4, or country is not allowed", "ip", ip, "ja4", ja4, + "country", countryCode, ) if denyPageID, err := campaign.DenyPageID.Get(); err == nil { err = s.renderDenyPage(ctx, domain, &denyPageID) diff --git a/backend/controller/geoip.go b/backend/controller/geoip.go new file mode 100644 index 0000000..c76f52e --- /dev/null +++ b/backend/controller/geoip.go @@ -0,0 +1,34 @@ +package controller + +import ( + "github.com/gin-gonic/gin" + "github.com/phishingclub/phishingclub/geoip" +) + +// GeoIP is a controller for GeoIP-related endpoints +type GeoIP struct { + Common +} + +// GetMetadata returns the GeoIP metadata including available country codes +func (c *GeoIP) GetMetadata(g *gin.Context) { + _, _, ok := c.handleSession(g) + if !ok { + return + } + + // get geoip instance + geo, err := geoip.Instance() + if ok := c.handleErrors(g, err); !ok { + return + } + + // get metadata + metadata := geo.GetMetadata() + if metadata == nil { + c.Response.BadRequest(g) + return + } + + c.Response.OK(g, metadata) +} diff --git a/backend/database/allowDeny.go b/backend/database/allowDeny.go index 57ba3a0..b943a75 100644 --- a/backend/database/allowDeny.go +++ b/backend/database/allowDeny.go @@ -20,6 +20,7 @@ type AllowDeny struct { Name string `gorm:"not null;uniqueIndex:idx_allow_denies_unique_name_and_company_id;"` Cidrs string `gorm:"not null;default:''"` JA4Fingerprints string `gorm:"not null;default:''"` + CountryCodes string `gorm:"not null;default:''"` Allowed bool `gorm:"not null;"` } diff --git a/backend/geoip/geoip.go b/backend/geoip/geoip.go new file mode 100644 index 0000000..35afbb1 --- /dev/null +++ b/backend/geoip/geoip.go @@ -0,0 +1,185 @@ +package geoip + +import ( + "encoding/json" + "fmt" + "net" + "strings" + "sync" + + "github.com/phishingclub/phishingclub/embedded" +) + +// countryData represents the structure from embedded geoip files +type countryData struct { + Code string `json:"code"` + Name string `json:"name"` + IPv4 []string `json:"ipv4"` + IPv6 []string `json:"ipv6"` +} + +// metadata represents the geoip metadata file +type Metadata struct { + Generated string `json:"generated"` + Source string `json:"source"` + License string `json:"license"` + Countries int `json:"countries"` + CountryCodes []string `json:"country_codes"` +} + +// ipRange represents a single IP range with its country code +type ipRange struct { + network *net.IPNet + countryCode string +} + +// GeoIP provides IP to country lookup functionality +type GeoIP struct { + ranges []ipRange + metadata *Metadata + mu sync.RWMutex +} + +var ( + instance *GeoIP + once sync.Once + initErr error +) + +// Instance returns the singleton GeoIP instance +func Instance() (*GeoIP, error) { + once.Do(func() { + instance, initErr = New() + }) + return instance, initErr +} + +// New creates a new GeoIP instance by loading embedded data +func New() (*GeoIP, error) { + geo := &GeoIP{ + ranges: make([]ipRange, 0), + } + + // load metadata + if err := geo.loadMetadata(); err != nil { + return nil, fmt.Errorf("failed to load metadata: %w", err) + } + + // load all country data + if err := geo.loadAllCountries(); err != nil { + return nil, fmt.Errorf("failed to load countries: %w", err) + } + + return geo, nil +} + +// loadMetadata loads the metadata.json file +func (g *GeoIP) loadMetadata() error { + data, err := embedded.GeoIPData.ReadFile("geoip/metadata.json") + if err != nil { + return fmt.Errorf("failed to read metadata: %w", err) + } + + var meta Metadata + if err := json.Unmarshal(data, &meta); err != nil { + return fmt.Errorf("failed to parse metadata: %w", err) + } + + g.metadata = &meta + return nil +} + +// loadAllCountries loads all country IP ranges +func (g *GeoIP) loadAllCountries() error { + if g.metadata == nil { + return fmt.Errorf("metadata not loaded") + } + + for _, code := range g.metadata.CountryCodes { + if err := g.loadCountry(code); err != nil { + // log warning but continue - some countries may not have data + continue + } + } + + return nil +} + +// loadCountry loads IP ranges for a single country +func (g *GeoIP) loadCountry(countryCode string) error { + filename := fmt.Sprintf("geoip/%s.json", strings.ToLower(countryCode)) + data, err := embedded.GeoIPData.ReadFile(filename) + if err != nil { + return fmt.Errorf("failed to read %s: %w", countryCode, err) + } + + var country countryData + if err := json.Unmarshal(data, &country); err != nil { + return fmt.Errorf("failed to parse %s: %w", countryCode, err) + } + + // add all IPv4 ranges + for _, cidr := range country.IPv4 { + _, network, err := net.ParseCIDR(cidr) + if err != nil { + continue // skip invalid entries + } + g.ranges = append(g.ranges, ipRange{ + network: network, + countryCode: country.Code, + }) + } + + // add all IPv6 ranges + for _, cidr := range country.IPv6 { + _, network, err := net.ParseCIDR(cidr) + if err != nil { + continue // skip invalid entries + } + g.ranges = append(g.ranges, ipRange{ + network: network, + countryCode: country.Code, + }) + } + + return nil +} + +// Lookup finds the country code for an IP address +// returns (countryCode, found) +func (g *GeoIP) Lookup(ipStr string) (string, bool) { + g.mu.RLock() + defer g.mu.RUnlock() + + ip := net.ParseIP(ipStr) + if ip == nil { + return "", false + } + + // linear search through ranges + // for better performance, consider using a trie or radix tree + for _, r := range g.ranges { + if r.network.Contains(ip) { + return r.countryCode, true + } + } + + return "", false +} + +// GetMetadata returns the GeoIP metadata +func (g *GeoIP) GetMetadata() *Metadata { + g.mu.RLock() + defer g.mu.RUnlock() + return g.metadata +} + +// GetCountryCodes returns a list of all available country codes +func (g *GeoIP) GetCountryCodes() []string { + g.mu.RLock() + defer g.mu.RUnlock() + if g.metadata == nil { + return []string{} + } + return g.metadata.CountryCodes +} diff --git a/backend/model/allowDeny.go b/backend/model/allowDeny.go index 0503a6e..c8f67e7 100644 --- a/backend/model/allowDeny.go +++ b/backend/model/allowDeny.go @@ -23,6 +23,7 @@ type AllowDeny struct { Name nullable.Nullable[vo.String127] `json:"name"` Cidrs nullable.Nullable[vo.IPNetSlice] `json:"cidrs"` JA4Fingerprints nullable.Nullable[string] `json:"ja4Fingerprints"` + CountryCodes nullable.Nullable[string] `json:"countryCodes"` Allowed nullable.Nullable[bool] `json:"allowed"` CompanyID nullable.Nullable[uuid.UUID] `json:"companyID"` } @@ -36,7 +37,7 @@ func (r *AllowDeny) Validate() error { return err } - // at least one of cidrs or ja4 fingerprints must be provided + // at least one of cidrs, ja4 fingerprints, or country codes must be provided hasCidrs := false if r.Cidrs.IsSpecified() { if cidrs, err := r.Cidrs.Get(); err == nil && len(cidrs) > 0 { @@ -51,9 +52,16 @@ func (r *AllowDeny) Validate() error { } } - if !hasCidrs && !hasJA4 { + hasCountryCodes := false + if r.CountryCodes.IsSpecified() { + if codes, err := r.CountryCodes.Get(); err == nil && codes != "" { + hasCountryCodes = true + } + } + + if !hasCidrs && !hasJA4 && !hasCountryCodes { return errs.NewValidationError( - errors.New("at least one of CIDRs or JA4 fingerprints must be provided"), + errors.New("at least one of CIDRs, JA4 fingerprints, or country codes must be provided"), ) } @@ -93,6 +101,12 @@ func (r *AllowDeny) ToDBMap() map[string]any { m["ja4_fingerprints"] = ja4 } } + if r.CountryCodes.IsSpecified() { + m["country_codes"] = "" + if codes, err := r.CountryCodes.Get(); err == nil { + m["country_codes"] = codes + } + } if r.Allowed.IsSpecified() { m["allowed"] = nil if allowed, err := r.Allowed.Get(); err == nil { @@ -294,3 +308,73 @@ func trimSpace(s string) string { func isSpace(b byte) bool { return b == ' ' || b == '\t' || b == '\n' || b == '\r' } + +// IsCountryAllowed checks if a country code is allowed based on the filter rules +func (r *AllowDeny) IsCountryAllowed(countryCode string) bool { + if countryCode == "" { + // if no country code available, skip country check + return true + } + + isTypeAllowList := r.Allowed.MustGet() + + // get country codes list + countryCodesStr, err := r.CountryCodes.Get() + if err != nil || countryCodesStr == "" { + // if no country codes configured, skip country check + return true + } + + // if country code is empty but we have country filters configured + if countryCode == "" { + // in allow list mode: unknown country should be denied + // in deny list mode: unknown country should be allowed + if isTypeAllowList { + return false + } + return true + } + + // parse country codes (newline separated) + codes := parseCountryCodes(countryCodesStr) + + // check if country code matches any in the list (case-insensitive) + isMatch := false + countryCodeUpper := strings.ToUpper(countryCode) + for _, code := range codes { + if strings.ToUpper(code) == countryCodeUpper { + isMatch = true + break + } + } + + // if allow list and country matches + if isTypeAllowList && isMatch { + return true + } + // if deny list and country matches + if !isTypeAllowList && isMatch { + return false + } + + // If this is an allow list and country didn't match, not allowed + if isTypeAllowList { + return false + } + + // If this is a deny list and country didn't match, it is allowed + return true +} + +// parseCountryCodes splits newline-separated country codes and trims whitespace +func parseCountryCodes(input string) []string { + var result []string + lines := splitLines(input) + for _, line := range lines { + trimmed := trimSpace(line) + if trimmed != "" { + result = append(result, trimmed) + } + } + return result +} diff --git a/backend/proxy/proxy.go b/backend/proxy/proxy.go index 3cd0076..2ccf15f 100644 --- a/backend/proxy/proxy.go +++ b/backend/proxy/proxy.go @@ -26,6 +26,7 @@ import ( "github.com/phishingclub/phishingclub/cache" "github.com/phishingclub/phishingclub/data" "github.com/phishingclub/phishingclub/database" + "github.com/phishingclub/phishingclub/geoip" "github.com/phishingclub/phishingclub/model" "github.com/phishingclub/phishingclub/repository" "github.com/phishingclub/phishingclub/server" @@ -191,7 +192,7 @@ func (m *ProxyHandler) HandleHTTPRequest(w http.ResponseWriter, req *http.Reques // check ip filtering for initial MITM requests (before session creation) // at this point, initializeRequestContext has loaded all campaign data if reqCtx.CampaignRecipientID != nil && reqCtx.Campaign != nil { - blocked, resp := m.checkIPFilter(req, reqCtx) + blocked, resp := m.checkFilter(req, reqCtx) if blocked { if resp != nil { return m.writeResponse(w, resp) @@ -437,7 +438,7 @@ func (m *ProxyHandler) processRequestWithContext(req *http.Request, reqCtx *Requ // check ip filtering for session-based requests (after session is resolved) // skip if this is initial request (already checked before session creation) if reqCtx.CampaignRecipientID != nil && !createSession { - blocked, resp := m.checkIPFilter(req, reqCtx) + blocked, resp := m.checkFilter(req, reqCtx) if blocked { return req, resp } @@ -3714,10 +3715,10 @@ func (m *ProxyHandler) registerEvasionPageVisitEventDirect(req *http.Request, re } } -// checkIPFilter checks if the client IP and JA4 fingerprint are allowed for proxy requests +// checkFilter checks if the client IP, JA4 fingerprint and geo ip are allowed for proxy requests // JA4 fingerprint is extracted from request context (set by middleware, not from session) // returns (blocked, response) where blocked=true means the request should be blocked -func (m *ProxyHandler) checkIPFilter(req *http.Request, reqCtx *RequestContext) (bool, *http.Response) { +func (m *ProxyHandler) checkFilter(req *http.Request, reqCtx *RequestContext) (bool, *http.Response) { // use cached campaign info if reqCtx.Campaign == nil || reqCtx.CampaignID == nil { return false, nil // allow if we can't get campaign info @@ -3745,7 +3746,17 @@ func (m *ProxyHandler) checkIPFilter(req *http.Request, reqCtx *RequestContext) // get ja4 fingerprint from request header (set by middleware) ja4 := req.Header.Get(HEADER_JA4) - // check IP and JA4 against allow/deny lists + // get country code from GeoIP lookup + var countryCode string + if geo, err := geoip.Instance(); err == nil { + countryCode, _ = geo.Lookup(ip) + } + m.logger.Debugw("checking geo ip", + "ip", ip, + "country", countryCode, + ) + + // check IP, JA4, and country code against allow/deny lists isAllowListing := false allowed := false // for allow lists, default is deny @@ -3770,17 +3781,19 @@ func (m *ProxyHandler) checkIPFilter(req *http.Request, reqCtx *RequestContext) continue } - // for allow lists: both IP and JA4 must pass - // for deny lists: either IP or JA4 failing blocks the request + // check country code filter + countryOk := allowDeny.IsCountryAllowed(countryCode) + // for allow lists: all filters (IP, JA4, country) must pass + // for deny lists: any filter failing blocks the request if isAllowListing { - // allow list: both must be allowed - if ipOk && ja4Ok { + // allow list: all must be allowed + if ipOk && ja4Ok && countryOk { allowed = true break } } else { - // deny list: if either IP or JA4 is denied, block the request - if !ipOk || !ja4Ok { + // deny list: if any filter denies, block the request + if !ipOk || !ja4Ok || !countryOk { allowed = false break } diff --git a/backend/repository/allowDeny.go b/backend/repository/allowDeny.go index a069c7e..1034845 100644 --- a/backend/repository/allowDeny.go +++ b/backend/repository/allowDeny.go @@ -187,6 +187,7 @@ func ToAllowDeny(row *database.AllowDeny) *model.AllowDeny { cidrsNullable := nullable.NewNullableWithValue(cidrs) ja4Fingerprints := nullable.NewNullableWithValue(row.JA4Fingerprints) + countryCodes := nullable.NewNullableWithValue(row.CountryCodes) return &model.AllowDeny{ ID: id, @@ -195,6 +196,7 @@ func ToAllowDeny(row *database.AllowDeny) *model.AllowDeny { Name: name, Cidrs: cidrsNullable, JA4Fingerprints: ja4Fingerprints, + CountryCodes: countryCodes, Allowed: nullable.NewNullableWithValue(row.Allowed), CompanyID: companyID, } diff --git a/frontend/src/lib/api/api.js b/frontend/src/lib/api/api.js index 176160f..16da096 100644 --- a/frontend/src/lib/api/api.js +++ b/frontend/src/lib/api/api.js @@ -2658,15 +2658,17 @@ export class API { * @param {string} allowdeny.name * @param {string} allowdeny.cidrs * @param {string} allowdeny.ja4Fingerprints + * @param {string} allowdeny.countryCodes * @param {boolean} allowdeny.allowed * @param {string} allowdeny.companyID * @returns {Promise} */ - create: async ({ name, cidrs, ja4Fingerprints, allowed, companyID }) => { + create: async ({ name, cidrs, ja4Fingerprints, countryCodes, allowed, companyID }) => { return await postJSON(this.getPath('/allow-deny'), { name: name, cidrs: cidrs, ja4Fingerprints: ja4Fingerprints, + countryCodes: countryCodes, allowed: allowed, companyID: companyID }); @@ -2718,14 +2720,16 @@ export class API { * @param {string} allowdeny.name * @param {string} allowdeny.cidrs * @param {string} allowdeny.ja4Fingerprints + * @param {string} allowdeny.countryCodes * @param {string} allowdeny.companyID * @returns {Promise} */ - update: async ({ id, name, cidrs, ja4Fingerprints, companyID }) => { + update: async ({ id, name, cidrs, ja4Fingerprints, countryCodes, companyID }) => { return await patchJSON(this.getPath(`/allow-deny/${id}`), { name: name, cidrs: cidrs, ja4Fingerprints: ja4Fingerprints, + countryCodes: countryCodes, companyID: companyID }); }, @@ -2741,6 +2745,20 @@ export class API { } }; + /** + * geoip is the API for GeoIP related operations. + */ + geoip = { + /** + * Get GeoIP metadata including available country codes. + * + * @returns {Promise} + */ + getMetadata: async () => { + return await getJSON(this.getPath('/geoip/metadata')); + } + }; + /** * webhook is the API for web hook related operations. */ diff --git a/frontend/src/routes/campaign/[id]/+page.svelte b/frontend/src/routes/campaign/[id]/+page.svelte index 8e05853..769298f 100644 --- a/frontend/src/routes/campaign/[id]/+page.svelte +++ b/frontend/src/routes/campaign/[id]/+page.svelte @@ -72,6 +72,7 @@ eventTypesIDToNameMap: {}, notableEventName: '' }; + let allowedFilter = null; let campaignRecipients = []; let recipientEventsRecipient = { name: null, @@ -224,6 +225,9 @@ if (t.sendStartAt === null && t.sendEndAt === null) { isSelfManaged = true; } + if (campaign.allowDeny && campaign.allowDeny[0]) { + allowedFilter = campaign.allowDeny[0].allowed; + } } catch (e) { addToast('Failed to load campaign', 'Error'); console.error('failed to load campaign', e); @@ -1267,7 +1271,7 @@ > - {campaign?.allowDeny ? (campaign.allowDeny.allowed ? 'Allow' : 'Deny') : ''} + {campaign?.allowDeny ? (allowedFilter ? 'Allow' : 'Deny') : ''} IP Filters: diff --git a/frontend/src/routes/filter/+page.svelte b/frontend/src/routes/filter/+page.svelte index 2c91e70..89654a6 100644 --- a/frontend/src/routes/filter/+page.svelte +++ b/frontend/src/routes/filter/+page.svelte @@ -33,6 +33,7 @@ import DeleteAlert from '$lib/components/modal/DeleteAlert.svelte'; import SelectSquare from '$lib/components/SelectSquare.svelte'; import TableCellScope from '$lib/components/table/TableCellScope.svelte'; + import TextFieldMultiSelect from '$lib/components/TextFieldMultiSelect.svelte'; // services const appStateService = AppStateService.instance; @@ -44,6 +45,7 @@ name: null, cidrs: null, ja4Fingerprints: null, + countryCodes: [], allowed: null }; let allowDenyList = []; @@ -56,6 +58,7 @@ let isTableLoading = false; let modalMode = null; let modalText = ''; + let availableCountryCodes = []; let isDeleteAlertVisible = false; let deleteValues = { @@ -74,6 +77,7 @@ } refreshAllowDenies(); tableURLParams.onChange(refreshAllowDenies); + loadGeoIPMetadata(); (async () => { const editID = $page.url.searchParams.get('edit'); @@ -87,6 +91,18 @@ }; }); + // load geoip metadata to get country codes + const loadGeoIPMetadata = async () => { + try { + const res = await api.geoip.getMetadata(); + if (res.success && res.data) { + availableCountryCodes = res.data.country_codes || []; + } + } catch (e) { + console.error('failed to load geoip metadata', e); + } + }; + // component logic const refreshAllowDenies = async () => { try { @@ -134,12 +150,13 @@ }; const onClickSubmit = async () => { - // validate that at least one of cidrs or ja4Fingerprints is provided + // validate that at least one of cidrs, ja4Fingerprints, or countryCodes is provided const hasCidrs = formValues.cidrs && formValues.cidrs.trim().length > 0; const hasJA4 = formValues.ja4Fingerprints && formValues.ja4Fingerprints.trim().length > 0; + const hasCountryCodes = formValues.countryCodes && formValues.countryCodes.length > 0; - if (!hasCidrs && !hasJA4) { - formError = 'At least one of CIDRs or JA4 fingerprints must be provided'; + if (!hasCidrs && !hasJA4 && !hasCountryCodes) { + formError = 'At least one of CIDRs, JA4 fingerprints, or Country Codes must be provided'; return; } @@ -176,6 +193,7 @@ name: formValues.name, cidrs: formValues.cidrs, ja4Fingerprints: formValues.ja4Fingerprints || '', + countryCodes: formValues.countryCodes.join('\n'), allowed: formValues.allowed, companyID: contextCompanyID }); @@ -212,6 +230,7 @@ name: formValues.name, cidrs: formValues.cidrs, ja4Fingerprints: formValues.ja4Fingerprints || '', + countryCodes: formValues.countryCodes.join('\n'), companyID: formValues.companyID }); if (res.success) { @@ -306,11 +325,21 @@ }; const assignAllowDeny = (allowDeny) => { + // parse country codes from newline-separated string to array + let countryCodesArray = []; + if (allowDeny.countryCodes) { + countryCodesArray = allowDeny.countryCodes + .split('\n') + .map((code) => code.trim()) + .filter((code) => code.length > 0); + } + formValues = { id: allowDeny.id, name: allowDeny.name, cidrs: allowDeny.cidrs, ja4Fingerprints: allowDeny.ja4Fingerprints || '', + countryCodes: countryCodesArray, allowed: allowDeny.allowed, companyID: allowDeny.companyID }; @@ -439,8 +468,18 @@ toolTipText="Newlines separated JA4 fingerprints (optional)" >JA4 Fingerprints + + Country Codes +

- Note: At least one of CIDRs or JA4 Fingerprints must be provided. + Note: At least one of CIDRs, JA4 Fingerprints, or Country Codes must be provided.