add support for proxy schema http and ports in proxy start URL

Signed-off-by: Ronni Skansing <rskansing@gmail.com>
This commit is contained in:
Ronni Skansing
2025-11-27 21:46:49 +01:00
parent d35d41732b
commit e0cff01796
6 changed files with 117 additions and 14 deletions
+37 -9
View File
@@ -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
+35 -4
View File
@@ -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
+9
View File
@@ -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
+2 -1
View File
@@ -117,7 +117,8 @@ services:
volumes:
- ./api-test-server:/app
networks:
- default
default:
ipv4_address: 172.20.0.135
ports:
- 8107:80
@@ -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.)',
+3
View File
@@ -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"