Skip to content

Commit 5d9fb73

Browse files
authored
Merge pull request #97 from SenseUnit/js_filter
Scripting support
2 parents b0251e6 + 98e0ea1 commit 5d9fb73

File tree

16 files changed

+620
-96
lines changed

16 files changed

+620
-96
lines changed

README.md

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,9 @@ Dumbest HTTP proxy ever.
2424
* Resilient to DPI (including active probing, see `hidden_domain` option for authentication providers)
2525
* Connecting via upstream HTTP(S)/SOCKS5 proxies (proxy chaining)
2626
* systemd socket activation
27+
* Scripting with JavaScript:
28+
* Access filter by JS function
29+
* Upstream proxy selection by JS function
2730

2831
## Installation
2932

@@ -175,6 +178,101 @@ Authentication parameters are passed as URI via `-auth` parameter. Scheme of URI
175178
* `blacklist` - location of file with list of serial numbers of blocked certificates, one per each line in form of hex-encoded colon-separated bytes. Example: `ab:01:02:03`. Empty lines and comments starting with `#` are ignored.
176179
* `reload` - interval for certificate blacklist file reload, if it was modified since last load. Use negative duration to disable autoreload. Default: `15s`.
177180

181+
## Scripting
182+
183+
With the dumbproxy, it is possible to modify request processing behaviour using simple scripts written in the JavaScript programming language.
184+
185+
### Access filter by JS script
186+
187+
It is possible to filter (allow or deny) requests with simple `access` JS function. Such function can be loaded with the `-js-access-filter` option. Option value must specify location of script file where `access` function is defined.
188+
189+
`access` function is invoked with following parameters:
190+
191+
1. **Request** *(Object)*. It contains following properties:
192+
* **method** *(String)* - HTTP method used in request (CONNECT, GET, POST, PUT, etc.).
193+
* **url** *(String)* - URL parsed from the URI supplied on the Request-Line.
194+
* **proto** *(String)* - the protocol version for incoming server requests.
195+
* **protoMajor** *(Number)* - numeric major protocol version.
196+
* **protoMinor** *(Number)* - numeric minor protocol version.
197+
* **header** *(Object)* - mapping of *String* headers (except Host) in canonical form to an *Array* of their *String* values.
198+
* **contentLength** *(Number)* - length of request body, if known.
199+
* **transferEncoding** *(Array)* - lists the request's transfer encodings from outermost to innermost.
200+
* **host** *(String)* - specifies the host on which the URL is sought. For HTTP/1 (per RFC 7230, section 5.4), this is either the value of the "Host" header or the host name given in the URL itself. For HTTP/2, it is the value of the ":authority" pseudo-header field.
201+
* **remoteAddr** *(String)* - client's IP:port.
202+
* **requestURI** *(String)* - the unmodified request-target of the Request-Line (RFC 7230, Section 3.1.1) as sent by the client to a server.
203+
2. **Destination address** *(Object)*. It's an address where actual connection is about to be created. It contains following properties:
204+
* **network** *(String)* - connection type. Should be `"tcp"` in most cases unless restricted to specific address family (`"tcp4"` or `"tcp6"`).
205+
* **originalHost** *(String)* - original hostname or IP address parsed from request.
206+
* **resolvedHost** *(String)* - resolved hostname from request or `null` if resolving was not performed (e.g. if upstream dialer is a proxy).
207+
* **port** *(Number)* - port number.
208+
3. **Username** *(String)*. Name of the authenticated user or an empty string if there is no authentication.
209+
210+
`access` function must return boolean value, `true` allows request and `false` forbids it. Any exception will be reported to log and the corresponding request will be denied.
211+
212+
Also it is possible to use builtin `print` function to print arbitrary values into dumbproxy log for debugging purposes.
213+
214+
Example:
215+
216+
```js
217+
// Deny unsafe ports for HTTP and non-SSL ports for HTTPS.
218+
219+
const SSL_ports = [
220+
443,
221+
]
222+
const Safe_ports = [
223+
80, // http
224+
21, // ftp
225+
443, // https
226+
70, // gopher
227+
210, // wais
228+
280, // http-mgmt
229+
488, // gss-http
230+
591, // filemaker
231+
777, // multiling http
232+
]
233+
const highPortBase = 1025
234+
235+
function access(req, dst, username) {
236+
if (req.method == "CONNECT") {
237+
if (SSL_ports.includes(dst.port)) return true
238+
} else {
239+
if (dst.port >= highPortBase || Safe_ports.includes(dst.port)) return true
240+
}
241+
return false
242+
}
243+
```
244+
245+
### Upstream proxy selection by JS script
246+
247+
dumbproxy can select upstream proxy dynamically invoking `getProxy` JS function from file specified by `-js-proxy-router` option.
248+
249+
Note that this option can be repeated multiple times, same as `-proxy` option for chaining of proxies. These two options can be used together and order of chaining will be as they come in command line. For generalization purposes we can say that `-proxy` option is equivalent to `-js-proxy-router` option with script which returns just one static proxy.
250+
251+
`getProxy` function is invoked with the [same parameters](#access-filter-by-js-script) as the `access` function. But unlike `access` function it is expected to return proxy URL in string format *scheme://[user:password@]host:port* or empty string `""` if no additional upstream proxy needed (i.e. direct connection if there are no other proxy dialers defined in chain).
252+
253+
Supported proxy schemes are:
254+
* `http` - regular HTTP proxy with the CONNECT method support.
255+
* `https` - HTTP proxy over TLS connection.
256+
* `socks5`, `socks5h` - SOCKS5 proxy with hostname resolving via remote proxy.
257+
258+
Example:
259+
260+
```js
261+
// Redirect .onion hidden domains to Tor SOCKS5 proxy
262+
263+
function getProxy(req, dst, username) {
264+
if (dst.originalHost.replace(/\.$/, "").toLowerCase().endsWith(".onion")) {
265+
return "socks5://127.0.0.1:9050"
266+
}
267+
return ""
268+
}
269+
```
270+
271+
> [!NOTE]
272+
> `getProxy` can be invoked once or twice per request. If first invocation with `null` resolved host address returns "direct" mode and no other dialer has suppressed name resolving, name resolution will be performed and `getProxy` will be invoked once again with resolved address for the final decision.
273+
>
274+
> This shouldn't be much of concern, though, if `getProxy` function doesn't use dst.resolvedHost and returns consistent values across invocations with the rest of inputs having same values.
275+
178276
## Synopsis
179277

180278
```
@@ -224,6 +322,14 @@ Usage of /home/user/go/bin/dumbproxy:
224322
sign username with specified key for given validity period. Positional arguments are: hex-encoded HMAC key, username, validity duration.
225323
-ip-hints string
226324
a comma-separated list of source addresses to use on dial attempts. "$lAddr" gets expanded to local address of connection. Example: "10.0.0.1,fe80::2,$lAddr,0.0.0.0,::"
325+
-js-access-filter string
326+
path to JS script file with the "access" filter function
327+
-js-access-filter-instances int
328+
number of JS VM instances to handle access filter requests (default 4)
329+
-js-proxy-router value
330+
path to JS script file with the "getProxy" function
331+
-js-proxy-router-instances int
332+
number of JS VM instances to handle proxy router requests (default 4)
227333
-key string
228334
key for TLS certificate
229335
-list-ciphers

access/dst.go

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -33,16 +33,13 @@ func (f DstAddrFilter) Access(ctx context.Context, req *http.Request, username,
3333
addrport, err := netip.ParseAddrPort(address)
3434
if err != nil {
3535
// not an IP address, no action needed
36-
return nil
36+
return f.next.Access(ctx, req, username, network, address)
3737
}
3838
addr := addrport.Addr().Unmap()
3939
for _, pfx := range f.pfxList {
4040
if pfx.Contains(addr) {
4141
return ErrDestinationAddressNotAllowed{addr, pfx}
4242
}
4343
}
44-
if f.next != nil {
45-
return f.next.Access(ctx, req, username, network, address)
46-
}
47-
return nil
44+
return f.next.Access(ctx, req, username, network, address)
4845
}

access/jsfilter.go

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
package access
2+
3+
import (
4+
"context"
5+
"errors"
6+
"fmt"
7+
"net/http"
8+
"os"
9+
10+
"github.com/dop251/goja"
11+
12+
"github.com/SenseUnit/dumbproxy/jsext"
13+
clog "github.com/SenseUnit/dumbproxy/log"
14+
)
15+
16+
var ErrJSDenied = errors.New("denied by JS filter")
17+
18+
type JSFilterFunc = func(req *jsext.JSRequestInfo, dst *jsext.JSDstInfo, username string) (bool, error)
19+
20+
// JSFilter is not suitable for concurrent use!
21+
// Wrap it with filter pool for that!
22+
type JSFilter struct {
23+
funcPool chan JSFilterFunc
24+
next Filter
25+
}
26+
27+
func NewJSFilter(filename string, instances int, logger *clog.CondLogger, next Filter) (*JSFilter, error) {
28+
script, err := os.ReadFile(filename)
29+
if err != nil {
30+
return nil, fmt.Errorf("unable to load JS script file %q: %w", filename, err)
31+
}
32+
33+
instances = max(1, instances)
34+
pool := make(chan JSFilterFunc, instances)
35+
36+
for i := 0; i < instances; i++ {
37+
vm := goja.New()
38+
err = jsext.AddPrinter(vm, logger)
39+
if err != nil {
40+
return nil, errors.New("can't add print function to runtime")
41+
}
42+
vm.SetFieldNameMapper(goja.TagFieldNameMapper("json", true))
43+
_, err = vm.RunString(string(script))
44+
if err != nil {
45+
return nil, fmt.Errorf("script run failed: %w", err)
46+
}
47+
48+
var f JSFilterFunc
49+
var accessFnJSVal goja.Value
50+
if ex := vm.Try(func() {
51+
accessFnJSVal = vm.Get("access")
52+
}); ex != nil {
53+
return nil, fmt.Errorf("\"access\" function cannot be located in VM context: %w", err)
54+
}
55+
if accessFnJSVal == nil {
56+
return nil, errors.New("\"access\" function is not defined")
57+
}
58+
err = vm.ExportTo(accessFnJSVal, &f)
59+
if err != nil {
60+
return nil, fmt.Errorf("can't export \"access\" function from JS VM: %w", err)
61+
}
62+
63+
pool <- f
64+
}
65+
66+
return &JSFilter{
67+
funcPool: pool,
68+
next: next,
69+
}, nil
70+
}
71+
72+
func (j *JSFilter) Access(ctx context.Context, req *http.Request, username, network, address string) error {
73+
ri := jsext.JSRequestInfoFromRequest(req)
74+
di, err := jsext.JSDstInfoFromContext(ctx, network, address)
75+
if err != nil {
76+
return fmt.Errorf("unable to construct dst info: %w", err)
77+
}
78+
var res bool
79+
func() {
80+
f := <-j.funcPool
81+
defer func(pool chan JSFilterFunc, f JSFilterFunc) {
82+
pool <- f
83+
}(j.funcPool, f)
84+
res, err = f(ri, di, username)
85+
}()
86+
if err != nil {
87+
return fmt.Errorf("JS access script exception: %w", err)
88+
}
89+
if !res {
90+
return ErrJSDenied
91+
}
92+
return j.next.Access(ctx, req, username, network, address)
93+
}

dialer/dto/dto.go

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
package dto
2+
3+
import (
4+
"context"
5+
"net/http"
6+
)
7+
8+
type boundDialerContextKey struct{}
9+
10+
type boundDialerContextValue struct {
11+
hints *string
12+
localAddr string
13+
}
14+
15+
func BoundDialerParamsToContext(ctx context.Context, hints *string, localAddr string) context.Context {
16+
return context.WithValue(ctx, boundDialerContextKey{}, boundDialerContextValue{hints, localAddr})
17+
}
18+
19+
func BoundDialerParamsFromContext(ctx context.Context) (*string, string, bool) {
20+
val, ok := ctx.Value(boundDialerContextKey{}).(boundDialerContextValue)
21+
if !ok {
22+
return nil, "", false
23+
}
24+
return val.hints, val.localAddr, true
25+
}
26+
27+
type filterContextKey struct{}
28+
29+
type filterContextParams struct {
30+
req *http.Request
31+
username string
32+
}
33+
34+
func FilterParamsFromContext(ctx context.Context) (*http.Request, string) {
35+
params := ctx.Value(filterContextKey{}).(filterContextParams)
36+
return params.req, params.username
37+
}
38+
39+
func FilterParamsToContext(ctx context.Context, req *http.Request, username string) context.Context {
40+
return context.WithValue(ctx, filterContextKey{}, filterContextParams{req, username})
41+
}
42+
43+
type origDstKey struct{}
44+
45+
func OrigDstFromContext(ctx context.Context) (string, bool) {
46+
orig, ok := ctx.Value(origDstKey{}).(string)
47+
return orig, ok
48+
}
49+
50+
func OrigDstToContext(ctx context.Context, dst string) context.Context {
51+
return context.WithValue(ctx, origDstKey{}, dst)
52+
}

dialer/errors/errors.go

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
package errors
2+
3+
import "fmt"
4+
5+
type ErrAccessDenied struct {
6+
Err error
7+
}
8+
9+
func (e ErrAccessDenied) Error() string {
10+
return fmt.Sprintf("access denied: %v", e.Err)
11+
}
12+
13+
func (e ErrAccessDenied) Unwrap() error {
14+
return e.Err
15+
}

dialer/filter.go

Lines changed: 5 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -2,32 +2,15 @@ package dialer
22

33
import (
44
"context"
5-
"fmt"
65
"net"
76
"net/http"
7+
8+
"github.com/SenseUnit/dumbproxy/dialer/dto"
9+
"github.com/SenseUnit/dumbproxy/dialer/errors"
810
)
911

1012
type FilterFunc = func(ctx context.Context, req *http.Request, username, network, address string) error
1113

12-
type ErrAccessDenied struct {
13-
err error
14-
}
15-
16-
func (e ErrAccessDenied) Error() string {
17-
return fmt.Sprintf("access denied: %v", e.err)
18-
}
19-
20-
func (e ErrAccessDenied) Unwrap() error {
21-
return e.err
22-
}
23-
24-
type filterContextKey struct{}
25-
26-
type filterContextParams struct {
27-
req *http.Request
28-
username string
29-
}
30-
3114
type FilterDialer struct {
3215
f FilterFunc
3316
next Dialer
@@ -41,9 +24,9 @@ func NewFilterDialer(filterFunc FilterFunc, next Dialer) FilterDialer {
4124
}
4225

4326
func (f FilterDialer) DialContext(ctx context.Context, network, address string) (net.Conn, error) {
44-
req, username := FilterParamsFromContext(ctx)
27+
req, username := dto.FilterParamsFromContext(ctx)
4528
if ferr := f.f(ctx, req, username, network, address); ferr != nil {
46-
return nil, ErrAccessDenied{ferr}
29+
return nil, errors.ErrAccessDenied{ferr}
4730
}
4831
return f.next.DialContext(ctx, network, address)
4932
}
@@ -56,14 +39,5 @@ func (f FilterDialer) WantsHostname(ctx context.Context, network, address string
5639
return WantsHostname(ctx, network, address, f.next)
5740
}
5841

59-
func FilterParamsFromContext(ctx context.Context) (*http.Request, string) {
60-
params := ctx.Value(filterContextKey{}).(filterContextParams)
61-
return params.req, params.username
62-
}
63-
64-
func FilterParamsToContext(ctx context.Context, req *http.Request, username string) context.Context {
65-
return context.WithValue(ctx, filterContextKey{}, filterContextParams{req, username})
66-
}
67-
6842
var _ Dialer = FilterDialer{}
6943
var _ HostnameWanter = FilterDialer{}

0 commit comments

Comments
 (0)