diff --git a/modules/l4rdp/matcher.go b/modules/l4rdp/matcher.go index 47e8d87..3be133b 100644 --- a/modules/l4rdp/matcher.go +++ b/modules/l4rdp/matcher.go @@ -21,6 +21,7 @@ import ( "io" "net" "net/netip" + "regexp" "strconv" "strings" @@ -35,12 +36,17 @@ func init() { // MatchRDP is able to match RDP connections. type MatchRDP struct { - CookieHash string `json:"cookie_hash,omitempty"` - CookieIPs []string `json:"cookie_ips,omitempty"` - CookiePorts []uint16 `json:"cookie_ports,omitempty"` - CustomInfo string `json:"custom_info,omitempty"` + CookieHash string `json:"cookie_hash,omitempty"` + CookieHashRegexp string `json:"cookie_hash_regexp,omitempty"` + CookieIPs []string `json:"cookie_ips,omitempty"` + CookiePorts []uint16 `json:"cookie_ports,omitempty"` + CustomInfo string `json:"custom_info,omitempty"` + CustomInfoRegexp string `json:"custom_info_regexp,omitempty"` cookieIPs []netip.Prefix + + cookieHashRegexp *regexp.Regexp + customInfoRegexp *regexp.Regexp } // CaddyModule returns the Caddy module information. @@ -142,16 +148,22 @@ func (m *MatchRDP) Match(cx *layer4.Connection) (bool, error) { repl := cx.Context.Value(layer4.ReplacerCtxKey).(*caddy.Replacer) repl.Set("l4.rdp.cookie_hash", hash) + // Full match if len(m.CookieHash) > 0 && m.CookieHash != hash { break } + // Regexp match + if len(m.CookieHashRegexp) > 0 && !m.cookieHashRegexp.MatchString(hash) { + break + } + hasValidCookie = true break } // NOTE: we can stop validation because hash hasn't matched - if !hasValidCookie && len(m.CookieHash) > 0 { + if !hasValidCookie && (len(m.CookieHash) > 0 || len(m.CookieHashRegexp) > 0) { return false, nil } @@ -296,16 +308,22 @@ func (m *MatchRDP) Match(cx *layer4.Connection) (bool, error) { repl := cx.Context.Value(layer4.ReplacerCtxKey).(*caddy.Replacer) repl.Set("l4.rdp.custom_info", info) + // Full match if len(m.CustomInfo) > 0 && m.CustomInfo != info { break } + // Regexp match + if len(m.CustomInfoRegexp) > 0 && !m.customInfoRegexp.MatchString(info) { + break + } + hasValidCustom = true break } // NOTE: we can stop validation because info hasn't matched - if !hasValidCustom && len(m.CustomInfo) > 0 { + if !hasValidCustom && (len(m.CustomInfo) > 0 || len(m.CustomInfoRegexp) > 0) { return false, nil } @@ -398,12 +416,20 @@ func (m *MatchRDP) Match(cx *layer4.Connection) (bool, error) { return true, nil } -// Provision parses m's IP ranges, either from IP or CIDR expressions. +// Provision parses m's IP ranges, either from IP or CIDR expressions, and regular expressions. func (m *MatchRDP) Provision(_ caddy.Context) (err error) { m.cookieIPs, err = layer4.ParseNetworks(m.CookieIPs) if err != nil { return err } + m.cookieHashRegexp, err = regexp.Compile(m.CookieHashRegexp) + if err != nil { + return err + } + m.customInfoRegexp, err = regexp.Compile(m.CustomInfoRegexp) + if err != nil { + return err + } return nil } @@ -413,12 +439,18 @@ func (m *MatchRDP) Provision(_ caddy.Context) (err error) { // cookie_hash // } // rdp { +// cookie_hash_regexp +// } +// rdp { // cookie_ip // cookie_port // } // rdp { // custom_info // } +// rdp { +// custom_info_regexp +// } // rdp // // Note: according to the protocol documentation, RDP cookies and tokens are optional, i.e. it depends on the client @@ -456,6 +488,18 @@ func (m *MatchRDP) UnmarshalCaddyfile(d *caddyfile.Dispenser) error { } _, val := d.NextArg(), d.Val() m.CookieHash, hasCookieHash = val[:min(RDPCookieHashBytesMax, uint16(len(val)))], true + case "cookie_hash_regexp": + if hasCookieIPOrPort || hasCustomInfo { + return d.Errf("%s option '%s' can't be combined with other options", wrapper, optionName) + } + if hasCookieHash { + return d.Errf("duplicate %s option '%s'", wrapper, optionName) + } + if d.CountRemainingArgs() != 1 { + return d.ArgErr() + } + _, val := d.NextArg(), d.Val() + m.CookieHashRegexp, hasCookieHash = val[:min(RDPCookieHashBytesMax, uint16(len(val)))], true case "cookie_ip": if hasCookieHash || hasCustomInfo { return d.Errf("%s option '%s' can only be combined with 'cookie_port' option", wrapper, optionName) @@ -499,6 +543,18 @@ func (m *MatchRDP) UnmarshalCaddyfile(d *caddyfile.Dispenser) error { } _, val := d.NextArg(), d.Val() m.CustomInfo, hasCustomInfo = val[:min(RDPCustomInfoBytesMax, uint16(len(val)))], true + case "custom_info_regexp": + if hasCookieHash || hasCookieIPOrPort { + return d.Errf("%s option '%s' can't be combined with other options", wrapper, optionName) + } + if hasCustomInfo { + return d.Errf("duplicate %s option '%s'", wrapper, optionName) + } + if d.CountRemainingArgs() != 1 { + return d.ArgErr() + } + _, val := d.NextArg(), d.Val() + m.CustomInfoRegexp, hasCustomInfo = val[:min(RDPCustomInfoBytesMax, uint16(len(val)))], true default: return d.ArgErr() } diff --git a/modules/l4rdp/matcher_test.go b/modules/l4rdp/matcher_test.go index 200c977..223d5a9 100644 --- a/modules/l4rdp/matcher_test.go +++ b/modules/l4rdp/matcher_test.go @@ -174,6 +174,9 @@ func Test_MatchRDP_Match(t *testing.T) { {matcher: &MatchRDP{CookieHash: ""}, data: packetValid3, shouldMatch: true}, {matcher: &MatchRDP{CookieHash: "a0123"}, data: packetValid3, shouldMatch: true}, {matcher: &MatchRDP{CookieHash: "admin"}, data: packetValid3, shouldMatch: false}, + {matcher: &MatchRDP{CookieHashRegexp: ""}, data: packetValid3, shouldMatch: true}, + {matcher: &MatchRDP{CookieHashRegexp: "^[a-z]\\d+$"}, data: packetValid3, shouldMatch: true}, + {matcher: &MatchRDP{CookieHashRegexp: "^[A-Z]\\d+$"}, data: packetValid3, shouldMatch: false}, // with filtered port {matcher: &MatchRDP{CookiePorts: []uint16{}}, data: packetValid5, shouldMatch: true}, {matcher: &MatchRDP{CookiePorts: []uint16{3389}}, data: packetValid5, shouldMatch: true}, @@ -186,6 +189,9 @@ func Test_MatchRDP_Match(t *testing.T) { {matcher: &MatchRDP{CustomInfo: ""}, data: packetValid9, shouldMatch: true}, {matcher: &MatchRDP{CustomInfo: "anything could be here"}, data: packetValid9, shouldMatch: true}, {matcher: &MatchRDP{CustomInfo: "arbitrary text"}, data: packetValid9, shouldMatch: false}, + {matcher: &MatchRDP{CustomInfoRegexp: ""}, data: packetValid9, shouldMatch: true}, + {matcher: &MatchRDP{CustomInfoRegexp: "^([A-Za-z0-9 ]+)$"}, data: packetValid9, shouldMatch: true}, + {matcher: &MatchRDP{CustomInfoRegexp: "^\\x00\\x01\\x02\\x03\\x04$"}, data: packetValid9, shouldMatch: false}, } ctx, cancel := caddy.NewContext(caddy.Context{Context: context.Background()})