mirror of
https://github.com/phishingclub/phishingclub.git
synced 2026-02-13 00:22:49 +00:00
511 lines
12 KiB
Go
511 lines
12 KiB
Go
package model
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"net"
|
|
"regexp"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/go-errors/errors"
|
|
|
|
"github.com/google/uuid"
|
|
"github.com/oapi-codegen/nullable"
|
|
"github.com/phishingclub/phishingclub/errs"
|
|
"github.com/phishingclub/phishingclub/validate"
|
|
"github.com/phishingclub/phishingclub/vo"
|
|
)
|
|
|
|
// AllowDeny is a model for allow deny listing
|
|
type AllowDeny struct {
|
|
ID nullable.Nullable[uuid.UUID] `json:"id"`
|
|
CreatedAt *time.Time `json:"createdAt"`
|
|
UpdatedAt *time.Time `json:"updatedAt"`
|
|
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"`
|
|
Headers nullable.Nullable[string] `json:"headers"`
|
|
Allowed nullable.Nullable[bool] `json:"allowed"`
|
|
CompanyID nullable.Nullable[uuid.UUID] `json:"companyID"`
|
|
}
|
|
|
|
// Validate checks if the allow deny list has a valid state
|
|
func (r *AllowDeny) Validate() error {
|
|
if err := validate.NullableFieldRequired("name", r.Name); err != nil {
|
|
return err
|
|
}
|
|
if err := validate.NullableFieldRequired("filter type", r.Allowed); err != nil {
|
|
return err
|
|
}
|
|
|
|
// 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 {
|
|
hasCidrs = true
|
|
}
|
|
}
|
|
|
|
hasJA4 := false
|
|
if r.JA4Fingerprints.IsSpecified() {
|
|
if ja4, err := r.JA4Fingerprints.Get(); err == nil && ja4 != "" {
|
|
hasJA4 = true
|
|
}
|
|
}
|
|
|
|
hasCountryCodes := false
|
|
if r.CountryCodes.IsSpecified() {
|
|
if codes, err := r.CountryCodes.Get(); err == nil && codes != "" {
|
|
hasCountryCodes = true
|
|
}
|
|
}
|
|
|
|
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, country codes, or headers must be provided"),
|
|
)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// ToDBMap converts the fields that can be stored or updated to a map
|
|
// if the value is nullable and not set, it is not included
|
|
// if the value is nullable and set, it is included, if it is null, it is set to nil
|
|
func (r *AllowDeny) ToDBMap() map[string]any {
|
|
m := map[string]any{}
|
|
if r.Name.IsSpecified() {
|
|
m["name"] = nil
|
|
if name, err := r.Name.Get(); err == nil {
|
|
m["name"] = name.String()
|
|
}
|
|
}
|
|
if r.Cidrs.IsSpecified() {
|
|
m["cidrs"] = nil
|
|
if cidrs, err := r.Cidrs.Get(); err == nil {
|
|
cidrsStr := ""
|
|
cidrsLen := len(cidrs)
|
|
for i, cidr := range cidrs {
|
|
if i == cidrsLen {
|
|
cidrsStr += fmt.Sprintf("%s", cidr.String())
|
|
|
|
} else {
|
|
cidrsStr += fmt.Sprintf("%s\n", cidr.String())
|
|
}
|
|
}
|
|
m["cidrs"] = cidrsStr
|
|
}
|
|
}
|
|
if r.JA4Fingerprints.IsSpecified() {
|
|
m["ja4_fingerprints"] = ""
|
|
if ja4, err := r.JA4Fingerprints.Get(); err == nil {
|
|
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.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 {
|
|
m["allowed"] = allowed
|
|
}
|
|
}
|
|
if r.CompanyID.IsSpecified() {
|
|
if r.CompanyID.IsNull() {
|
|
m["company_id"] = nil
|
|
} else {
|
|
m["company_id"] = r.CompanyID.MustGet()
|
|
}
|
|
}
|
|
|
|
return m
|
|
}
|
|
|
|
func (r *AllowDeny) IsIPAllowed(ip string) (bool, error) {
|
|
isTypeAllowList := r.Allowed.MustGet()
|
|
|
|
// if no cidrs configured, skip ip check (always pass)
|
|
cidrs, err := r.Cidrs.Get()
|
|
if err != nil || len(cidrs) == 0 {
|
|
return true, nil
|
|
}
|
|
|
|
netIP := net.ParseIP(ip)
|
|
if netIP == nil {
|
|
return false, fmt.Errorf("invalid ip address: %s", ip)
|
|
}
|
|
|
|
for _, cidr := range cidrs {
|
|
isInRange := cidr.Contains(netIP)
|
|
// if allow list and ip is within range
|
|
if isTypeAllowList && isInRange {
|
|
return true, nil
|
|
}
|
|
// if deny list and ip is within range
|
|
if !isTypeAllowList && isInRange {
|
|
return false, nil
|
|
}
|
|
}
|
|
|
|
// If this is an allow list and we didn't find the IP, it's not allowed
|
|
if isTypeAllowList {
|
|
return false, nil
|
|
}
|
|
|
|
// If this is a deny list and we didn't find the IP, it is allowed
|
|
return true, nil
|
|
}
|
|
|
|
// IsJA4Allowed checks if a JA4 fingerprint is allowed based on the filter rules
|
|
func (r *AllowDeny) IsJA4Allowed(ja4 string) (bool, error) {
|
|
if ja4 == "" {
|
|
// if no ja4 fingerprint available, skip ja4 check
|
|
return true, nil
|
|
}
|
|
|
|
isTypeAllowList := r.Allowed.MustGet()
|
|
|
|
// get ja4 fingerprints list
|
|
ja4FingerprintsStr, err := r.JA4Fingerprints.Get()
|
|
if err != nil || ja4FingerprintsStr == "" {
|
|
// if no ja4 fingerprints configured, skip ja4 check
|
|
return true, nil
|
|
}
|
|
|
|
// parse fingerprints (newline separated)
|
|
fingerprints := parseFingerprints(ja4FingerprintsStr)
|
|
|
|
// check if ja4 matches any fingerprint (supports wildcard patterns with *)
|
|
isMatch := false
|
|
for _, fp := range fingerprints {
|
|
if matchJA4Pattern(fp, ja4) {
|
|
isMatch = true
|
|
break
|
|
}
|
|
}
|
|
|
|
// if allow list and ja4 matches
|
|
if isTypeAllowList && isMatch {
|
|
return true, nil
|
|
}
|
|
// if deny list and ja4 matches
|
|
if !isTypeAllowList && isMatch {
|
|
return false, nil
|
|
}
|
|
|
|
// If this is an allow list and ja4 didn't match, not allowed
|
|
if isTypeAllowList {
|
|
return false, nil
|
|
}
|
|
|
|
// If this is a deny list and ja4 didn't match, it is allowed
|
|
return true, nil
|
|
}
|
|
|
|
// matchJA4Pattern checks if a JA4 fingerprint matches a pattern with wildcard support
|
|
// supports * as wildcard to match any characters
|
|
// examples:
|
|
//
|
|
// t13d151*h2_8daaf6152771_* matches any fingerprint with that prefix and cipher hash
|
|
// t13d*_*_* matches any TLS 1.3 fingerprint with SNI
|
|
// * matches everything
|
|
func matchJA4Pattern(pattern, ja4 string) bool {
|
|
// exact match (no wildcards)
|
|
if pattern == ja4 {
|
|
return true
|
|
}
|
|
|
|
// if pattern doesn't contain *, no match
|
|
if !strings.Contains(pattern, "*") {
|
|
return false
|
|
}
|
|
|
|
// wildcard-only pattern matches everything
|
|
if pattern == "*" {
|
|
return true
|
|
}
|
|
|
|
// split pattern by * and check each part exists in order
|
|
parts := strings.Split(pattern, "*")
|
|
pos := 0
|
|
|
|
for i, part := range parts {
|
|
if part == "" {
|
|
continue
|
|
}
|
|
|
|
// find the part in the remaining string
|
|
idx := strings.Index(ja4[pos:], part)
|
|
if idx == -1 {
|
|
return false
|
|
}
|
|
|
|
// for first part, must match at beginning
|
|
if i == 0 && idx != 0 {
|
|
return false
|
|
}
|
|
|
|
pos += idx + len(part)
|
|
}
|
|
|
|
// for last part, must match at end
|
|
lastPart := parts[len(parts)-1]
|
|
if lastPart != "" && !strings.HasSuffix(ja4, lastPart) {
|
|
return false
|
|
}
|
|
|
|
return true
|
|
}
|
|
|
|
// parseFingerprints splits newline-separated fingerprints and trims whitespace
|
|
func parseFingerprints(input string) []string {
|
|
var result []string
|
|
lines := splitLines(input)
|
|
for _, line := range lines {
|
|
trimmed := trimSpace(line)
|
|
if trimmed != "" {
|
|
result = append(result, trimmed)
|
|
}
|
|
}
|
|
return result
|
|
}
|
|
|
|
// splitLines splits a string by newlines
|
|
func splitLines(s string) []string {
|
|
var lines []string
|
|
start := 0
|
|
for i := 0; i < len(s); i++ {
|
|
if s[i] == '\n' {
|
|
lines = append(lines, s[start:i])
|
|
start = i + 1
|
|
}
|
|
}
|
|
if start < len(s) {
|
|
lines = append(lines, s[start:])
|
|
}
|
|
return lines
|
|
}
|
|
|
|
// trimSpace removes leading and trailing whitespace
|
|
func trimSpace(s string) string {
|
|
start := 0
|
|
end := len(s)
|
|
|
|
for start < end && isSpace(s[start]) {
|
|
start++
|
|
}
|
|
for end > start && isSpace(s[end-1]) {
|
|
end--
|
|
}
|
|
|
|
return s[start:end]
|
|
}
|
|
|
|
// isSpace checks if a byte is whitespace
|
|
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
|
|
}
|
|
|
|
// 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
|
|
}
|