● Notice //GitHub (@shehzade) is temporarily suspended pending an automated false-positive review. All tools are hosted on this Code Mirror and available on request (contact); GitHub links will return once reinstated.
PUBLIC MIRRORGo v1.0

Multi-protocol network egress assessment and DLP verification framework featuring automated L4 sweeps, corporate proxy traversal, and covert protocol smuggling across 20+ channels.

$ git clone https://shehzade.io/repos/contraband.git Copy
Source ZIP (1.93 MB)
Code 174 Commits 1
contraband / pkg / client / proxy / pac.go
← Back to directory
392 lines · 10.3 KB
Raw
1package proxy
2
3import (
4 "context"
5 "errors"
6 "fmt"
7 "net"
8 "path/filepath"
9 "regexp"
10 "strconv"
11 "strings"
12 "sync"
13 "time"
14
15 "github.com/dop251/goja"
16)
17
18// DirectiveType represents the proxy directive type parsed from PAC results.
19type DirectiveType int
20
21const (
22 DirectiveDirect DirectiveType = iota
23 DirectiveHTTP
24 DirectiveHTTPS
25 DirectiveSOCKS5
26 DirectiveSOCKS4
27)
28
29// Typed PAC errors for diagnostics and strict evaluation.
30var (
31 ErrPACEmptyScript = errors.New("empty or whitespace PAC script")
32 ErrPACSyntaxError = errors.New("PAC JavaScript syntax error")
33 ErrPACMissingFindProxy = errors.New("FindProxyForURL function not defined in PAC script")
34 ErrPACExecutionTimeout = errors.New("PAC script execution timed out")
35 ErrPACRuntimeError = errors.New("PAC script execution error")
36)
37
38// DefaultPACTimout defines the maximum allowable runtime for evaluating a PAC script.
39var DefaultPACTimout = 1000 * time.Millisecond
40
41// ProxyDirective represents a parsed proxy instruction from a PAC script.
42type ProxyDirective struct {
43 Type DirectiveType
44 Host string
45 Port int
46}
47
48func (d ProxyDirective) String() string {
49 switch d.Type {
50 case DirectiveDirect:
51 return "DIRECT"
52 case DirectiveHTTP:
53 return fmt.Sprintf("PROXY %s:%d", d.Host, d.Port)
54 case DirectiveHTTPS:
55 return fmt.Sprintf("HTTPS %s:%d", d.Host, d.Port)
56 case DirectiveSOCKS5:
57 return fmt.Sprintf("SOCKS5 %s:%d", d.Host, d.Port)
58 case DirectiveSOCKS4:
59 return fmt.Sprintf("SOCKS %s:%d", d.Host, d.Port)
60 default:
61 return "DIRECT"
62 }
63}
64
65// ToURL returns the URL representation of the proxy directive.
66func (d ProxyDirective) ToURL() string {
67 switch d.Type {
68 case DirectiveDirect:
69 return "direct"
70 case DirectiveHTTP:
71 return fmt.Sprintf("http://%s:%d", d.Host, d.Port)
72 case DirectiveHTTPS:
73 return fmt.Sprintf("https://%s:%d", d.Host, d.Port)
74 case DirectiveSOCKS5:
75 return fmt.Sprintf("socks5://%s:%d", d.Host, d.Port)
76 case DirectiveSOCKS4:
77 return fmt.Sprintf("socks4://%s:%d", d.Host, d.Port)
78 default:
79 return "direct"
80 }
81}
82
83// ParsePACResult parses the semicolon-delimited string returned by FindProxyForURL.
84func ParsePACResult(result string) []ProxyDirective {
85 var directives []ProxyDirective
86 parts := strings.Split(result, ";")
87 for _, part := range parts {
88 part = strings.TrimSpace(part)
89 if part == "" {
90 continue
91 }
92 fields := strings.Fields(part)
93 if len(fields) == 0 {
94 continue
95 }
96 switch strings.ToUpper(fields[0]) {
97 case "DIRECT":
98 directives = append(directives, ProxyDirective{Type: DirectiveDirect})
99 case "PROXY", "HTTP":
100 if len(fields) > 1 {
101 h, p := parseHostPort(fields[1], 8080)
102 directives = append(directives, ProxyDirective{Type: DirectiveHTTP, Host: h, Port: p})
103 }
104 case "HTTPS":
105 if len(fields) > 1 {
106 h, p := parseHostPort(fields[1], 8443)
107 directives = append(directives, ProxyDirective{Type: DirectiveHTTPS, Host: h, Port: p})
108 }
109 case "SOCKS5":
110 if len(fields) > 1 {
111 h, p := parseHostPort(fields[1], 1080)
112 directives = append(directives, ProxyDirective{Type: DirectiveSOCKS5, Host: h, Port: p})
113 }
114 case "SOCKS":
115 if len(fields) > 1 {
116 h, p := parseHostPort(fields[1], 1080)
117 directives = append(directives, ProxyDirective{Type: DirectiveSOCKS4, Host: h, Port: p})
118 }
119 }
120 }
121
122 if len(directives) == 0 {
123 directives = append(directives, ProxyDirective{Type: DirectiveDirect})
124 }
125 return directives
126}
127
128func parseHostPort(hp string, defaultPort int) (string, int) {
129 hp = strings.TrimSpace(hp)
130 host, portStr, err := net.SplitHostPort(hp)
131 if err != nil {
132 return hp, defaultPort
133 }
134 port, err := strconv.Atoi(portStr)
135 if err != nil || port <= 0 {
136 return host, defaultPort
137 }
138 return host, port
139}
140
141// --- Standard PAC Helper Functions ---
142
143// IsPlainHostName returns true if host contains no dots.
144func IsPlainHostName(host string) bool {
145 return !strings.Contains(host, ".")
146}
147
148// DNSDomainIs returns true if host ends with domain.
149func DNSDomainIs(host, domain string) bool {
150 return strings.HasSuffix(strings.ToLower(host), strings.ToLower(domain))
151}
152
153// LocalHostOrDomainIs returns true if host matches domain or prefix matches without dot.
154func LocalHostOrDomainIs(host, hostdom string) bool {
155 host = strings.ToLower(host)
156 hostdom = strings.ToLower(hostdom)
157 if host == hostdom {
158 return true
159 }
160 idx := strings.Index(hostdom, ".")
161 if idx != -1 && host == hostdom[:idx] {
162 return true
163 }
164 return false
165}
166
167// LocalHostOrFQDN returns the host or fully qualified domain name.
168func LocalHostOrFQDN(host string) string {
169 return host
170}
171
172// IsResolvable returns true if the host can be resolved by DNS.
173func IsResolvable(host string) bool {
174 return DNSResolve(host) != ""
175}
176
177// IsInNet resolves or takes an IP address and matches against pattern and mask.
178func IsInNet(ipStr, pattern, mask string) bool {
179 ip := net.ParseIP(ipStr)
180 if ip == nil {
181 resolved := DNSResolve(ipStr)
182 if resolved == "" {
183 return false
184 }
185 ip = net.ParseIP(resolved)
186 if ip == nil {
187 return false
188 }
189 }
190
191 patIP := net.ParseIP(pattern)
192 maskIP := net.ParseIP(mask)
193 if patIP == nil || maskIP == nil {
194 return false
195 }
196
197 ipv4 := ip.To4()
198 pat4 := patIP.To4()
199 mask4 := maskIP.To4()
200 if ipv4 == nil || pat4 == nil || mask4 == nil {
201 return false
202 }
203
204 for i := 0; i < 4; i++ {
205 if (ipv4[i] & mask4[i]) != (pat4[i] & mask4[i]) {
206 return false
207 }
208 }
209 return true
210}
211
212// DNSResolve resolves a hostname to an IPv4 string.
213func DNSResolve(host string) string {
214 ips, err := net.LookupIP(host)
215 if err != nil || len(ips) == 0 {
216 return ""
217 }
218 for _, ip := range ips {
219 if ipv4 := ip.To4(); ipv4 != nil {
220 return ipv4.String()
221 }
222 }
223 return ips[0].String()
224}
225
226// MyIPAddress returns the client outbound IP.
227func MyIPAddress() string {
228 addrs, err := net.InterfaceAddrs()
229 if err != nil {
230 return "127.0.0.1"
231 }
232 for _, a := range addrs {
233 if ipnet, ok := a.(*net.IPNet); ok && !ipnet.IP.IsLoopback() {
234 if ipnet.IP.To4() != nil {
235 return ipnet.IP.String()
236 }
237 }
238 }
239 return "127.0.0.1"
240}
241
242// DNSDomainLevels returns the number of dots in the hostname.
243func DNSDomainLevels(host string) int {
244 return strings.Count(host, ".")
245}
246
247var (
248 shExpMu sync.RWMutex
249 shExpCache = make(map[string]*regexp.Regexp)
250)
251
252// ShExpMatch checks shell wildcard match (* and ?).
253func ShExpMatch(str, shexp string) bool {
254 matched, err := filepath.Match(strings.ToLower(shexp), strings.ToLower(str))
255 if err == nil && matched {
256 return true
257 }
258
259 pattern := strings.ToLower(shexp)
260 shExpMu.RLock()
261 re, ok := shExpCache[pattern]
262 shExpMu.RUnlock()
263
264 if !ok {
265 regPattern := "^" + regexp.QuoteMeta(pattern) + "$"
266 regPattern = strings.ReplaceAll(regPattern, `\*`, `.*`)
267 regPattern = strings.ReplaceAll(regPattern, `\?`, `.`)
268 var compErr error
269 re, compErr = regexp.Compile("(?i)" + regPattern)
270 if compErr != nil {
271 return false
272 }
273 shExpMu.Lock()
274 if len(shExpCache) < 1000 {
275 shExpCache[pattern] = re
276 }
277 shExpMu.Unlock()
278 }
279 return re.MatchString(str)
280}
281
282// bindPACHelpers registers all standard PAC functions to the Goja VM global scope.
283func bindPACHelpers(vm *goja.Runtime) {
284 helpers := map[string]interface{}{
285 "isPlainHostName": IsPlainHostName,
286 "isplainhostname": IsPlainHostName,
287 "dnsDomainIs": DNSDomainIs,
288 "dnsdomainis": DNSDomainIs,
289 "localHostOrDomainIs": LocalHostOrDomainIs,
290 "localhostordomainis": LocalHostOrDomainIs,
291 "localHostOrFQDN": LocalHostOrFQDN,
292 "localhostorfqdn": LocalHostOrFQDN,
293 "isResolvable": IsResolvable,
294 "isresolvable": IsResolvable,
295 "isInNet": IsInNet,
296 "isinnet": IsInNet,
297 "dnsResolve": DNSResolve,
298 "dnsresolve": DNSResolve,
299 "myIpAddress": MyIPAddress,
300 "myipaddress": MyIPAddress,
301 "dnsDomainLevels": DNSDomainLevels,
302 "dnsdomainlevels": DNSDomainLevels,
303 "shExpMatch": ShExpMatch,
304 "shexpmatch": ShExpMatch,
305 "alert": func(call goja.FunctionCall) goja.Value {
306 return goja.Undefined()
307 },
308 }
309
310 for name, fn := range helpers {
311 _ = vm.Set(name, fn)
312 }
313}
314
315// EvaluatePACScriptStrict evaluates a PAC script using Goja ECMAScript 5.1 runtime and returns typed errors.
316func EvaluatePACScriptStrict(ctx context.Context, script string, targetURL string, host string) ([]ProxyDirective, error) {
317 if strings.TrimSpace(script) == "" {
318 return nil, ErrPACEmptyScript
319 }
320
321 if ctx == nil {
322 ctx = context.Background()
323 }
324
325 evalCtx, cancel := context.WithTimeout(ctx, DefaultPACTimout)
326 defer cancel()
327
328 vm := goja.New()
329
330 // Setup interrupt channel for timeout protection
331 stopInterrupt := context.AfterFunc(evalCtx, func() {
332 vm.Interrupt(evalCtx.Err())
333 })
334 defer stopInterrupt()
335
336 // Bind standard PAC functions
337 bindPACHelpers(vm)
338
339 // Execute PAC script definitions
340 _, err := vm.RunString(script)
341 if err != nil {
342 var intErr *goja.InterruptedError
343 if errors.As(err, &intErr) {
344 return nil, ErrPACExecutionTimeout
345 }
346 return nil, fmt.Errorf("%w: %v", ErrPACSyntaxError, err)
347 }
348
349 // Retrieve FindProxyForURL (or lowercase findProxyForURL)
350 fnVal := vm.Get("FindProxyForURL")
351 if fnVal == nil || goja.IsUndefined(fnVal) || goja.IsNull(fnVal) {
352 fnVal = vm.Get("findProxyForURL")
353 }
354
355 findProxy, ok := goja.AssertFunction(fnVal)
356 if !ok {
357 return nil, ErrPACMissingFindProxy
358 }
359
360 // Invoke FindProxyForURL(url, host)
361 res, err := findProxy(goja.Undefined(), vm.ToValue(targetURL), vm.ToValue(host))
362 if err != nil {
363 var intErr *goja.InterruptedError
364 if errors.As(err, &intErr) {
365 return nil, ErrPACExecutionTimeout
366 }
367 return nil, fmt.Errorf("%w: %v", ErrPACRuntimeError, err)
368 }
369
370 if res == nil || goja.IsUndefined(res) || goja.IsNull(res) {
371 return []ProxyDirective{{Type: DirectiveDirect}}, nil
372 }
373
374 resultStr := res.String()
375 return ParsePACResult(resultStr), nil
376}
377
378// EvaluatePACScript evaluates a PAC script with standard fail-open semantics:
379// On empty scripts, syntax errors, missing FindProxyForURL, or execution timeouts,
380// it safely falls back to DIRECT proxy directives without returning an error.
381func EvaluatePACScript(ctx context.Context, script string, targetURL string, host string) ([]ProxyDirective, error) {
382 dirs, err := EvaluatePACScriptStrict(ctx, script, targetURL, host)
383 if err != nil {
384 return []ProxyDirective{{Type: DirectiveDirect}}, nil
385 }
386 return dirs, nil
387}
388
389// EvaluatePAC is a convenience wrapper calling EvaluatePACScript with context.Background().
390func EvaluatePAC(script string, targetURL string, host string) ([]ProxyDirective, error) {
391 return EvaluatePACScript(context.Background(), script, targetURL, host)
392}