diff --git a/.gitignore b/.gitignore index 900a83e..919c978 100644 --- a/.gitignore +++ b/.gitignore @@ -63,3 +63,4 @@ www/html/index.html /src/plugins .DS_Store +/build diff --git a/src/mod/dynamicproxy/Server.go b/src/mod/dynamicproxy/Server.go index 17ec73b..fa9d400 100644 --- a/src/mod/dynamicproxy/Server.go +++ b/src/mod/dynamicproxy/Server.go @@ -75,6 +75,15 @@ func (h *ProxyHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { return } + /* Exploit Detection */ + if sep.detector != nil { + if sep.detector.CheckIsAttack(w, r) { + //Request was handled by exploit detector, log it + h.Parent.logRequest(r, false, 403, "exploit-blocked", domainOnly, "blocked", sep) + return + } + } + // Rate Limit if sep.RequireRateLimit { err := h.handleRateLimitRouting(w, r, sep) diff --git a/src/mod/dynamicproxy/dynamicproxy.go b/src/mod/dynamicproxy/dynamicproxy.go index f90f1d2..5fb873e 100644 --- a/src/mod/dynamicproxy/dynamicproxy.go +++ b/src/mod/dynamicproxy/dynamicproxy.go @@ -391,6 +391,8 @@ func CopyEndpoint(endpoint *ProxyEndpoint) *ProxyEndpoint { if err != nil { return nil } + // Initialize the exploit detector for the copied endpoint + newProxyEndpoint.InitializeExploitDetector() return &newProxyEndpoint } diff --git a/src/mod/dynamicproxy/exploits/exploits.go b/src/mod/dynamicproxy/exploits/exploits.go index ff5e6dd..79b0055 100644 --- a/src/mod/dynamicproxy/exploits/exploits.go +++ b/src/mod/dynamicproxy/exploits/exploits.go @@ -4,6 +4,14 @@ package exploits exploits.go This file is used to define routing rules that blocks common exploits. + These include SQL injection, file injection, and other common attack patterns. + It can also detect requests made by AI crawlers or bots based on their user-agent strings. + + Warning: This is not a complete security solution. Sometimes it might misfire and block legitimate requests. + Use with caution and always monitor the logs. + + References: + https://github.com/NginxProxyManager/nginx-proxy-manager/blob/bb0f4bfa626bfa30e3ad2fa31b5d759c9de98559/docker/rootfs/etc/nginx/conf.d/include/block-exploits.conf */ @@ -15,11 +23,80 @@ import ( agents "github.com/monperrus/crawler-user-agents" ) +type ExploitsRequestResponseType int + +const ( + ExploitRequestResponseTypeNotFound ExploitsRequestResponseType = iota //Respond with 404 Not Found + ExploitRequestResponseTypeForbidden //Respond with 403 Forbidden + ExploitRequestResponseTypeBadRequest //Respond with 400 Bad Request + ExploitRequestResponseTypeDropConnection //Drop the connection without responding + ExploitRequestResponseTypeCaptcha //Present a captcha challenge +) + type Detector struct { + CheckCommonExploits bool + CheckAiBots bool + ExploitRespType ExploitsRequestResponseType } -func NewExploitDetector() *Detector { - return &Detector{} +func NewExploitDetector(CheckCommonExploits bool, CheckAiBots bool, ExploitRespType ExploitsRequestResponseType) *Detector { + return &Detector{ + CheckCommonExploits: CheckCommonExploits, + CheckAiBots: CheckAiBots, + ExploitRespType: ExploitRespType, + } +} + +// CheckIsAttack checks if the request is an attack based on common exploits +// return true if the request is handled +func (d *Detector) CheckIsAttack(w http.ResponseWriter, r *http.Request) bool { + if d.CheckCommonExploits && d.RequestContainCommonExploits(r) { + return d.handleExploitResponse(w, r, d.ExploitRespType) + } + if d.CheckAiBots && d.RequestIsMadeByAiCrawlerOrBots(r) { + return d.handleExploitResponse(w, r, d.ExploitRespType) + } + return false +} + +// GetResponseStatusCodeFromResponseType converts the response type to HTTP status code +func (d *Detector) GetResponseStatusCode() int { + respType := d.ExploitRespType + switch respType { + case ExploitRequestResponseTypeNotFound: + return http.StatusNotFound + case ExploitRequestResponseTypeForbidden: + return http.StatusForbidden + case ExploitRequestResponseTypeBadRequest: + return http.StatusBadRequest + default: + return http.StatusForbidden + } +} + +func (d *Detector) handleExploitResponse(w http.ResponseWriter, r *http.Request, respType ExploitsRequestResponseType) bool { + isHandled := true + switch respType { + case ExploitRequestResponseTypeNotFound: + http.NotFound(w, r) + case ExploitRequestResponseTypeForbidden: + http.Error(w, "Forbidden", http.StatusForbidden) + case ExploitRequestResponseTypeBadRequest: + http.Error(w, "Bad Request", http.StatusBadRequest) + case ExploitRequestResponseTypeDropConnection: + // Drop the connection without responding + hj, ok := w.(http.Hijacker) + if ok { + conn, _, err := hj.Hijack() + if err == nil { + conn.Close() + } + } + + case ExploitRequestResponseTypeCaptcha: + // Present a captcha challenge + } + return isHandled } // RequestContainCommonExploits checks if the request contains common exploits @@ -101,8 +178,31 @@ func (d *Detector) RequestContainCommonExploits(r *http.Request) bool { return false } -// RequestIsMadeByBots checks if the request is made by bots or crawlers -func (d *Detector) RequestIsMadeByBots(r *http.Request) bool { +func (d *Detector) RequestIsMadeByAiCrawlerOrBots(r *http.Request) bool { userAgent := r.UserAgent() + if userAgent == "" { + return false + } + + aiBotPatterns := []string{ + `(?i)openai`, + `(?i)chatgpt`, + `(?i)gpt-?`, + `(?i)claude`, + `(?i)anthropic`, + `(?i)perplexity`, + `(?i)perplexitybot`, + `(?i)bingbot`, + `(?i)bingpreview`, + `(?i)serpapi`, + } + + for _, pattern := range aiBotPatterns { + if match, _ := regexp.MatchString(pattern, userAgent); match { + return true + } + } + + // Fallback: treat known crawlers as bots too return agents.IsCrawler(userAgent) } diff --git a/src/mod/dynamicproxy/redirection/redirection.go b/src/mod/dynamicproxy/redirection/redirection.go index 15a3b14..a7de77c 100644 --- a/src/mod/dynamicproxy/redirection/redirection.go +++ b/src/mod/dynamicproxy/redirection/redirection.go @@ -2,7 +2,6 @@ package redirection import ( "encoding/json" - "fmt" "log" "os" "path" @@ -215,7 +214,6 @@ func (t *RuleTable) MatchRedirectRule(requestedURL string) *RedirectRules { //Check matching based on exact match requirement var matched bool if rule.RequireExactMatch { - fmt.Println(requestedURL, keyStr) //Exact match required if t.CaseSensitive { matched = requestedURL == keyStr diff --git a/src/mod/dynamicproxy/router.go b/src/mod/dynamicproxy/router.go index 2e484d2..49500af 100644 --- a/src/mod/dynamicproxy/router.go +++ b/src/mod/dynamicproxy/router.go @@ -8,6 +8,7 @@ import ( "time" "imuslab.com/zoraxy/mod/dynamicproxy/dpcore" + "imuslab.com/zoraxy/mod/dynamicproxy/exploits" "imuslab.com/zoraxy/mod/utils" ) @@ -66,6 +67,9 @@ func (router *Router) PrepareProxyRoute(endpoint *ProxyEndpoint) (*ProxyEndpoint vdir.parent = endpoint } + // Initialize the exploit detector for this endpoint + endpoint.InitializeExploitDetector() + return endpoint, nil } @@ -152,3 +156,13 @@ func (h *Router) GetProxyEndpointByAlias(alias string) (*ProxyEndpoint, error) { } return nil, errors.New("proxy rule with given alias not found") } + +// InitializeExploitDetector initializes or updates the exploit detector for this proxy endpoint +func (pe *ProxyEndpoint) InitializeExploitDetector() { + if pe.BlockCommonExploits || pe.BlockAICrawlers { + exploitRespType := exploits.ExploitsRequestResponseType(pe.MitigationAction) + pe.detector = exploits.NewExploitDetector(pe.BlockCommonExploits, pe.BlockAICrawlers, exploitRespType) + } else { + pe.detector = nil + } +} diff --git a/src/mod/dynamicproxy/typedef.go b/src/mod/dynamicproxy/typedef.go index d207a86..403a367 100644 --- a/src/mod/dynamicproxy/typedef.go +++ b/src/mod/dynamicproxy/typedef.go @@ -18,6 +18,7 @@ import ( "imuslab.com/zoraxy/mod/access" "imuslab.com/zoraxy/mod/auth/sso/forward" "imuslab.com/zoraxy/mod/dynamicproxy/dpcore" + "imuslab.com/zoraxy/mod/dynamicproxy/exploits" "imuslab.com/zoraxy/mod/dynamicproxy/loadbalance" "imuslab.com/zoraxy/mod/dynamicproxy/permissionpolicy" "imuslab.com/zoraxy/mod/dynamicproxy/redirection" @@ -211,6 +212,11 @@ type ProxyEndpoint struct { DisableLogging bool //Disable logging of reverse proxy requests DisableStatisticCollection bool //Disable statistic collection for this endpoint + //Exploit Detection + BlockCommonExploits bool //Enable blocking of common exploits (SQLi, XSS, etc.) + BlockAICrawlers bool //Enable blocking of AI crawlers and bots + MitigationAction int //Action to take when exploit/crawler detected (0=404, 1=403, 2=400, 3=Drop, 4=Delay, 5=Captcha) + // Chunked Transfer Encoding DisableChunkedTransferEncoding bool //Disable chunked transfer encoding for this endpoint @@ -222,8 +228,9 @@ type ProxyEndpoint struct { DefaultSiteValue string //Fallback routing target, optional //Internal Logic Elements - parent *Router `json:"-"` - Tags []string // Tags for the proxy endpoint + parent *Router `json:"-"` //Parent router, excluded from JSON + detector *exploits.Detector `json:"-"` //Exploit detector instance, excluded from JSON + Tags []string // Tags for the proxy endpoint } /* diff --git a/src/reverseproxy.go b/src/reverseproxy.go index 8e9cf77..943837d 100644 --- a/src/reverseproxy.go +++ b/src/reverseproxy.go @@ -343,6 +343,15 @@ func ReverseProxyHandleAddEndpoint(w http.ResponseWriter, r *http.Request) { } tags = filteredTags + // Exploit prevention settings + blockCommonExploits, _ := utils.PostBool(r, "blockCommonExploits") + blockAICrawlers, _ := utils.PostBool(r, "blockAICrawlers") + mitigationActionStr, _ := utils.PostPara(r, "mitigationAction") + mitigationAction := 0 + if mitigationActionStr != "" { + mitigationAction, _ = strconv.Atoi(mitigationActionStr) + } + var proxyEndpointCreated *dynamicproxy.ProxyEndpoint switch eptype { case "host": @@ -420,6 +429,9 @@ func ReverseProxyHandleAddEndpoint(w http.ResponseWriter, r *http.Request) { Tags: tags, DisableUptimeMonitor: !enableUtm, DisableLogging: disableLog, + BlockCommonExploits: blockCommonExploits, + BlockAICrawlers: blockAICrawlers, + MitigationAction: mitigationAction, } preparedEndpoint, err := dynamicProxyRouter.PrepareProxyRoute(&thisProxyEndpoint) @@ -580,6 +592,15 @@ func ReverseProxyHandleEditEndpoint(w http.ResponseWriter, r *http.Request) { // Disable statistic collection disableStatisticCollection, _ := utils.PostBool(r, "dStatisticCollection") + // Exploit Detection + blockCommonExploits, _ := utils.PostBool(r, "blockCommonExploits") + blockAICrawlers, _ := utils.PostBool(r, "blockAICrawlers") + mitigationActionStr, _ := utils.PostPara(r, "mitigationAction") + mitigationAction := 0 + if mitigationActionStr != "" { + mitigationAction, _ = strconv.Atoi(mitigationActionStr) + } + //Load the previous basic auth credentials from current proxy rules targetProxyEntry, err := dynamicProxyRouter.LoadProxy(rootNameOrMatchingDomain) if err != nil { @@ -623,6 +644,9 @@ func ReverseProxyHandleEditEndpoint(w http.ResponseWriter, r *http.Request) { newProxyEndpoint.DisableChunkedTransferEncoding = disableChunkedEncoding newProxyEndpoint.DisableLogging = disableLogging newProxyEndpoint.DisableStatisticCollection = disableStatisticCollection + newProxyEndpoint.BlockCommonExploits = blockCommonExploits + newProxyEndpoint.BlockAICrawlers = blockAICrawlers + newProxyEndpoint.MitigationAction = mitigationAction newProxyEndpoint.Tags = tags //Prepare to replace the current routing rule diff --git a/src/router.go b/src/router.go index 064ed27..92434ca 100644 --- a/src/router.go +++ b/src/router.go @@ -1,7 +1,6 @@ package main import ( - "fmt" "net/http" "os" "path/filepath" @@ -68,13 +67,13 @@ func FSHandler(handler http.Handler) http.Handler { if len(parts) > 2 { //Extract the instance ID from the request path instanceUUID := parts[2] - fmt.Println(instanceUUID) + //fmt.Println(instanceUUID) //Rewrite the url so the proxy knows how to serve stuffs r.URL, _ = sshprox.RewriteURL("/web.ssh/"+instanceUUID, r.RequestURI) webSshManager.HandleHttpByInstanceId(instanceUUID, w, r) } else { - fmt.Println(parts) + //fmt.Println(parts) http.Error(w, "Invalid Usage", http.StatusInternalServerError) } return diff --git a/src/web/components/httprp.html b/src/web/components/httprp.html index 0e3e3d3..7c563bb 100644 --- a/src/web/components/httprp.html +++ b/src/web/components/httprp.html @@ -437,7 +437,38 @@
- +
+ + +
+
+ + +
+
+
+ + +
+
+ + + Select how the system should respond when malicious or automated traffic is detected.
@@ -900,6 +931,9 @@ let disableChunkedTransferEncoding = $(editor).find(".DisableChunkedTransferEncoding")[0].checked; let disableLogging = $(editor).find(".DisableLogging")[0].checked; let disableStatisticCollection = $(editor).find(".DisableStatisticCollection")[0].checked; + let blockCommonExploits = $(editor).find(".BlockCommonExploits")[0].checked; + let blockAICrawlers = $(editor).find(".BlockAICrawlers")[0].checked; + let mitigationAction = $(editor).find(".mitigationActionDropdown").dropdown("get value"); let tags = getTagsArrayFromEndpoint(uuid); if (tags.length > 0){ tags = tags.join(","); @@ -918,6 +952,9 @@ "dChunkedEnc": disableChunkedTransferEncoding, "dLogging": disableLogging, "dStatisticCollection": disableStatisticCollection, + "blockCommonExploits": blockCommonExploits, + "blockAICrawlers": blockAICrawlers, + "mitigationAction": mitigationAction, "ratenum" :rateLimit, "tags": tags, }; @@ -1278,6 +1315,30 @@ if (subd.DisableLogging) { editor.find(".DisableStatisticCollection").prop("checked", true).prop("disabled", true); } + + // Exploit Detection + editor.find(".BlockCommonExploits").off('change'); + editor.find(".BlockCommonExploits").prop("checked", subd.BlockCommonExploits || false); + editor.find(".BlockCommonExploits").on("change", function() { + saveProxyInlineEdit(uuid); + }); + + editor.find(".BlockAICrawlers").off('change'); + editor.find(".BlockAICrawlers").prop("checked", subd.BlockAICrawlers || false); + editor.find(".BlockAICrawlers").on("change", function() { + saveProxyInlineEdit(uuid); + }); + + editor.find(".mitigationActionDropdown").off('change'); + editor.find(".mitigationActionDropdown input[type='hidden']").val((subd.MitigationAction || 0).toString()); + setTimeout(function(){ + editor.find(".mitigationActionDropdown").dropdown({ + onChange: function(value) { + saveProxyInlineEdit(uuid); + } + }); + }, 100); + //Bind the edit button editor.find(".downstream_primary_hostname_edit_btn").off("click").on("click", function(){ diff --git a/src/web/components/rules.html b/src/web/components/rules.html index c017f21..16ca102 100644 --- a/src/web/components/rules.html +++ b/src/web/components/rules.html @@ -70,13 +70,7 @@
-
-
- - -
-
+
@@ -104,6 +98,39 @@
+
+
+ +
+ + +
+

+
+ + +
+

+ + + Select how the system should respond when malicious or automated traffic is detected. +
+
Access Control @@ -169,6 +196,24 @@
Return a 429 error code if request rate exceed the rate limit. +
+ + Logging +
+
+
+ + +
+
+
+
+ + +
+
@@ -276,6 +321,10 @@ let tags = $("#proxyTags").val().trim(); let enableUtm = $("#enableUtm")[0].checked; let disableLog = $("#disableLog")[0].checked; + let disableStatisticCollection = $("#disableStatisticCollection")[0].checked; + let blockCommonExploits = $("#blockCommonExploits")[0].checked; + let blockAICrawlers = $("#blockAICrawlers")[0].checked; + let mitigationAction = $("#mitigationAction").val(); if (rootname.trim() == ""){ $("#rootname").parent().addClass("error"); @@ -312,6 +361,10 @@ tags: tags, enableUtm: enableUtm, disableLog: disableLog, + dStatisticCollection: disableStatisticCollection, + blockCommonExploits: blockCommonExploits, + blockAICrawlers: blockAICrawlers, + mitigationAction: mitigationAction, }, success: function(data){ if (data.error != undefined){ @@ -321,6 +374,10 @@ $("#rootname").val(""); $("#proxyDomain").val(""); $("#proxyTags").val(""); + $("#blockCommonExploits").prop("checked", false); + $("#blockAICrawlers").prop("checked", false); + $("#mitigationAction").parent().dropdown("set selected", "0"); + $("#disableStatisticCollection").prop("checked", false); credentials = []; updateTable(); reloadUptimeList(); @@ -423,6 +480,17 @@ $("#requireRateLimit").on('change', toggleRateLimit); toggleRateLimit(); + function toggleStatisticCollection() { + if ($("#disableLog").parent().checkbox("is checked")) { + $("#disableStatisticCollection").parent().checkbox("set checked"); + $("#disableStatisticCollection").parent().addClass("disabled"); + } else { + $("#disableStatisticCollection").parent().removeClass("disabled"); + } + } + $("#disableLog").on('change', toggleStatisticCollection); + toggleStatisticCollection(); + /* Credential Managements @@ -584,12 +652,14 @@ return events && events.click && events.click.length > 0; } + $("#advanceProxyRules").accordion(); if (!hasClickEvent($("#advanceProxyRules"))){ // Not sure why sometime the accordion events are not binding // to the DOM element. This makes sure the element is binded // correctly by checking it again after 300ms $("#advanceProxyRules").accordion(); $("#newProxyRuleAccessFilter").parent().dropdown(); + $("#mitigationAction").parent().dropdown(); setTimeout(function(){ initAdvanceSettingsAccordion(); }, 300); diff --git a/src/web/snippet/acme.html b/src/web/snippet/acme.html index 6104da0..9d885a3 100644 --- a/src/web/snippet/acme.html +++ b/src/web/snippet/acme.html @@ -117,7 +117,7 @@
Let's Encrypt