Add geo IP checking

This commit is contained in:
Ronni Skansing
2025-11-09 11:19:32 +01:00
parent 75cba58f9f
commit 9c5acbed90
12 changed files with 436 additions and 30 deletions
+4
View File
@@ -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).
+5
View File
@@ -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,
+26 -9
View File
@@ -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)
+34
View File
@@ -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)
}
+1
View File
@@ -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;"`
}
+185
View File
@@ -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
}
+87 -3
View File
@@ -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
}
+24 -11
View File
@@ -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
}
+2
View File
@@ -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,
}
+20 -2
View File
@@ -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<ApiResponse>}
*/
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<ApiResponse>}
*/
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<ApiResponse>}
*/
getMetadata: async () => {
return await getJSON(this.getPath('/geoip/metadata'));
}
};
/**
* webhook is the API for web hook related operations.
*/
@@ -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 @@
>
<span class="text-grayblue-dark font-medium">
{campaign?.allowDeny ? (campaign.allowDeny.allowed ? 'Allow' : 'Deny') : ''}
{campaign?.allowDeny ? (allowedFilter ? 'Allow' : 'Deny') : ''}
IP Filters:
</span>
<span class="text-pc-darkblue dark:text-white">
+43 -4
View File
@@ -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</TextareaField
>
<TextFieldMultiSelect
id="country-codes"
optional
bind:value={formValues.countryCodes}
options={availableCountryCodes}
placeholder="Select countries..."
toolTipText="Select country codes to filter (optional)"
>
Country Codes
</TextFieldMultiSelect>
<p style="font-size: 0.875rem; color: #666; margin-top: 0.5rem;">
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.
</p>
</FormColumn>
</FormColumns>