diff --git a/backend/app/server.go b/backend/app/server.go index 9c2901b..d4b9a2c 100644 --- a/backend/app/server.go +++ b/backend/app/server.go @@ -2300,12 +2300,20 @@ func (s *Server) checkIPFilter( // check country code filter countryOk := allowDeny.IsCountryAllowed(countryCode) - // for allow lists: all filters (IP, JA4, country) must pass + + // check header filter + headers := ctx.Request.Header + headerOk, err := allowDeny.IsHeaderAllowed(headers) + if err != nil { + return false, errs.Wrap(err) + } + + // for allow lists: all filters (IP, JA4, country, headers) must pass // for deny lists: any filter failing blocks the request if isAllowListing { // allow list: all must be allowed - if ipOk && ja4Ok && countryOk { - s.logger.Debugw("IP, JA4, and country are allow listed", + if ipOk && ja4Ok && countryOk && headerOk { + s.logger.Debugw("IP, JA4, country, and headers are allow listed", "ip", ip, "ja4", ja4, "country", countryCode, @@ -2317,14 +2325,15 @@ func (s *Server) checkIPFilter( } } else { // deny list: if any filter denies, block the request - if !ipOk || !ja4Ok || !countryOk { - s.logger.Debugw("IP, JA4, or country is deny listed", + if !ipOk || !ja4Ok || !countryOk || !headerOk { + s.logger.Debugw("IP, JA4, country, or headers is deny listed", "ip", ip, "ja4", ja4, "country", countryCode, "ipOk", ipOk, "ja4Ok", ja4Ok, "countryOk", countryOk, + "headerOk", headerOk, "list name", allowDeny.Name.MustGet().String(), "list id", allowDeny.ID.MustGet().String(), ) @@ -2334,7 +2343,7 @@ func (s *Server) checkIPFilter( } } if !allowed { - s.logger.Debugw("IP, JA4, or country is not allowed", + s.logger.Debugw("IP, JA4, country, or headers is not allowed", "ip", ip, "ja4", ja4, "country", countryCode, diff --git a/backend/database/allowDeny.go b/backend/database/allowDeny.go index b943a75..e845abb 100644 --- a/backend/database/allowDeny.go +++ b/backend/database/allowDeny.go @@ -21,6 +21,7 @@ type AllowDeny struct { Cidrs string `gorm:"not null;default:''"` JA4Fingerprints string `gorm:"not null;default:''"` CountryCodes string `gorm:"not null;default:''"` + Headers string `gorm:"not null;default:''"` Allowed bool `gorm:"not null;"` } diff --git a/backend/model/allowDeny.go b/backend/model/allowDeny.go index c8f67e7..827feba 100644 --- a/backend/model/allowDeny.go +++ b/backend/model/allowDeny.go @@ -1,8 +1,10 @@ package model import ( + "encoding/json" "fmt" "net" + "regexp" "strings" "time" @@ -24,6 +26,7 @@ type AllowDeny struct { Cidrs nullable.Nullable[vo.IPNetSlice] `json:"cidrs"` JA4Fingerprints nullable.Nullable[string] `json:"ja4Fingerprints"` CountryCodes nullable.Nullable[string] `json:"countryCodes"` + Headers nullable.Nullable[string] `json:"headers"` Allowed nullable.Nullable[bool] `json:"allowed"` CompanyID nullable.Nullable[uuid.UUID] `json:"companyID"` } @@ -37,7 +40,7 @@ func (r *AllowDeny) Validate() error { return err } - // at least one of cidrs, ja4 fingerprints, or country codes must be provided + // at least one of cidrs, ja4 fingerprints, country codes, or headers must be provided hasCidrs := false if r.Cidrs.IsSpecified() { if cidrs, err := r.Cidrs.Get(); err == nil && len(cidrs) > 0 { @@ -59,9 +62,16 @@ func (r *AllowDeny) Validate() error { } } - if !hasCidrs && !hasJA4 && !hasCountryCodes { + hasHeaders := false + if r.Headers.IsSpecified() { + if headers, err := r.Headers.Get(); err == nil && headers != "" { + hasHeaders = true + } + } + + if !hasCidrs && !hasJA4 && !hasCountryCodes && !hasHeaders { return errs.NewValidationError( - errors.New("at least one of CIDRs, JA4 fingerprints, or country codes must be provided"), + errors.New("at least one of CIDRs, JA4 fingerprints, country codes, or headers must be provided"), ) } @@ -107,6 +117,12 @@ func (r *AllowDeny) ToDBMap() map[string]any { m["country_codes"] = codes } } + if r.Headers.IsSpecified() { + m["headers"] = "" + if headers, err := r.Headers.Get(); err == nil { + m["headers"] = headers + } + } if r.Allowed.IsSpecified() { m["allowed"] = nil if allowed, err := r.Allowed.Get(); err == nil { @@ -378,3 +394,117 @@ func parseCountryCodes(input string) []string { } return result } + +// IsHeaderAllowed checks if request headers are allowed based on the filter rules +// headers parameter is a map of header key-value pairs from the HTTP request +func (r *AllowDeny) IsHeaderAllowed(headers map[string][]string) (bool, error) { + if headers == nil || len(headers) == 0 { + // if no headers provided, skip header check + return true, nil + } + + isTypeAllowList := r.Allowed.MustGet() + + // get headers filter configuration + headersStr, err := r.Headers.Get() + if err != nil || headersStr == "" { + // if no headers configured, skip header check + return true, nil + } + + // parse header rules from json array + rules, err := parseHeaderRules(headersStr) + if err != nil { + return false, err + } + + if len(rules) == 0 { + // if no valid rules, skip header check + return true, nil + } + + // check if any header rule matches + isMatch := false + for _, rule := range rules { + matched, err := matchHeaderRule(rule, headers) + if err != nil { + continue + } + if matched { + isMatch = true + break + } + } + + // if allow list and header matches + if isTypeAllowList && isMatch { + return true, nil + } + // if deny list and header matches + if !isTypeAllowList && isMatch { + return false, nil + } + + // If this is an allow list and header didn't match, not allowed + if isTypeAllowList { + return false, nil + } + + // If this is a deny list and header didn't match, it is allowed + return true, nil +} + +// HeaderRule represents a header matching rule with key and value regex patterns +type HeaderRule struct { + KeyRegex string `json:"keyRegex"` + ValueRegex string `json:"valueRegex"` +} + +// parseHeaderRules parses json array of header rules +func parseHeaderRules(input string) ([]HeaderRule, error) { + var rules []HeaderRule + + if err := json.Unmarshal([]byte(input), &rules); err != nil { + return nil, errors.New("invalid header rules json format") + } + + // validate rules + for _, rule := range rules { + if rule.KeyRegex == "" || rule.ValueRegex == "" { + return nil, errors.New("header key regex and value regex cannot be empty") + } + } + + return rules, nil +} + +// matchHeaderRule checks if any request header matches the rule +// both key and value must match their respective regex patterns +func matchHeaderRule(rule HeaderRule, headers map[string][]string) (bool, error) { + keyRegex, err := regexp.Compile(rule.KeyRegex) + if err != nil { + return false, err + } + valueRegex, err := regexp.Compile(rule.ValueRegex) + if err != nil { + return false, err + } + + // iterate through all headers in the request + for headerKey, headerValues := range headers { + // check if header key matches the key regex + if !keyRegex.MatchString(headerKey) { + continue + } + + // if key matches, check if any value matches the value regex + for _, headerValue := range headerValues { + if valueRegex.MatchString(headerValue) { + // both key and value match + return true, nil + } + } + } + + return false, nil +} diff --git a/backend/proxy/proxy.go b/backend/proxy/proxy.go index 3e36cd9..dd737c5 100644 --- a/backend/proxy/proxy.go +++ b/backend/proxy/proxy.go @@ -4909,17 +4909,25 @@ func (m *ProxyHandler) checkFilter(req *http.Request, reqCtx *RequestContext) (b // check country code filter countryOk := allowDeny.IsCountryAllowed(countryCode) - // for allow lists: all filters (IP, JA4, country) must pass + + // check header filter + headers := req.Header + headerOk, err := allowDeny.IsHeaderAllowed(headers) + if err != nil { + continue + } + + // for allow lists: all filters (IP, JA4, country, headers) must pass // for deny lists: any filter failing blocks the request if isAllowListing { // allow list: all must be allowed - if ipOk && ja4Ok && countryOk { + if ipOk && ja4Ok && countryOk && headerOk { allowed = true break } } else { // deny list: if any filter denies, block the request - if !ipOk || !ja4Ok || !countryOk { + if !ipOk || !ja4Ok || !countryOk || !headerOk { allowed = false break } diff --git a/backend/repository/allowDeny.go b/backend/repository/allowDeny.go index 1034845..ea15c9a 100644 --- a/backend/repository/allowDeny.go +++ b/backend/repository/allowDeny.go @@ -188,6 +188,7 @@ func ToAllowDeny(row *database.AllowDeny) *model.AllowDeny { ja4Fingerprints := nullable.NewNullableWithValue(row.JA4Fingerprints) countryCodes := nullable.NewNullableWithValue(row.CountryCodes) + headers := nullable.NewNullableWithValue(row.Headers) return &model.AllowDeny{ ID: id, @@ -197,6 +198,7 @@ func ToAllowDeny(row *database.AllowDeny) *model.AllowDeny { Cidrs: cidrsNullable, JA4Fingerprints: ja4Fingerprints, CountryCodes: countryCodes, + Headers: headers, Allowed: nullable.NewNullableWithValue(row.Allowed), CompanyID: companyID, } diff --git a/backend/seed/migrate.go b/backend/seed/migrate.go index f434e2d..e42be0f 100644 --- a/backend/seed/migrate.go +++ b/backend/seed/migrate.go @@ -383,5 +383,20 @@ func migrate(db *gorm.DB) error { return errs.Wrap(err) } + // migration for allow_denies.headers + // first add column as nullable + if err := db.Exec(`ALTER TABLE allow_denies ADD COLUMN headers TEXT`).Error; err != nil { + // column might already exist, ignore error + errMsg := strings.ToLower(err.Error()) + if !strings.Contains(errMsg, "duplicate") && !strings.Contains(errMsg, "already exists") { + return errs.Wrap(err) + } + } + + // update existing rows to have empty string default + if err := db.Exec(`UPDATE allow_denies SET headers = '' WHERE headers IS NULL`).Error; err != nil { + return errs.Wrap(err) + } + return nil } diff --git a/backend/service/allowDeny.go b/backend/service/allowDeny.go index 3417934..c4755d9 100644 --- a/backend/service/allowDeny.go +++ b/backend/service/allowDeny.go @@ -133,6 +133,9 @@ func (s *AllowDeny) Update( if v, err := incoming.CountryCodes.Get(); err == nil { current.CountryCodes.Set(v) } + if v, err := incoming.Headers.Get(); err == nil { + current.Headers.Set(v) + } // allow can not be changed as it could mess up a campaign that // uses multiple entries as all entries must be allow or deny. diff --git a/frontend/src/lib/api/api.js b/frontend/src/lib/api/api.js index 9d084b3..c8dba64 100644 --- a/frontend/src/lib/api/api.js +++ b/frontend/src/lib/api/api.js @@ -2823,16 +2823,18 @@ export class API { * @param {string} allowdeny.cidrs * @param {string} allowdeny.ja4Fingerprints * @param {string} allowdeny.countryCodes + * @param {string} allowdeny.headers * @param {boolean} allowdeny.allowed * @param {string} allowdeny.companyID * @returns {Promise} */ - create: async ({ name, cidrs, ja4Fingerprints, countryCodes, allowed, companyID }) => { + create: async ({ name, cidrs, ja4Fingerprints, countryCodes, headers, allowed, companyID }) => { return await postJSON(this.getPath('/allow-deny'), { name: name, cidrs: cidrs, ja4Fingerprints: ja4Fingerprints, countryCodes: countryCodes, + headers: headers, allowed: allowed, companyID: companyID }); @@ -2885,15 +2887,17 @@ export class API { * @param {string} allowdeny.cidrs * @param {string} allowdeny.ja4Fingerprints * @param {string} allowdeny.countryCodes + * @param {string} allowdeny.headers * @param {string} allowdeny.companyID * @returns {Promise} */ - update: async ({ id, name, cidrs, ja4Fingerprints, countryCodes, companyID }) => { + update: async ({ id, name, cidrs, ja4Fingerprints, countryCodes, headers, companyID }) => { return await patchJSON(this.getPath(`/allow-deny/${id}`), { name: name, cidrs: cidrs, ja4Fingerprints: ja4Fingerprints, countryCodes: countryCodes, + headers: headers, companyID: companyID }); }, diff --git a/frontend/src/routes/campaign/+page.svelte b/frontend/src/routes/campaign/+page.svelte index 64b53bf..e44561c 100644 --- a/frontend/src/routes/campaign/+page.svelte +++ b/frontend/src/routes/campaign/+page.svelte @@ -90,7 +90,7 @@ } ]; - const ipFilterOptions = [ + const filteringOptions = [ { label: 'None', value: 'none', @@ -256,7 +256,7 @@ showSecurityOptions = true; } - // reactive statement to clear evasion page and IP filtering when deny page is cleared + // reactive statement to clear evasion page and filtering when deny page is cleared $: if (!formValues.denyPageValue) { if (formValues.evasionPageValue) { formValues.evasionPageValue = null; @@ -583,13 +583,13 @@ }; const validateMisc = () => { - // validate that deny page is selected if evasion page or IP filtering is used + // validate that deny page is selected if evasion page or filtering is used if (formValues.evasionPageValue && !formValues.denyPageValue) { modalError = 'Deny page is required when using an evasion page'; return false; } if (allowDenyType !== 'none' && !formValues.denyPageValue) { - modalError = 'Deny page is required when using IP filtering'; + modalError = 'Deny page is required when using filtering'; return false; } return checkCurrentStepValidity(); @@ -1961,7 +1961,7 @@ id="deny-page" bind:value={formValues.denyPageValue} optional - toolTipText="Page to show when access is denied. Required for evasion pages and IP filtering." + toolTipText="Page to show when access is denied. Required for evasion pages and filtering." onSelect={(page) => { formValues.denyPageValue = page; }} @@ -1997,9 +1997,9 @@
{#if formValues.denyPageValue} { @@ -2011,7 +2011,7 @@
Lists @@ -2022,8 +2022,8 @@ class="p-4 bg-gray-100 dark:bg-gray-800 rounded-lg border-2 border-dashed border-gray-300 dark:border-gray-600" >

- IP filtering mode
- You must select a deny page first to use IP filtering. + Filtering mode
+ You must select a deny page first to use filtering.

{/if} @@ -2288,7 +2288,7 @@
- IP Filtering: + Filtering: {#if allowDenyType === 'none'} None diff --git a/frontend/src/routes/filter/+page.svelte b/frontend/src/routes/filter/+page.svelte index 3112147..fc2bf02 100644 --- a/frontend/src/routes/filter/+page.svelte +++ b/frontend/src/routes/filter/+page.svelte @@ -6,6 +6,7 @@ import { globalButtonDisabledAttributes } from '$lib/utils/form.js'; import Headline from '$lib/components/Headline.svelte'; import TextField from '$lib/components/TextField.svelte'; + import ToolTip from '$lib/components/ToolTip.svelte'; import TableRow from '$lib/components/table/TableRow.svelte'; import TableCell from '$lib/components/table/TableCell.svelte'; import TableUpdateButton from '$lib/components/table/TableUpdateButton.svelte'; @@ -46,6 +47,7 @@ cidrs: null, ja4Fingerprints: null, countryCodes: [], + headers: [], allowed: null }; let allowDenyList = []; @@ -150,13 +152,15 @@ }; const onClickSubmit = async () => { - // validate that at least one of cidrs, ja4Fingerprints, or countryCodes is provided + // validate that at least one of cidrs, ja4Fingerprints, countryCodes, or headers 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; + const hasHeaders = formValues.headers && formValues.headers.length > 0; - if (!hasCidrs && !hasJA4 && !hasCountryCodes) { - formError = 'At least one of CIDRs, JA4 fingerprints, or Country Codes must be provided'; + if (!hasCidrs && !hasJA4 && !hasCountryCodes && !hasHeaders) { + formError = + 'At least one of CIDRs, JA4 fingerprints, Country Codes, or Headers must be provided'; return; } @@ -189,11 +193,17 @@ } try { + // convert headers array to json string + const headersStr = JSON.stringify( + formValues.headers.filter((h) => h.keyRegex && h.valueRegex) + ); + const res = await api.allowDeny.create({ name: formValues.name, cidrs: formValues.cidrs, ja4Fingerprints: formValues.ja4Fingerprints || '', countryCodes: formValues.countryCodes.join('\n'), + headers: headersStr, allowed: formValues.allowed, companyID: contextCompanyID }); @@ -225,12 +235,18 @@ } try { + // convert headers array to json string + const headersStr = JSON.stringify( + formValues.headers.filter((h) => h.keyRegex && h.valueRegex) + ); + const res = await api.allowDeny.update({ id: formValues.id, name: formValues.name, cidrs: formValues.cidrs, ja4Fingerprints: formValues.ja4Fingerprints || '', countryCodes: formValues.countryCodes.join('\n'), + headers: headersStr, companyID: formValues.companyID }); if (res.success) { @@ -334,17 +350,37 @@ .filter((code) => code.length > 0); } + // parse headers from json string to array + let headersArray = []; + if (allowDeny.headers) { + try { + headersArray = JSON.parse(allowDeny.headers); + } catch (e) { + console.error('failed to parse headers json', e); + headersArray = []; + } + } + formValues = { id: allowDeny.id, name: allowDeny.name, cidrs: allowDeny.cidrs, ja4Fingerprints: allowDeny.ja4Fingerprints || '', countryCodes: countryCodesArray, + headers: headersArray, allowed: allowDeny.allowed, companyID: allowDeny.companyID }; }; + const addHeaderRule = () => { + formValues.headers = [...formValues.headers, { keyRegex: '', valueRegex: '' }]; + }; + + const removeHeaderRule = (index) => { + formValues.headers = formValues.headers.filter((_, i) => i !== index); + }; + /** @param {string} ip */ const singleIPToCIDR = (ip) => { if (ip.trim() == '') { @@ -438,18 +474,79 @@ bind:value={formValues.allowed} /> {/if} - CIDRs +
+ +
JA4 FingerprintsJA4 Fingerprints + CIDRs - Country Codes + GeoIP Country Codes