From e0cff017963ac9374bede092c4dac6b82e54dbe5 Mon Sep 17 00:00:00 2001 From: Ronni Skansing Date: Thu, 27 Nov 2025 21:46:49 +0100 Subject: [PATCH] add support for proxy schema http and ports in proxy start URL Signed-off-by: Ronni Skansing --- backend/proxy/proxy.go | 46 +++++++++++++++---- backend/service/domain.go | 39 ++++++++++++++-- backend/service/proxy.go | 9 ++++ docker-compose.yml | 3 +- frontend/src/lib/utils/proxyYamlCompletion.js | 31 +++++++++++++ frontend/src/routes/proxy/+page.svelte | 3 ++ 6 files changed, 117 insertions(+), 14 deletions(-) diff --git a/backend/proxy/proxy.go b/backend/proxy/proxy.go index 0930117..f949ad2 100644 --- a/backend/proxy/proxy.go +++ b/backend/proxy/proxy.go @@ -80,6 +80,7 @@ type RequestContext struct { SessionCreated bool PhishDomain string TargetDomain string + TargetScheme string Domain *database.Domain ProxyConfig *service.ProxyServiceConfigYAML Session *service.ProxySession @@ -255,17 +256,43 @@ func (m *ProxyHandler) HandleHTTPRequest(w http.ResponseWriter, req *http.Reques return m.writeResponse(w, finalResp) } -func (m *ProxyHandler) extractTargetDomain(domain *database.Domain) string { +// extractTargetHostAndScheme extracts both the host and scheme from the target domain +// it first checks the config for an explicit scheme, then falls back to parsing the URL +// returns the host (with port if present) and scheme (defaults to "https") +func (m *ProxyHandler) extractTargetHostAndScheme(domain *database.Domain, config *service.ProxyServiceConfigYAML) (string, string) { targetDomain := domain.ProxyTargetDomain if targetDomain == "" { - return "" + return "", "https" } + + // extract the host first + host := targetDomain + schemeFromURL := "" + + // check if it's a full URL with scheme if strings.Contains(targetDomain, "://") { if parsedURL, err := url.Parse(targetDomain); err == nil { - targetDomain = parsedURL.Host + host = parsedURL.Host + schemeFromURL = parsedURL.Scheme } } - return targetDomain + + // determine the scheme to use + // priority: 1. config scheme field, 2. scheme from URL, 3. default to https + scheme := "https" + + // check if there's a scheme specified in the config for this target domain + if config != nil && config.Hosts != nil { + if hostConfig, exists := config.Hosts[targetDomain]; exists && hostConfig.Scheme != "" { + scheme = hostConfig.Scheme + } else if schemeFromURL != "" { + scheme = schemeFromURL + } + } else if schemeFromURL != "" { + scheme = schemeFromURL + } + + return host, scheme } // initializeRequestContext creates and populates the request context with all necessary data @@ -280,8 +307,8 @@ func (m *ProxyHandler) initializeRequestContext(ctx context.Context, req *http.R return nil, errors.Errorf("failed to parse Proxy config for domain %s: %w", domain.Name, err) } - // extract target domain - targetDomain := m.extractTargetDomain(domain) + // extract target domain and scheme + targetDomain, targetScheme := m.extractTargetHostAndScheme(domain, proxyConfig) if targetDomain == "" { return nil, errors.Errorf("domain has empty Proxy target domain") } @@ -292,6 +319,7 @@ func (m *ProxyHandler) initializeRequestContext(ctx context.Context, req *http.R reqCtx := &RequestContext{ PhishDomain: req.Host, TargetDomain: targetDomain, + TargetScheme: targetScheme, Domain: domain, ProxyConfig: proxyConfig, CampaignRecipientID: campaignRecipientID, @@ -475,7 +503,7 @@ func (m *ProxyHandler) prepareRequestWithoutSession(req *http.Request, reqCtx *R // set host and scheme req.Host = reqCtx.TargetDomain req.URL.Host = reqCtx.TargetDomain - req.URL.Scheme = "https" + req.URL.Scheme = reqCtx.TargetScheme // create a dummy session for header normalization (no campaign/session data) dummySession := &service.ProxySession{ @@ -645,7 +673,7 @@ func (m *ProxyHandler) applySessionToRequestWithContext(req *http.Request, reqCt // use session's original target domain only for initial landing if reqCtx.CampaignRecipientID != nil && reqCtx.SessionCreated { req.Host = reqCtx.Session.TargetDomain - req.URL.Scheme = "https" + req.URL.Scheme = reqCtx.TargetScheme req.URL.Host = reqCtx.Session.TargetDomain // remove campaign parameters from query params q := req.URL.Query() @@ -668,7 +696,7 @@ func (m *ProxyHandler) applySessionToRequestWithContext(req *http.Request, reqCt } req.Host = targetDomain req.URL.Host = targetDomain - req.URL.Scheme = "https" + req.URL.Scheme = reqCtx.TargetScheme } // apply request processing diff --git a/backend/service/domain.go b/backend/service/domain.go index 8fd7569..65e7f7b 100644 --- a/backend/service/domain.go +++ b/backend/service/domain.go @@ -9,6 +9,7 @@ import ( "net/http" "os" "path/filepath" + "strconv" "strings" "time" @@ -1109,17 +1110,47 @@ func (d *Domain) validateProxyDomain(ctx context.Context, domain *model.Domain) return validate.WrapErrorWithField(errors.New("proxy target domain cannot be empty"), "proxyTargetDomain") } - // validate proxy target format - can be full URL or domain + // validate proxy target format - can be full URL or domain (with optional port) if strings.Contains(targetDomainStr, "://") { // full URL format - basic validation if !strings.HasPrefix(targetDomainStr, "http://") && !strings.HasPrefix(targetDomainStr, "https://") { return validate.WrapErrorWithField(errors.New("proxy target domain URL must start with http:// or https://"), "proxyTargetDomain") } } else { - // domain-only format - validate as domain - if !isValidDomain(targetDomainStr) { - return validate.WrapErrorWithField(errors.New("invalid domain format for proxy target domain"), "proxyTargetDomain") + // domain-only format (with optional port) - validate as domain, ip address, or localhost + domainPart := targetDomainStr + + // try to split host and port using net.SplitHostPort for proper handling of ipv6 + host, port, err := net.SplitHostPort(targetDomainStr) + if err == nil { + // port was present and successfully split + domainPart = host + + // validate port is numeric and in valid range + portNum, err := strconv.Atoi(port) + if err != nil { + return validate.WrapErrorWithField(errors.New("port must be numeric in proxy target domain"), "proxyTargetDomain") + } + if portNum < 1 || portNum > 65535 { + return validate.WrapErrorWithField(errors.New("port must be between 1 and 65535"), "proxyTargetDomain") + } } + // if SplitHostPort fails, targetDomainStr has no port, which is fine + + // check if it's localhost (special case for single-label hostname) + if strings.ToLower(domainPart) == "localhost" { + // localhost is always valid + return nil + } + + // check if it's an ip address (ipv4 or ipv6) + if net.ParseIP(domainPart) == nil { + // not an ip address, validate as domain + if !isValidDomain(domainPart) { + return validate.WrapErrorWithField(errors.New("invalid domain format for proxy target domain"), "proxyTargetDomain") + } + } + // if it's a valid ip address, no further validation needed } return nil diff --git a/backend/service/proxy.go b/backend/service/proxy.go index c37db0a..26c09d3 100644 --- a/backend/service/proxy.go +++ b/backend/service/proxy.go @@ -42,6 +42,7 @@ type ProxyServiceConfig struct { // ProxyServiceDomainConfig represents configuration for a specific domain mapping type ProxyServiceDomainConfig struct { To string `yaml:"to"` + Scheme string `yaml:"scheme,omitempty"` // http or https (defaults to https) TLS *ProxyServiceTLSConfig `yaml:"tls,omitempty"` Access *ProxyServiceAccessControl `yaml:"access,omitempty"` Capture []ProxyServiceCaptureRule `yaml:"capture,omitempty"` @@ -1620,6 +1621,14 @@ func (m *Proxy) validateProxyConfig(ctx context.Context, proxy *model.Proxy) err } } + // validate domain-specific scheme + if domainConfig.Scheme != "" && domainConfig.Scheme != "http" && domainConfig.Scheme != "https" { + return validate.WrapErrorWithField( + errors.New(fmt.Sprintf("scheme for domain '%s' must be either 'http' or 'https'", originalDomain)), + "proxyConfig", + ) + } + // validate domain-specific TLS config if err := m.validateTLSConfig(domainConfig.TLS); err != nil { return err diff --git a/docker-compose.yml b/docker-compose.yml index 6cfa89f..a457be7 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -117,7 +117,8 @@ services: volumes: - ./api-test-server:/app networks: - - default + default: + ipv4_address: 172.20.0.135 ports: - 8107:80 diff --git a/frontend/src/lib/utils/proxyYamlCompletion.js b/frontend/src/lib/utils/proxyYamlCompletion.js index 0ef1221..2239945 100644 --- a/frontend/src/lib/utils/proxyYamlCompletion.js +++ b/frontend/src/lib/utils/proxyYamlCompletion.js @@ -98,6 +98,9 @@ export class ProxyYamlCompletionProvider { if (linePrefix.match(/\s*target:\s*$/)) { return this.getTargetSuggestions(range); } + if (linePrefix.match(/\s*scheme:\s*$/)) { + return this.getSchemeSuggestions(range); + } if (linePrefix.match(/\s*mode:\s*$/)) { // determine context for mode - could be access or tls const context = this.findParentSection(linesAbove, currentIndent); @@ -306,6 +309,13 @@ export class ProxyYamlCompletionProvider { documentation: 'Phishing domain (where victims will visit)', range }, + { + label: 'scheme', + kind: this.monaco.languages.CompletionItemKind.Field, + insertText: 'scheme: ', + documentation: 'Scheme for proxying to target (http or https, defaults to https)', + range + }, { label: 'tls', kind: this.monaco.languages.CompletionItemKind.Module, @@ -382,6 +392,25 @@ export class ProxyYamlCompletionProvider { ]; } + getSchemeSuggestions(range) { + return [ + { + label: 'http', + kind: this.monaco.languages.CompletionItemKind.Value, + insertText: 'http', + documentation: 'Use HTTP when connecting to target', + range + }, + { + label: 'https', + kind: this.monaco.languages.CompletionItemKind.Value, + insertText: 'https', + documentation: 'Use HTTPS when connecting to target (default)', + range + } + ]; + } + getImpersonateSuggestions(range) { return [ { @@ -1137,6 +1166,8 @@ export class ProxyYamlCompletionProvider { mode: 'Access control mode: "public" (allow all traffic) or "private" (IP whitelist after lure access, DEFAULT), OR TLS mode: "managed" (Let\'s Encrypt) or "self-signed"', on_deny: 'Response when access is denied in private mode (e.g., "404", "https://example.com")', + scheme: + 'Protocol scheme for proxying to target: "http" or "https" (defaults to https). Specifies whether to use HTTP or HTTPS when connecting to the target domain', capture: 'Rules for capturing data from requests/responses', name: 'Unique identifier for the rule', method: 'HTTP method to match (GET, POST, PUT, DELETE, etc.)', diff --git a/frontend/src/routes/proxy/+page.svelte b/frontend/src/routes/proxy/+page.svelte index 73cdd9d..8771c9f 100644 --- a/frontend/src/routes/proxy/+page.svelte +++ b/frontend/src/routes/proxy/+page.svelte @@ -84,6 +84,9 @@ global: portal.example.com: to: "evil.example.com" + # optional: specify scheme for proxying to target (defaults to https) + # scheme: "http" # use http:// when connecting to target + # scheme: "https" # use https:// when connecting to target # optional: override global TLS config for this specific host # tls: # mode: "self-signed"