Просмотр исходного кода

Merge pull request #630 from mirokuratczyk/http-transform

Add HTTP transforms
Rod Hynes 3 лет назад
Родитель
Сommit
ecd62272ec

+ 35 - 0
psiphon/common/parameters/parameters.go

@@ -239,6 +239,7 @@ const (
 	ReplayAPIRequestPadding                          = "ReplayAPIRequestPadding"
 	ReplayAPIRequestPadding                          = "ReplayAPIRequestPadding"
 	ReplayHoldOffTunnel                              = "ReplayHoldOffTunnel"
 	ReplayHoldOffTunnel                              = "ReplayHoldOffTunnel"
 	ReplayResolveParameters                          = "ReplayResolveParameters"
 	ReplayResolveParameters                          = "ReplayResolveParameters"
+	ReplayHTTPTransformerParameters                  = "ReplayHTTPTransformerParameters"
 	APIRequestUpstreamPaddingMinBytes                = "APIRequestUpstreamPaddingMinBytes"
 	APIRequestUpstreamPaddingMinBytes                = "APIRequestUpstreamPaddingMinBytes"
 	APIRequestUpstreamPaddingMaxBytes                = "APIRequestUpstreamPaddingMaxBytes"
 	APIRequestUpstreamPaddingMaxBytes                = "APIRequestUpstreamPaddingMaxBytes"
 	APIRequestDownstreamPaddingMinBytes              = "APIRequestDownstreamPaddingMinBytes"
 	APIRequestDownstreamPaddingMinBytes              = "APIRequestDownstreamPaddingMinBytes"
@@ -321,6 +322,12 @@ const (
 	DNSResolverCacheExtensionInitialTTL              = "DNSResolverCacheExtensionInitialTTL"
 	DNSResolverCacheExtensionInitialTTL              = "DNSResolverCacheExtensionInitialTTL"
 	DNSResolverCacheExtensionVerifiedTTL             = "DNSResolverCacheExtensionVerifiedTTL"
 	DNSResolverCacheExtensionVerifiedTTL             = "DNSResolverCacheExtensionVerifiedTTL"
 	AddFrontingProviderPsiphonFrontingHeader         = "AddFrontingProviderPsiphonFrontingHeader"
 	AddFrontingProviderPsiphonFrontingHeader         = "AddFrontingProviderPsiphonFrontingHeader"
+	DirectHTTPProtocolTransformSpecs                 = "DirectHTTPProtocolTransformSpecs"
+	DirectHTTPProtocolTransformScopedSpecNames       = "DirectHTTPProtocolTransformScopedSpecNames"
+	DirectHTTPProtocolTransformProbability           = "DirectHTTPProtocolTransformProbability"
+	FrontedHTTPProtocolTransformSpecs                = "FrontedHTTPProtocolTransformSpecs"
+	FrontedHTTPProtocolTransformScopedSpecNames      = "FrontedHTTPProtocolTransformScopedSpecNames"
+	FrontedHTTPProtocolTransformProbability          = "FrontedHTTPProtocolTransformProbability"
 )
 )
 
 
 const (
 const (
@@ -575,6 +582,7 @@ var defaultParameters = map[string]struct {
 	ReplayAPIRequestPadding:                {value: true},
 	ReplayAPIRequestPadding:                {value: true},
 	ReplayHoldOffTunnel:                    {value: true},
 	ReplayHoldOffTunnel:                    {value: true},
 	ReplayResolveParameters:                {value: true},
 	ReplayResolveParameters:                {value: true},
+	ReplayHTTPTransformerParameters:        {value: true},
 
 
 	APIRequestUpstreamPaddingMinBytes:   {value: 0, minimum: 0},
 	APIRequestUpstreamPaddingMinBytes:   {value: 0, minimum: 0},
 	APIRequestUpstreamPaddingMaxBytes:   {value: 1024, minimum: 0},
 	APIRequestUpstreamPaddingMaxBytes:   {value: 1024, minimum: 0},
@@ -678,6 +686,13 @@ var defaultParameters = map[string]struct {
 	DNSResolverCacheExtensionVerifiedTTL:        {value: time.Duration(0), minimum: time.Duration(0)},
 	DNSResolverCacheExtensionVerifiedTTL:        {value: time.Duration(0), minimum: time.Duration(0)},
 
 
 	AddFrontingProviderPsiphonFrontingHeader: {value: protocol.LabeledTunnelProtocols{}},
 	AddFrontingProviderPsiphonFrontingHeader: {value: protocol.LabeledTunnelProtocols{}},
+
+	DirectHTTPProtocolTransformSpecs:            {value: transforms.Specs{}},
+	DirectHTTPProtocolTransformScopedSpecNames:  {value: transforms.ScopedSpecNames{}},
+	DirectHTTPProtocolTransformProbability:      {value: 0.0, minimum: 0.0},
+	FrontedHTTPProtocolTransformSpecs:           {value: transforms.Specs{}},
+	FrontedHTTPProtocolTransformScopedSpecNames: {value: transforms.ScopedSpecNames{}},
+	FrontedHTTPProtocolTransformProbability:     {value: 0.0, minimum: 0.0},
 }
 }
 
 
 // IsServerSideOnly indicates if the parameter specified by name is used
 // IsServerSideOnly indicates if the parameter specified by name is used
@@ -859,6 +874,22 @@ func (p *Parameters) Set(
 	dnsResolverProtocolTransformSpecs, _ :=
 	dnsResolverProtocolTransformSpecs, _ :=
 		dnsResolverProtocolTransformSpecsValue.(transforms.Specs)
 		dnsResolverProtocolTransformSpecsValue.(transforms.Specs)
 
 
+	directHttpProtocolTransformSpecsValue, err := getAppliedValue(
+		DirectHTTPProtocolTransformSpecs, parameters, applyParameters)
+	if err != nil {
+		return nil, errors.Trace(err)
+	}
+	directHttpProtocolTransformSpecs, _ :=
+		directHttpProtocolTransformSpecsValue.(transforms.Specs)
+
+	frontedHttpProtocolTransformSpecsValue, err := getAppliedValue(
+		FrontedHTTPProtocolTransformSpecs, parameters, applyParameters)
+	if err != nil {
+		return nil, errors.Trace(err)
+	}
+	frontedHttpProtocolTransformSpecs, _ :=
+		frontedHttpProtocolTransformSpecsValue.(transforms.Specs)
+
 	for i := 0; i < len(applyParameters); i++ {
 	for i := 0; i < len(applyParameters); i++ {
 
 
 		count := 0
 		count := 0
@@ -1043,6 +1074,10 @@ func (p *Parameters) Set(
 				var specs transforms.Specs
 				var specs transforms.Specs
 				if name == DNSResolverProtocolTransformScopedSpecNames {
 				if name == DNSResolverProtocolTransformScopedSpecNames {
 					specs = dnsResolverProtocolTransformSpecs
 					specs = dnsResolverProtocolTransformSpecs
+				} else if name == DirectHTTPProtocolTransformScopedSpecNames {
+					specs = directHttpProtocolTransformSpecs
+				} else if name == FrontedHTTPProtocolTransformScopedSpecNames {
+					specs = frontedHttpProtocolTransformSpecs
 				}
 				}
 
 
 				err := v.Validate(specs)
 				err := v.Validate(specs)

+ 1 - 1
psiphon/common/resolver/resolver.go

@@ -1643,7 +1643,7 @@ func (conn *transformDNSPacketConn) Write(b []byte) (int, error) {
 	// the network packet MTU.
 	// the network packet MTU.
 
 
 	input := hex.EncodeToString(b)
 	input := hex.EncodeToString(b)
-	output, err := conn.transform.Apply(conn.seed, input)
+	output, err := conn.transform.ApplyString(conn.seed, input)
 	if err != nil {
 	if err != nil {
 		return 0, errors.Trace(err)
 		return 0, errors.Trace(err)
 	}
 	}

+ 272 - 0
psiphon/common/transforms/httpTransformer.go

@@ -0,0 +1,272 @@
+/*
+ * Copyright (c) 2023, Psiphon Inc.
+ * All rights reserved.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+
+package transforms
+
+import (
+	"bytes"
+	"context"
+	"math"
+	"net"
+	"net/textproto"
+	"strconv"
+
+	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common"
+	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common/errors"
+	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common/prng"
+)
+
+type HTTPTransformerParameters struct {
+	// ProtocolTransformName specifies the name associated with
+	// ProtocolTransformSpec and is used for metrics.
+	ProtocolTransformName string
+
+	// ProtocolTransformSpec specifies a transform to apply to the HTTP request.
+	// See: "github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common/transforms".
+	//
+	// HTTP transforms include strategies discovered by the Geneva team,
+	// https://geneva.cs.umd.edu.
+	ProtocolTransformSpec Spec
+
+	// ProtocolTransformSeed specifies the seed to use for generating random
+	// data in the ProtocolTransformSpec transform. To replay a transform,
+	// specify the same seed.
+	ProtocolTransformSeed *prng.Seed
+}
+
+const (
+	// httpTransformerReadWriteHeader HTTPTransformer is waiting to finish
+	// reading and writing the next HTTP request header.
+	httpTransformerReadWriteHeader = 0
+	// httpTransformerReadWriteBody HTTPTransformer is waiting to finish reading
+	// and writing the current HTTP request body.
+	httpTransformerReadWriteBody = 1
+)
+
+// HTTPTransformer wraps a net.Conn, intercepting Write calls and applying the
+// specified protocol transform.
+//
+// The HTTP request to be written (input to the Write) is converted to a
+// string, transformed, and converted back to binary and then actually written
+// to the underlying net.Conn.
+//
+// HTTPTransformer is not safe for concurrent use.
+type HTTPTransformer struct {
+	transform Spec
+	seed      *prng.Seed
+
+	// state is the HTTPTransformer state. Possible values are
+	// httpTransformerReadWriteHeader and httpTransformerReadWriteBody.
+	state int64
+	// b is used to buffer the accumulated bytes of the current HTTP request
+	// header until the entire header is received and written.
+	b bytes.Buffer
+	// remain is the number of remaining HTTP request bytes to write to the
+	// underlying net.Conn. Set to the value of Content-Length (HTTP request
+	// body bytes) plus the length of the transformed HTTP header once the
+	// current request header is received.
+	remain uint64
+
+	net.Conn
+}
+
+// Write implements the net.Conn interface.
+//
+// Note: it is assumed that the underlying transport, net.Conn, is a reliable
+// stream transport, i.e. TCP, therefore it is required that the caller stop
+// calling Write() on an instance of HTTPTransformer after an error is returned
+// because, following this assumption, the connection will have failed when a
+// Write() call to the underlying net.Conn fails; a new connection must be
+// established, net.Conn, and wrapped with a new HTTPTransformer. For this
+// reason, the return value may be the number of bytes buffered internally
+// and not the number of bytes written to the underlying net.Conn when a non-nil
+// error is returned.
+//
+// Warning: Does not handle chunked encoding and multiple HTTP requests written
+// in a single Write(). Must be called synchronously.
+func (t *HTTPTransformer) Write(b []byte) (int, error) {
+
+	if t.state == httpTransformerReadWriteHeader {
+
+		// Do not need to check return value https://github.com/golang/go/blob/1e9ff255a130200fcc4ec5e911d28181fce947d5/src/bytes/buffer.go#L164
+		t.b.Write(b)
+
+		// Wait until the entire HTTP request header has been read. Must check
+		// all accumulated bytes incase the "\r\n\r\n" separator is written over
+		// multiple Write() calls; from reading the go1.19.5 net/http code the
+		// entire HTTP request is written in a single Write() call.
+
+		sep := []byte("\r\n\r\n")
+
+		headerBodyLines := bytes.SplitN(t.b.Bytes(), sep, 2) // split header and body
+
+		if len(headerBodyLines) <= 1 {
+			// b buffered in t.b and the entire HTTP request header has not been
+			// recieved so another Write() call is expected.
+			return len(b), nil
+		} // else: HTTP request header has been read
+
+		// read Content-Length before applying transform
+
+		var headerLines [][]byte
+
+		lines := bytes.Split(headerBodyLines[0], []byte("\r\n"))
+		if len(lines) > 1 {
+			// skip request line, e.g. "GET /foo HTTP/1.1"
+			headerLines = lines[1:]
+		}
+
+		var cl []byte
+		contentLengthHeader := []byte("Content-Length:")
+
+		for _, header := range headerLines {
+
+			if bytes.HasPrefix(header, contentLengthHeader) {
+
+				cl = textproto.TrimBytes(header[len(contentLengthHeader):])
+				break
+			}
+		}
+		if len(cl) == 0 {
+			// Irrecoverable error because either Content-Length header
+			// missing, or Content-Length header value is empty, e.g.
+			// "Content-Length: ", and request body length cannot be
+			// determined.
+			return len(b), errors.TraceNew("Content-Length missing")
+		}
+
+		contentLength, err := strconv.ParseUint(string(cl), 10, 63)
+		if err != nil {
+			// Irrecoverable error because Content-Length is malformed and
+			// request body length cannot be determined.
+			return len(b), errors.Trace(err)
+		}
+
+		t.remain = contentLength
+
+		// transform and write header
+
+		headerLen := len(headerBodyLines[0]) + len(sep)
+		header := t.b.Bytes()[:headerLen]
+
+		if t.transform != nil {
+			newHeader, err := t.transform.Apply(t.seed, header)
+			if err != nil {
+				// TODO: consider logging an error and skiping transform
+				// instead of returning an error, if the transform is broken
+				// then all subsequent applications may fail.
+				return len(b), errors.Trace(err)
+			}
+
+			// only allocate new slice if header length changed
+			if len(newHeader) == len(header) {
+				// Do not need to check return value. It is guaranteed that
+				// n == len(newHeader) because t.b.Len() >= n if the header
+				// size has not changed.
+				copy(t.b.Bytes()[:len(header)], newHeader)
+			} else {
+				b := t.b.Bytes()
+				t.b.Reset()
+				// Do not need to check return value of bytes.Buffer.Write() https://github.com/golang/go/blob/1e9ff255a130200fcc4ec5e911d28181fce947d5/src/bytes/buffer.go#L164
+				t.b.Write(newHeader)
+				t.b.Write(b[len(header):])
+			}
+
+			header = newHeader
+		}
+
+		if math.MaxUint64-t.remain < uint64(len(header)) {
+			// Irrecoverable error because request is malformed:
+			// Content-Length + len(header) > math.MaxUint64.
+			return len(b), errors.TraceNew("t.remain + uint64(len(header)) overflows")
+		}
+		t.remain += uint64(len(header))
+
+		if uint64(t.b.Len()) > t.remain {
+			// Should never happen, multiple requests written in a single
+			// Write() are not supported.
+			return len(b), errors.TraceNew("multiple HTTP requests in single Write() not supported")
+		}
+
+		n, err := t.b.WriteTo(t.Conn)
+		t.remain -= uint64(n)
+
+		if t.remain > 0 {
+			t.state = httpTransformerReadWriteBody
+		}
+
+		// Do not wrap any I/O err returned by Conn
+		return len(b), err
+	}
+
+	// HTTP request header has been transformed. Write any remaining bytes of
+	// HTTP request header and then write HTTP request body.
+
+	// Must write buffered bytes first, in-order, to write bytes to underlying
+	// net.Conn in the same order they were received in.
+	//
+	// Already checked that t.b does not contain bytes of a subsequent HTTP
+	// request when the header is parsed, i.e. at this point it is guaranteed
+	// that t.b.Len() <= t.remain.
+	//
+	// In practise the buffer will be empty by this point because its entire
+	// contents will have been written in the first call to t.b.WriteTo(t.Conn)
+	// when the header is received, parsed, and transformed; otherwise the
+	// underlying transport will have failed and the caller will not invoke
+	// Write() again on this instance. See HTTPTransformer.Write() comment.
+	wrote, err := t.b.WriteTo(t.Conn)
+	t.remain -= uint64(wrote)
+	if err != nil {
+		// b not written or buffered
+		// Do not wrap any I/O err returned by Conn
+		return 0, err
+	}
+
+	if uint64(len(b)) > t.remain {
+		return len(b), errors.TraceNew("multiple HTTP requests in single Write() not supported")
+	}
+
+	n, err := t.Conn.Write(b)
+
+	t.remain -= uint64(n)
+
+	if t.remain <= 0 {
+		// Entire request, header and body, has been written. Return to
+		// waiting for next HTTP request header to arrive.
+		t.state = httpTransformerReadWriteHeader
+		t.remain = 0
+	}
+
+	// Do not wrap any I/O err returned by Conn
+	return n, err
+}
+
+func WrapDialerWithHTTPTransformer(dialer common.Dialer, params *HTTPTransformerParameters) common.Dialer {
+	return func(ctx context.Context, network, addr string) (net.Conn, error) {
+		conn, err := dialer(ctx, network, addr)
+		if err != nil {
+			return nil, errors.Trace(err)
+		}
+		return &HTTPTransformer{
+			Conn:      conn,
+			transform: params.ProtocolTransformSpec,
+			seed:      params.ProtocolTransformSeed,
+		}, nil
+	}
+}

+ 519 - 0
psiphon/common/transforms/httpTransformer_test.go

@@ -0,0 +1,519 @@
+/*
+ * Copyright (c) 2023, Psiphon Inc.
+ * All rights reserved.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+
+package transforms
+
+import (
+	"bytes"
+	"context"
+	"errors"
+	"fmt"
+	"io"
+	"math"
+	"net"
+	"net/http"
+	"strings"
+	"testing"
+	"time"
+
+	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common/prng"
+)
+
+func TestHTTPTransformerHTTPRequest(t *testing.T) {
+
+	type test struct {
+		name           string
+		input          string
+		wantOutput     string
+		wantError      error
+		chunkSize      int
+		transform      Spec
+		connWriteLimit int
+		connWriteLens  []int
+		connWriteErrs  []error
+	}
+
+	tests := []test{
+		{
+			name:       "written in chunks",
+			input:      "POST / HTTP/1.1\r\nContent-Length: 4\r\n\r\nabcd",
+			wantOutput: "POST / HTTP/1.1\r\nContent-Length: 4\r\n\r\nabcd",
+			chunkSize:  1,
+		},
+		{
+			name:       "write header then body", // behaviour of net/http code
+			input:      "POST / HTTP/1.1\r\nContent-Length: 4\r\n\r\nabcd",
+			wantOutput: "POST / HTTP/1.1\r\nContent-Length: 4\r\n\r\nabcd",
+			chunkSize:  38,
+		},
+		{
+			name:          "write header then body with error",
+			input:         "POST / HTTP/1.1\r\nContent-Length: 4\r\n\r\nabcd",
+			wantOutput:    "POST / HTTP/1.1\r\nContent-Length: 4\r\n\r\nabcd",
+			chunkSize:     38,
+			connWriteErrs: []error{nil, errors.New("err1")},
+			wantError:     errors.New("err1"),
+		},
+		{
+			name:       "written in a single write",
+			input:      "POST / HTTP/1.1\r\nContent-Length: 4\r\n\r\nabcd",
+			wantOutput: "POST / HTTP/1.1\r\nContent-Length: 4\r\n\r\nabcd",
+			chunkSize:  999,
+		},
+		{
+			name:          "written in single write with error",
+			input:         "POST / HTTP/1.1\r\nContent-Length: 4\r\n\r\nabcd",
+			wantOutput:    "POST / HTTP/1.1\r\nContent-Length: 4\r\n\r\nabcd",
+			chunkSize:     999,
+			connWriteErrs: []error{errors.New("err1")},
+			wantError:     errors.New("err1"),
+		},
+		{
+			name:           "written with partial write and errors",
+			input:          "POST / HTTP/1.1\r\nContent-Length: 4\r\n\r\nabcd",
+			wantOutput:     "POST / HTTP/1.1\r\nContent-Length: 4\r\n\r\nabcd",
+			chunkSize:      1,
+			connWriteLimit: 1,
+			connWriteErrs:  []error{errors.New("err1"), errors.New("err2")},
+			wantError:      errors.New("err1"),
+		},
+		{
+			name:       "transform not applied to body",
+			input:      "POST / HTTP/1.1\r\nContent-Length: 4\r\n\r\nabcd",
+			wantOutput: "POST / HTTP/1.1\r\nContent-Length: 4\r\n\r\nabcd",
+			chunkSize:  1,
+			transform:  Spec{[2]string{"abcd", "efgh"}},
+		},
+		{
+			name:      "Content-Length missing",
+			input:     "POST / HTTP/1.1\r\n\r\nabcd",
+			wantError: errors.New("Content-Length missing"),
+			chunkSize: 1,
+		},
+		{
+			name:      "Content-Length overflow",
+			input:     fmt.Sprintf("POST / HTTP/1.1\r\nContent-Length: %d\r\n\r\nabcd", uint64(math.MaxUint64)),
+			wantError: errors.New("strconv.ParseUint: parsing \"18446744073709551615\": value out of range"),
+			chunkSize: 1,
+		},
+		{
+			name:       "incorrect Content-Length header value",
+			input:      "POST / HTTP/1.1\r\nContent-Length: 3\r\n\r\nabcd",
+			wantOutput: "POST / HTTP/1.1\r\nContent-Length: 3\r\n\r\nabc",
+			chunkSize:  1,
+		},
+		{
+			name:       "transform",
+			input:      "POST / HTTP/1.1\r\nContent-Length: 4\r\n\r\nabcd",
+			wantOutput: "POST / HTTP/1.1\r\nContent-Length: 100\r\n\r\nabcd",
+			chunkSize:  1,
+			transform:  Spec{[2]string{"4", "100"}},
+		},
+		{
+			name:           "transform with partial write and errors in header write",
+			input:          "POST / HTTP/1.1\r\nContent-Length: 4\r\n\r\nabcd",
+			wantOutput:     "POST / HTTP/1.1\r\nContent-Length: 100\r\n\r\nabcd",
+			chunkSize:      1,
+			transform:      Spec{[2]string{"4", "100"}},
+			connWriteLimit: 1,
+			connWriteErrs:  []error{errors.New("err1"), errors.New("err2")},
+			wantError:      errors.New("err1"),
+		},
+		{
+			name:           "transform with chunk write and errors in body write",
+			input:          "POST / HTTP/1.1\r\nContent-Length: 4\r\n\r\nabcd",
+			wantOutput:     "POST / HTTP/1.1\r\nContent-Length: 100\r\n\r\nabcd",
+			chunkSize:      39,
+			transform:      Spec{[2]string{"4", "100"}},
+			connWriteLimit: 1,
+			connWriteErrs:  []error{errors.New("err1"), errors.New("err2"), errors.New("err3")},
+			wantError:      errors.New("err1"),
+		},
+		//
+		// Below tests document unsupported behavior.
+		//
+		{
+			name:          "written in a single write with errors and partial writes",
+			input:         "POST / HTTP/1.1\r\nHost: example.com\r\nContent-Length: 0\r\n\r\n",
+			wantOutput:    "POST / HTTP/1.1\r\nContent-Length: 0\r\n\r\n",
+			chunkSize:     999,
+			transform:     Spec{[2]string{"Host: example.com\r\n", ""}},
+			connWriteErrs: []error{errors.New("err1"), nil, errors.New("err2"), nil, nil, errors.New("err3")},
+			connWriteLens: []int{1, 2, 3, 4, 5, 6, 7, 8, 9, 10},
+			wantError:     errors.New("err1"),
+		},
+		{
+			name:          "written in a single write with error and partial write",
+			input:         "POST / HTTP/1.1\r\nHost: example.com\r\nContent-Length: 4\r\n\r\nabcd",
+			wantOutput:    "POST / HTTP/1.1\r\nContent-Length: 4\r\n\r\nabcd",
+			chunkSize:     999,
+			transform:     Spec{[2]string{"Host: example.com\r\n", ""}},
+			connWriteErrs: []error{errors.New("err1")},
+			connWriteLens: []int{28}, // write lands mid "\r\n\r\n"
+			wantError:     errors.New("err1"),
+		},
+		{
+			name:       "multiple HTTP requests written in a single write",
+			input:      "POST / HTTP/1.1\r\nContent-Length: 4\r\n\r\nabcdPOST / HTTP/1.1\r\nContent-Length: 2\r\n\r\n12",
+			wantOutput: "POST / HTTP/1.1\r\nContent-Length: 4\r\n\r\nabcdPOST / HTTP/1.1\r\nContent-Length: 2\r\n\r\n12",
+			chunkSize:  999,
+			wantError:  errors.New("multiple HTTP requests in single Write() not supported"),
+		},
+		{
+			name:       "multiple HTTP requests written in a single write with transform",
+			input:      "POST / HTTP/1.1\r\nContent-Length: 4\r\n\r\nabcdPOST / HTTP/1.1\r\nContent-Length: 4\r\n\r\n12POST / HTTP/1.1\r\nContent-Length: 4\r\n\r\n34",
+			wantOutput: "POST / HTTP/1.1\r\nContent-Length: 100\r\n\r\nabcdPOST / HTTP/1.1\r\nContent-Length: 100\r\n\r\n12POST / HTTP/1.1\r\nContent-Length: 100\r\n\r\n34",
+			chunkSize:  999,
+			transform:  Spec{[2]string{"4", "100"}},
+			wantError:  errors.New("multiple HTTP requests in single Write() not supported"),
+		},
+		// Multiple HTTP requests written in a single write. A write will occur
+		// where it contains both the end of the previous HTTP request and the
+		// start of a new one.
+		{
+			name:       "multiple HTTP requests written in chunks",
+			input:      "POST / HTTP/1.1\r\nContent-Length: 4\r\n\r\nabcdPOST / HTTP/1.1\r\nContent-Length: 2\r\n\r\n12",
+			wantOutput: "POST / HTTP/1.1\r\nContent-Length: 4\r\n\r\nabcdPOST / HTTP/1.1\r\nContent-Length: 2\r\n\r\n12",
+			chunkSize:  4,
+			wantError:  errors.New("multiple HTTP requests in single Write() not supported"),
+		},
+		// Multiple HTTP requests written in a single write with transform. A
+		// write will occur where it contains both the end of the previous HTTP
+		// request and the start of a new one.
+		{
+			name:       "multiple HTTP requests written in chunks with transform",
+			input:      "POST / HTTP/1.1\r\nContent-Length: 4\r\n\r\nabcdPOST / HTTP/1.1\r\nContent-Length: 4\r\n\r\n12",
+			wantOutput: "POST / HTTP/1.1\r\nContent-Length: 100\r\n\r\nabcdPOST / HTTP/1.1\r\nContent-Length: 100\r\n\r\n12",
+			chunkSize:  4, // ensure one write contains bytes from both reqs
+			transform:  Spec{[2]string{"4", "100"}},
+			wantError:  errors.New("multiple HTTP requests in single Write() not supported"),
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+
+			seed, err := prng.NewSeed()
+			if err != nil {
+				t.Fatalf("prng.NewSeed failed %v", err)
+			}
+
+			conn := testConn{
+				writeLimit: tt.connWriteLimit,
+				writeLens:  tt.connWriteLens,
+				writeErrs:  tt.connWriteErrs,
+			}
+
+			transformer := &HTTPTransformer{
+				transform: tt.transform,
+				seed:      seed,
+				Conn:      &conn,
+			}
+
+			remain := []byte(tt.input)
+
+			// Write input bytes to transformer in chunks and then check
+			// output.
+			for {
+				if len(remain) == 0 {
+					break
+				}
+
+				var b []byte
+				if len(remain) < tt.chunkSize || tt.chunkSize == 0 {
+					b = remain
+				} else {
+					b = remain[:tt.chunkSize]
+				}
+
+				var n int
+				n, err = transformer.Write(b)
+				if err != nil {
+					// The underlying transport will be a reliable stream
+					// transport, i.e. TCP, and we expect the caller to stop
+					// writing after an error is returned.
+					break
+				}
+
+				remain = remain[n:]
+			}
+			if tt.wantError == nil {
+				if err != nil {
+					t.Fatalf("unexpected error %v", err)
+				}
+				if string(conn.b) != tt.wantOutput {
+					t.Fatalf("expected \"%s\" of len %d but got \"%s\" of len %d", escapeNewlines(tt.wantOutput), len(tt.wantOutput), escapeNewlines(string(conn.b)), len(conn.b))
+				}
+			} else {
+				// tt.wantError != nil
+				if err == nil {
+					t.Fatalf("expected error %v", tt.wantError)
+				} else if !strings.Contains(err.Error(), tt.wantError.Error()) {
+					t.Fatalf("expected error %v got %v", tt.wantError, err)
+				}
+			}
+		})
+	}
+}
+
+func TestHTTPTransformerHTTPServer(t *testing.T) {
+
+	type test struct {
+		name           string
+		request        func(string) *http.Request
+		wantBody       string
+		transform      Spec
+		connWriteLimit int
+		connWriteLens  []int
+		connWriteErrs  []error
+		wantError      error
+	}
+
+	tests := []test{
+		{
+			name:      "request body truncated",
+			transform: Spec{[2]string{"Content-Length: 4", "Content-Length: 3"}},
+			request: func(addr string) *http.Request {
+
+				body := bytes.NewReader([]byte("abcd"))
+
+				req, err := http.NewRequest("POST", "http://"+addr, body)
+				if err != nil {
+					panic(err)
+				}
+
+				return req
+			},
+			wantBody: "abc",
+		},
+		// Expect HTTP request to abort after a single Write() call on
+		// underlying net.Conn fails.
+		{
+			name:      "transport fails",
+			transform: Spec{[2]string{"", ""}},
+			request: func(addr string) *http.Request {
+
+				body := bytes.NewReader([]byte("abcd"))
+
+				req, err := http.NewRequest("POST", "http://"+addr, body)
+				if err != nil {
+					panic(err)
+				}
+
+				return req
+			},
+			wantBody:      "abc",
+			connWriteErrs: []error{errors.New("test error")},
+			wantError:     errors.New("test error"),
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+
+			seed, err := prng.NewSeed()
+			if err != nil {
+				t.Fatalf("prng.NewSeed failed %v", err)
+			}
+
+			params := &HTTPTransformerParameters{
+				ProtocolTransformName: "spec",
+				ProtocolTransformSpec: tt.transform,
+				ProtocolTransformSeed: seed,
+			}
+
+			dialer := func(ctx context.Context, network, address string) (net.Conn, error) {
+
+				if network != "tcp" {
+					return nil, errors.New("expected network tcp")
+				}
+
+				conn, err := net.Dial(network, address)
+				if err != nil {
+					return nil, err
+				}
+
+				wrappedConn := testConn{
+					Conn:       conn,
+					writeLimit: tt.connWriteLimit,
+					writeLens:  tt.connWriteLens,
+					writeErrs:  tt.connWriteErrs,
+				}
+
+				return &wrappedConn, nil
+			}
+
+			httpTransport := &http.Transport{
+				DialContext: WrapDialerWithHTTPTransformer(dialer, params),
+			}
+
+			type serverRequest struct {
+				req  *http.Request
+				body []byte
+			}
+
+			serverReq := make(chan *serverRequest, 1)
+
+			mux := http.NewServeMux()
+
+			mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
+				b, err := io.ReadAll(r.Body)
+				if err != nil {
+					w.WriteHeader(http.StatusInternalServerError)
+				}
+				go func() {
+					serverReq <- &serverRequest{
+						req:  r,
+						body: b,
+					}
+					close(serverReq)
+				}()
+			})
+
+			s := &http.Server{
+				Addr:    "127.0.0.1:8080",
+				Handler: mux,
+			}
+
+			go func() {
+				s.ListenAndServe()
+			}()
+
+			client := http.Client{
+				Transport: httpTransport,
+				Timeout:   2 * time.Second,
+			}
+
+			req := tt.request(s.Addr)
+
+			resp, err := client.Do(req)
+
+			// first shutdown server, then check err
+			shutdownErr := s.Shutdown(context.Background())
+			if shutdownErr != nil {
+				t.Fatalf("s.Shutdown failed %v", shutdownErr)
+			}
+
+			if tt.wantError == nil {
+				if err != nil {
+					t.Fatalf("client.Do failed %v", err)
+				}
+				if resp.StatusCode != http.StatusOK {
+					t.Fatalf("expected 200 but got %d", resp.StatusCode)
+				}
+
+				r := <-serverReq
+
+				if tt.wantBody != string(r.body) {
+					t.Fatalf("expected body %s but got %s", tt.wantBody, string(r.body))
+				}
+			} else {
+				// tt.wantError != nil
+				if err == nil {
+					t.Fatalf("expected error %v", tt.wantError)
+				} else if !strings.Contains(err.Error(), tt.wantError.Error()) {
+					t.Fatalf("expected error %v got %v", tt.wantError, err)
+				}
+			}
+		})
+	}
+}
+
+func escapeNewlines(s string) string {
+	s = strings.ReplaceAll(s, "\n", "\\n")
+	s = strings.ReplaceAll(s, "\r", "\\r")
+	return s
+}
+
+type testConn struct {
+	// b is the accumulated bytes from Write() calls.
+	b []byte
+	// writeLimit is the max number of bytes that will be written in a Write()
+	// call.
+	writeLimit int
+	// writeLens are returned from Write() calls in order and determine the
+	// max number of bytes that will be written. Overrides writeLimit if
+	// non-empty. If empty, then the value of writeLimit is returned.
+	writeLens []int
+	// writeErrs are returned from Write() calls in order. If empty, then a nil
+	// error is returned.
+	writeErrs []error
+
+	net.Conn
+}
+
+func (c *testConn) Read(b []byte) (n int, err error) {
+	return c.Conn.Read(b)
+}
+
+func (c *testConn) Write(b []byte) (n int, err error) {
+
+	if len(c.writeErrs) > 0 {
+		err = c.writeErrs[0]
+		c.writeErrs = c.writeErrs[1:]
+	}
+
+	if len(c.writeLens) > 0 {
+		n = c.writeLens[0]
+		c.writeLens = c.writeLens[1:]
+		if len(b) <= n {
+			c.b = append(c.b, b...)
+			n = len(b)
+		} else {
+			c.b = append(c.b, b[:n]...)
+		}
+	} else if c.writeLimit != 0 && c.writeLimit < len(b) {
+		c.b = append(c.b, b[:c.writeLimit]...)
+		n = c.writeLimit
+	} else {
+		c.b = append(c.b, b...)
+		n = len(b)
+	}
+
+	// Only write to net.Conn if set
+	if c.Conn != nil && n > 0 {
+		c.Conn.Write(b[:n])
+	}
+
+	return
+}
+
+func (c *testConn) Close() error {
+	return c.Conn.Close()
+}
+
+func (c *testConn) LocalAddr() net.Addr {
+	return c.Conn.LocalAddr()
+}
+
+func (c *testConn) RemoteAddr() net.Addr {
+	return c.Conn.RemoteAddr()
+}
+
+func (c *testConn) SetDeadline(t time.Time) error {
+	return c.Conn.SetDeadline(t)
+}
+
+func (c *testConn) SetReadDeadline(t time.Time) error {
+	return c.Conn.SetReadDeadline(t)
+}
+
+func (c *testConn) SetWriteDeadline(t time.Time) error {
+	return c.Conn.SetWriteDeadline(t)
+}

+ 44 - 18
psiphon/common/transforms/transforms.go

@@ -45,7 +45,7 @@ const (
 // data may be retained in the transformed data.
 // data may be retained in the transformed data.
 //
 //
 // For example, with the transform [2]string{"([a-b])", "\\$\\
 // For example, with the transform [2]string{"([a-b])", "\\$\\
-// {1\\}"c}, substrings consisting of the characters 'a' and 'b' will be
+// {1\\}c"}, substrings consisting of the characters 'a' and 'b' will be
 // transformed into the same substring with a single character 'c' appended.
 // transformed into the same substring with a single character 'c' appended.
 type Spec [][2]string
 type Spec [][2]string
 
 
@@ -61,7 +61,7 @@ func (specs Specs) Validate() error {
 	}
 	}
 	for _, spec := range specs {
 	for _, spec := range specs {
 		// Call Apply to compile/validate the regular expressions and generators.
 		// Call Apply to compile/validate the regular expressions and generators.
-		_, err := spec.Apply(seed, "")
+		_, err := spec.ApplyString(seed, "")
 		if err != nil {
 		if err != nil {
 			return errors.Trace(err)
 			return errors.Trace(err)
 		}
 		}
@@ -142,30 +142,56 @@ func (specs Specs) Select(scope string, scopedSpecs ScopedSpecNames) (string, Sp
 	return specName, spec
 	return specName, spec
 }
 }
 
 
-// Apply applies the Spec to the input string, producing the output string.
+// ApplyString applies the Spec to the input string, producing the output string.
 //
 //
 // The input seed is used for all random generation. The same seed can be
 // The input seed is used for all random generation. The same seed can be
 // supplied to produce the same output, for replay.
 // supplied to produce the same output, for replay.
-func (spec Spec) Apply(seed *prng.Seed, input string) (string, error) {
-
-	// TODO: the compiled regexp and regen could be cached, but the seed is an
-	// issue with caching the regen.
+func (spec Spec) ApplyString(seed *prng.Seed, input string) (string, error) {
 
 
 	value := input
 	value := input
 	for _, transform := range spec {
 	for _, transform := range spec {
 
 
-		args := &regen.GeneratorArgs{
-			RngSource: prng.NewPRNGWithSeed(seed),
-			Flags:     syntax.OneLine | syntax.NonGreedy,
-		}
-		rg, err := regen.NewGenerator(transform[1], args)
-		if err != nil {
-			panic(err.Error())
-		}
-		replacement := rg.Generate()
-
-		re := regexp.MustCompile(transform[0])
+		re, replacement := makeRegexAndRepl(seed, transform)
 		value = re.ReplaceAllString(value, replacement)
 		value = re.ReplaceAllString(value, replacement)
 	}
 	}
 	return value, nil
 	return value, nil
 }
 }
+
+// Apply applies the Spec to the input bytes, producing the output bytes.
+//
+// The input seed is used for all random generation. The same seed can be
+// supplied to produce the same output, for replay.
+func (spec Spec) Apply(seed *prng.Seed, input []byte) ([]byte, error) {
+
+	value := input
+	for _, transform := range spec {
+
+		re, replacement := makeRegexAndRepl(seed, transform)
+		value = re.ReplaceAll(value, []byte(replacement))
+	}
+	return value, nil
+}
+
+// makeRegexAndRepl generates the regex and replacement for a given seed and
+// transform. The same seed can be supplied to produce the same output, for
+// replay.
+func makeRegexAndRepl(seed *prng.Seed, transform [2]string) (re *regexp.Regexp, replacement string) {
+
+	// TODO: the compiled regexp and regen could be cached, but the seed is an
+	// issue with caching the regen.
+
+	args := &regen.GeneratorArgs{
+		RngSource: prng.NewPRNGWithSeed(seed),
+		Flags:     syntax.OneLine | syntax.NonGreedy,
+	}
+	rg, err := regen.NewGenerator(transform[1], args)
+	if err != nil {
+		panic(err.Error())
+	}
+
+	replacement = rg.Generate()
+
+	re = regexp.MustCompile(transform[0])
+
+	return
+}

+ 3 - 3
psiphon/common/transforms/transforms_test.go

@@ -87,7 +87,7 @@ func runTestTransforms() error {
 	}
 	}
 
 
 	input := "aa0aa0aa0bb0aa0bb0aa0bb0aa0bb0aa0bb0aa0bb0aa0bb0aa0bb0aa0bb0aa"
 	input := "aa0aa0aa0bb0aa0bb0aa0bb0aa0bb0aa0bb0aa0bb0aa0bb0aa0bb0aa0bb0aa"
-	output, err := spec.Apply(seed, input)
+	output, err := spec.ApplyString(seed, input)
 	if err != nil {
 	if err != nil {
 		return errors.Trace(err)
 		return errors.Trace(err)
 	}
 	}
@@ -102,7 +102,7 @@ func runTestTransforms() error {
 
 
 	previousOutput := output
 	previousOutput := output
 
 
-	output, err = spec.Apply(seed, input)
+	output, err = spec.ApplyString(seed, input)
 	if err != nil {
 	if err != nil {
 		return errors.Trace(err)
 		return errors.Trace(err)
 	}
 	}
@@ -121,7 +121,7 @@ func runTestTransforms() error {
 			return errors.Trace(err)
 			return errors.Trace(err)
 		}
 		}
 
 
-		output, err = spec.Apply(seed, input)
+		output, err = spec.ApplyString(seed, input)
 		if err != nil {
 		if err != nil {
 			return errors.Trace(err)
 			return errors.Trace(err)
 		}
 		}

+ 69 - 0
psiphon/config.go

@@ -825,6 +825,13 @@ type Config struct {
 	DNSResolverCacheExtensionInitialTTLMilliseconds  *int
 	DNSResolverCacheExtensionInitialTTLMilliseconds  *int
 	DNSResolverCacheExtensionVerifiedTTLMilliseconds *int
 	DNSResolverCacheExtensionVerifiedTTLMilliseconds *int
 
 
+	DirectHTTPProtocolTransformSpecs            transforms.Specs
+	DirectHTTPProtocolTransformScopedSpecNames  transforms.ScopedSpecNames
+	DirectHTTPProtocolTransformProbability      *float64
+	FrontedHTTPProtocolTransformSpecs           transforms.Specs
+	FrontedHTTPProtocolTransformScopedSpecNames transforms.ScopedSpecNames
+	FrontedHTTPProtocolTransformProbability     *float64
+
 	// params is the active parameters.Parameters with defaults, config values,
 	// params is the active parameters.Parameters with defaults, config values,
 	// and, optionally, tactics applied.
 	// and, optionally, tactics applied.
 	//
 	//
@@ -1907,6 +1914,30 @@ func (config *Config) makeConfigParameters() map[string]interface{} {
 		applyParameters[parameters.DNSResolverCacheExtensionVerifiedTTL] = fmt.Sprintf("%dms", *config.DNSResolverCacheExtensionVerifiedTTLMilliseconds)
 		applyParameters[parameters.DNSResolverCacheExtensionVerifiedTTL] = fmt.Sprintf("%dms", *config.DNSResolverCacheExtensionVerifiedTTLMilliseconds)
 	}
 	}
 
 
+	if config.DirectHTTPProtocolTransformSpecs != nil {
+		applyParameters[parameters.DirectHTTPProtocolTransformSpecs] = config.DirectHTTPProtocolTransformSpecs
+	}
+
+	if config.DirectHTTPProtocolTransformScopedSpecNames != nil {
+		applyParameters[parameters.DirectHTTPProtocolTransformScopedSpecNames] = config.DirectHTTPProtocolTransformScopedSpecNames
+	}
+
+	if config.DirectHTTPProtocolTransformProbability != nil {
+		applyParameters[parameters.DirectHTTPProtocolTransformProbability] = *config.DirectHTTPProtocolTransformProbability
+	}
+
+	if config.FrontedHTTPProtocolTransformSpecs != nil {
+		applyParameters[parameters.FrontedHTTPProtocolTransformSpecs] = config.FrontedHTTPProtocolTransformSpecs
+	}
+
+	if config.FrontedHTTPProtocolTransformScopedSpecNames != nil {
+		applyParameters[parameters.FrontedHTTPProtocolTransformScopedSpecNames] = config.FrontedHTTPProtocolTransformScopedSpecNames
+	}
+
+	if config.FrontedHTTPProtocolTransformProbability != nil {
+		applyParameters[parameters.FrontedHTTPProtocolTransformProbability] = *config.FrontedHTTPProtocolTransformProbability
+	}
+
 	// When adding new config dial parameters that may override tactics, also
 	// When adding new config dial parameters that may override tactics, also
 	// update setDialParametersHash.
 	// update setDialParametersHash.
 
 
@@ -2315,6 +2346,44 @@ func (config *Config) setDialParametersHash() {
 		binary.Write(hash, binary.LittleEndian, int64(*config.DNSResolverCacheExtensionVerifiedTTLMilliseconds))
 		binary.Write(hash, binary.LittleEndian, int64(*config.DNSResolverCacheExtensionVerifiedTTLMilliseconds))
 	}
 	}
 
 
+	if config.DirectHTTPProtocolTransformSpecs != nil {
+		hash.Write([]byte("DirectHTTPProtocolTransformSpecs"))
+		encodedDirectHTTPProtocolTransformSpecs, _ :=
+			json.Marshal(config.DirectHTTPProtocolTransformSpecs)
+		hash.Write(encodedDirectHTTPProtocolTransformSpecs)
+	}
+
+	if config.DirectHTTPProtocolTransformScopedSpecNames != nil {
+		hash.Write([]byte(""))
+		encodedDirectHTTPProtocolTransformScopedSpecNames, _ :=
+			json.Marshal(config.DirectHTTPProtocolTransformScopedSpecNames)
+		hash.Write(encodedDirectHTTPProtocolTransformScopedSpecNames)
+	}
+
+	if config.DirectHTTPProtocolTransformProbability != nil {
+		hash.Write([]byte("DirectHTTPProtocolTransformProbability"))
+		binary.Write(hash, binary.LittleEndian, *config.DirectHTTPProtocolTransformProbability)
+	}
+
+	if config.FrontedHTTPProtocolTransformSpecs != nil {
+		hash.Write([]byte("FrontedHTTPProtocolTransformSpecs"))
+		encodedFrontedHTTPProtocolTransformSpecs, _ :=
+			json.Marshal(config.FrontedHTTPProtocolTransformSpecs)
+		hash.Write(encodedFrontedHTTPProtocolTransformSpecs)
+	}
+
+	if config.FrontedHTTPProtocolTransformScopedSpecNames != nil {
+		hash.Write([]byte(""))
+		encodedFrontedHTTPProtocolTransformScopedSpecNames, _ :=
+			json.Marshal(config.FrontedHTTPProtocolTransformScopedSpecNames)
+		hash.Write(encodedFrontedHTTPProtocolTransformScopedSpecNames)
+	}
+
+	if config.FrontedHTTPProtocolTransformProbability != nil {
+		hash.Write([]byte("FrontedHTTPProtocolTransformProbability"))
+		binary.Write(hash, binary.LittleEndian, *config.FrontedHTTPProtocolTransformProbability)
+	}
+
 	config.dialParametersHash = hash.Sum(nil)
 	config.dialParametersHash = hash.Sum(nil)
 }
 }
 
 

+ 78 - 0
psiphon/dialParameters.go

@@ -38,6 +38,7 @@ import (
 	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common/prng"
 	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common/prng"
 	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common/protocol"
 	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common/protocol"
 	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common/resolver"
 	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common/resolver"
+	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common/transforms"
 	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common/values"
 	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common/values"
 	utls "github.com/refraction-networking/utls"
 	utls "github.com/refraction-networking/utls"
 	regen "github.com/zach-klippenstein/goregen"
 	regen "github.com/zach-klippenstein/goregen"
@@ -143,6 +144,8 @@ type DialParameters struct {
 	resolver          *resolver.Resolver `json:"-"`
 	resolver          *resolver.Resolver `json:"-"`
 	ResolveParameters *resolver.ResolveParameters
 	ResolveParameters *resolver.ResolveParameters
 
 
+	HTTPTransformerParameters *transforms.HTTPTransformerParameters
+
 	dialConfig *DialConfig `json:"-"`
 	dialConfig *DialConfig `json:"-"`
 	meekConfig *MeekConfig `json:"-"`
 	meekConfig *MeekConfig `json:"-"`
 }
 }
@@ -196,6 +199,7 @@ func MakeDialParameters(
 	replayAPIRequestPadding := p.Bool(parameters.ReplayAPIRequestPadding)
 	replayAPIRequestPadding := p.Bool(parameters.ReplayAPIRequestPadding)
 	replayHoldOffTunnel := p.Bool(parameters.ReplayHoldOffTunnel)
 	replayHoldOffTunnel := p.Bool(parameters.ReplayHoldOffTunnel)
 	replayResolveParameters := p.Bool(parameters.ReplayResolveParameters)
 	replayResolveParameters := p.Bool(parameters.ReplayResolveParameters)
+	replayHTTPTransformerParameters := p.Bool(parameters.ReplayHTTPTransformerParameters)
 
 
 	// Check for existing dial parameters for this server/network ID.
 	// Check for existing dial parameters for this server/network ID.
 
 
@@ -772,6 +776,22 @@ func MakeDialParameters(
 
 
 	}
 	}
 
 
+	if (!isReplay || !replayHTTPTransformerParameters) && protocol.TunnelProtocolUsesMeekHTTP(dialParams.TunnelProtocol) {
+
+		isFronted := protocol.TunnelProtocolUsesFrontedMeek(dialParams.TunnelProtocol)
+
+		params, err := makeHTTPTransformerParameters(config.GetParameters().Get(), serverEntry.FrontingProviderID, isFronted)
+		if err != nil {
+			return nil, errors.Trace(err)
+		}
+
+		if params.ProtocolTransformSpec != nil {
+			dialParams.HTTPTransformerParameters = params
+		} else {
+			dialParams.HTTPTransformerParameters = nil
+		}
+	}
+
 	// Set dial address fields. This portion of configuration is
 	// Set dial address fields. This portion of configuration is
 	// deterministic, given the parameters established or replayed so far.
 	// deterministic, given the parameters established or replayed so far.
 
 
@@ -979,6 +999,7 @@ func MakeDialParameters(
 			MeekObfuscatedKey:             serverEntry.MeekObfuscatedKey,
 			MeekObfuscatedKey:             serverEntry.MeekObfuscatedKey,
 			MeekObfuscatorPaddingSeed:     dialParams.MeekObfuscatorPaddingSeed,
 			MeekObfuscatorPaddingSeed:     dialParams.MeekObfuscatorPaddingSeed,
 			NetworkLatencyMultiplier:      dialParams.NetworkLatencyMultiplier,
 			NetworkLatencyMultiplier:      dialParams.NetworkLatencyMultiplier,
+			HTTPTransformerParameters:     dialParams.HTTPTransformerParameters,
 		}
 		}
 
 
 		// Use an asynchronous callback to record the resolved IP address when
 		// Use an asynchronous callback to record the resolved IP address when
@@ -1379,3 +1400,60 @@ func selectHostName(
 
 
 	return hostName
 	return hostName
 }
 }
+
+// makeHTTPTransformerParameters generates HTTPTransformerParameters using the
+// input tactics parameters and optional frontingProviderID context.
+func makeHTTPTransformerParameters(p parameters.ParametersAccessor,
+	frontingProviderID string, isFronted bool) (*transforms.HTTPTransformerParameters, error) {
+
+	params := transforms.HTTPTransformerParameters{}
+
+	// Select an HTTP transform. If the request is fronted, HTTP request
+	// transforms are "scoped" by fronting provider ID. Otherwise, a transform
+	// from the default scope (transforms.SCOPE_ANY == "") is selected.
+
+	var specsKey string
+	var scopedSpecsNamesKey string
+
+	useTransform := false
+	scope := transforms.SCOPE_ANY
+
+	if isFronted {
+		if p.WeightedCoinFlip(parameters.FrontedHTTPProtocolTransformProbability) {
+			useTransform = true
+			scope = frontingProviderID
+			specsKey = parameters.FrontedHTTPProtocolTransformSpecs
+			scopedSpecsNamesKey = parameters.FrontedHTTPProtocolTransformScopedSpecNames
+		}
+	} else {
+		// unfronted
+		if p.WeightedCoinFlip(parameters.DirectHTTPProtocolTransformProbability) {
+			useTransform = true
+			specsKey = parameters.DirectHTTPProtocolTransformSpecs
+			scopedSpecsNamesKey = parameters.DirectHTTPProtocolTransformScopedSpecNames
+		}
+	}
+
+	if useTransform {
+
+		specs := p.ProtocolTransformSpecs(
+			specsKey)
+		scopedSpecNames := p.ProtocolTransformScopedSpecNames(
+			scopedSpecsNamesKey)
+
+		name, spec := specs.Select(scope, scopedSpecNames)
+
+		if spec != nil {
+			params.ProtocolTransformName = name
+			params.ProtocolTransformSpec = spec
+			var err error
+			// transform seed generated
+			params.ProtocolTransformSeed, err = prng.NewSeed()
+			if err != nil {
+				return nil, errors.Trace(err)
+			}
+		}
+	}
+
+	return &params, nil
+}

+ 104 - 0
psiphon/dialParameters_test.go

@@ -33,6 +33,7 @@ import (
 	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common/parameters"
 	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common/parameters"
 	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common/prng"
 	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common/prng"
 	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common/protocol"
 	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common/protocol"
+	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common/transforms"
 	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common/values"
 	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common/values"
 )
 )
 
 
@@ -89,6 +90,9 @@ func runDialParametersAndReplay(t *testing.T, tunnelProtocol string) {
 	applyParameters[parameters.HoldOffTunnelFrontingProviderIDs] = []string{frontingProviderID}
 	applyParameters[parameters.HoldOffTunnelFrontingProviderIDs] = []string{frontingProviderID}
 	applyParameters[parameters.HoldOffTunnelProbability] = 1.0
 	applyParameters[parameters.HoldOffTunnelProbability] = 1.0
 	applyParameters[parameters.DNSResolverAlternateServers] = []string{"127.0.0.1", "127.0.0.2", "127.0.0.3"}
 	applyParameters[parameters.DNSResolverAlternateServers] = []string{"127.0.0.1", "127.0.0.2", "127.0.0.3"}
+	applyParameters[parameters.DirectHTTPProtocolTransformProbability] = 1.0
+	applyParameters[parameters.DirectHTTPProtocolTransformSpecs] = transforms.Specs{"spec": transforms.Spec{{"", ""}}}
+	applyParameters[parameters.DirectHTTPProtocolTransformScopedSpecNames] = transforms.ScopedSpecNames{"": {"spec"}}
 	err = clientConfig.SetParameters("tag1", false, applyParameters)
 	err = clientConfig.SetParameters("tag1", false, applyParameters)
 	if err != nil {
 	if err != nil {
 		t.Fatalf("SetParameters failed: %s", err)
 		t.Fatalf("SetParameters failed: %s", err)
@@ -356,6 +360,12 @@ func runDialParametersAndReplay(t *testing.T, tunnelProtocol string) {
 		t.Fatalf("mismatching ResolveParameters fields")
 		t.Fatalf("mismatching ResolveParameters fields")
 	}
 	}
 
 
+	if (replayDialParams.HTTPTransformerParameters == nil) != (dialParams.HTTPTransformerParameters == nil) ||
+		(replayDialParams.HTTPTransformerParameters != nil &&
+			!reflect.DeepEqual(replayDialParams.HTTPTransformerParameters, dialParams.HTTPTransformerParameters)) {
+		t.Fatalf("mismatching HTTPTransformerParameters fields")
+	}
+
 	// Test: no replay after change tactics
 	// Test: no replay after change tactics
 
 
 	applyParameters[parameters.ReplayDialParametersTTL] = "1s"
 	applyParameters[parameters.ReplayDialParametersTTL] = "1s"
@@ -702,3 +712,97 @@ func makeMockServerEntries(
 
 
 	return serverEntries
 	return serverEntries
 }
 }
+
+func TestMakeHTTPTransformerParameters(t *testing.T) {
+
+	type test struct {
+		name                  string
+		frontingProviderID    string
+		isFronted             bool
+		paramValues           map[string]interface{}
+		expectedTransformName string
+		expectedTransformSpec transforms.Spec
+	}
+
+	tests := []test{
+		{
+			name:               "unfronted",
+			frontingProviderID: "",
+			isFronted:          false,
+			paramValues: map[string]interface{}{
+				"DirectHTTPProtocolTransformProbability": 1,
+				"DirectHTTPProtocolTransformSpecs": transforms.Specs{
+					"spec1": {{"A", "B"}},
+				},
+				"DirectHTTPProtocolTransformScopedSpecNames": transforms.ScopedSpecNames{
+					"": {"spec1"},
+				},
+			},
+			expectedTransformName: "spec1",
+			expectedTransformSpec: [][2]string{{"A", "B"}},
+		},
+		{
+			name:               "fronted",
+			frontingProviderID: "frontingProvider",
+			isFronted:          true,
+			paramValues: map[string]interface{}{
+				"FrontedHTTPProtocolTransformProbability": 1,
+				"FrontedHTTPProtocolTransformSpecs": transforms.Specs{
+					"spec1": {{"A", "B"}},
+				},
+				"FrontedHTTPProtocolTransformScopedSpecNames": transforms.ScopedSpecNames{
+					"frontingProvider": {"spec1"},
+				},
+			},
+			expectedTransformName: "spec1",
+			expectedTransformSpec: [][2]string{{"A", "B"}},
+		},
+		{
+			name:               "no transform, coinflip false",
+			frontingProviderID: "frontingProvider",
+			isFronted:          false,
+			paramValues: map[string]interface{}{
+				"DirectHTTPProtocolTransformProbability": 0,
+				"DirectHTTPProtocolTransformSpecs": transforms.Specs{
+					"spec1": {{"A", "B"}},
+				},
+				"DirectHTTPProtocolTransformScopedSpecNames": transforms.ScopedSpecNames{
+					"frontingProvider": {"spec1"},
+				},
+			},
+			expectedTransformName: "",
+			expectedTransformSpec: nil,
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+
+			params, err := parameters.NewParameters(nil)
+			if err != nil {
+				t.Fatalf("parameters.NewParameters failed %v", err)
+			}
+
+			_, err = params.Set("", false, tt.paramValues)
+			if err != nil {
+				t.Fatalf("params.Set failed %v", err)
+			}
+
+			httpTransformerParams, err := makeHTTPTransformerParameters(params.Get(), tt.frontingProviderID, tt.isFronted)
+			if err != nil {
+				t.Fatalf("MakeHTTPTransformerParameters failed %v", err)
+			}
+			if httpTransformerParams.ProtocolTransformName != tt.expectedTransformName {
+				t.Fatalf("expected ProtocolTransformName \"%s\" but got \"%s\"", tt.expectedTransformName, httpTransformerParams.ProtocolTransformName)
+			}
+			if !reflect.DeepEqual(httpTransformerParams.ProtocolTransformSpec, tt.expectedTransformSpec) {
+				t.Fatalf("expected ProtocolTransformSpec %v but got %v", tt.expectedTransformSpec, httpTransformerParams.ProtocolTransformSpec)
+			}
+			if httpTransformerParams.ProtocolTransformSpec != nil {
+				if httpTransformerParams.ProtocolTransformSeed == nil {
+					t.Fatalf("expected non-nil seed")
+				}
+			}
+		})
+	}
+}

+ 14 - 0
psiphon/meekConn.go

@@ -44,6 +44,7 @@ import (
 	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common/prng"
 	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common/prng"
 	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common/protocol"
 	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common/protocol"
 	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common/quic"
 	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common/quic"
+	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common/transforms"
 	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common/values"
 	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common/values"
 	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/upstreamproxy"
 	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/upstreamproxy"
 	"golang.org/x/crypto/nacl/box"
 	"golang.org/x/crypto/nacl/box"
@@ -192,6 +193,10 @@ type MeekConfig struct {
 	MeekCookieEncryptionPublicKey string
 	MeekCookieEncryptionPublicKey string
 	MeekObfuscatedKey             string
 	MeekObfuscatedKey             string
 	MeekObfuscatorPaddingSeed     *prng.Seed
 	MeekObfuscatorPaddingSeed     *prng.Seed
+
+	// HTTPTransformerParameters specifies an HTTP transformer to apply to the
+	// meek connection if it uses HTTP.
+	HTTPTransformerParameters *transforms.HTTPTransformerParameters
 }
 }
 
 
 // MeekConn is a network connection that tunnels net.Conn flows over HTTP and supports
 // MeekConn is a network connection that tunnels net.Conn flows over HTTP and supports
@@ -589,6 +594,15 @@ func DialMeek(
 			}
 			}
 		}
 		}
 
 
+		if protocol.TunnelProtocolUsesMeekHTTP(meekConfig.ClientTunnelProtocol) {
+			// Only apply transformer if it will perform a transform; otherwise
+			// applying a no-op transform will incur an unnecessary performance
+			// cost.
+			if meekConfig.HTTPTransformerParameters != nil && meekConfig.HTTPTransformerParameters.ProtocolTransformSpec != nil {
+				dialer = transforms.WrapDialerWithHTTPTransformer(dialer, meekConfig.HTTPTransformerParameters)
+			}
+		}
+
 		httpTransport := &http.Transport{
 		httpTransport := &http.Transport{
 			Proxy:       proxyUrl,
 			Proxy:       proxyUrl,
 			DialContext: dialer,
 			DialContext: dialer,

+ 6 - 0
psiphon/notice.go

@@ -579,6 +579,12 @@ func noticeWithDialParameters(noticeType string, dialParams *DialParameters, pos
 			}
 			}
 		}
 		}
 
 
+		if dialParams.HTTPTransformerParameters != nil {
+			if dialParams.HTTPTransformerParameters.ProtocolTransformSpec != nil {
+				args = append(args, "HTTPTransform", dialParams.HTTPTransformerParameters.ProtocolTransformName)
+			}
+		}
+
 		if dialParams.DialConnMetrics != nil {
 		if dialParams.DialConnMetrics != nil {
 			metrics := dialParams.DialConnMetrics.GetMetrics()
 			metrics := dialParams.DialConnMetrics.GetMetrics()
 			for name, value := range metrics {
 			for name, value := range metrics {

+ 1 - 0
psiphon/server/api.go

@@ -934,6 +934,7 @@ var baseDialParams = []requestParamSpec{
 	{"dns_preferred", isAnyString, requestParamOptional},
 	{"dns_preferred", isAnyString, requestParamOptional},
 	{"dns_transform", isAnyString, requestParamOptional},
 	{"dns_transform", isAnyString, requestParamOptional},
 	{"dns_attempt", isIntString, requestParamOptional | requestParamLogStringAsInt},
 	{"dns_attempt", isIntString, requestParamOptional | requestParamLogStringAsInt},
+	{"http_transform", isAnyString, requestParamOptional},
 }
 }
 
 
 // baseSessionAndDialParams adds baseDialParams to baseSessionParams.
 // baseSessionAndDialParams adds baseDialParams to baseSessionParams.

+ 6 - 0
psiphon/serverApi.go

@@ -1104,6 +1104,12 @@ func getBaseAPIParameters(
 					params["dns_transform"] = dialParams.ResolveParameters.ProtocolTransformName
 					params["dns_transform"] = dialParams.ResolveParameters.ProtocolTransformName
 				}
 				}
 
 
+				if dialParams.HTTPTransformerParameters != nil {
+					if dialParams.HTTPTransformerParameters.ProtocolTransformSpec != nil {
+						params["http_transform"] = dialParams.HTTPTransformerParameters.ProtocolTransformName
+					}
+				}
+
 				params["dns_attempt"] = strconv.Itoa(
 				params["dns_attempt"] = strconv.Itoa(
 					dialParams.ResolveParameters.GetFirstAttemptWithAnswer())
 					dialParams.ResolveParameters.GetFirstAttemptWithAnswer())
 			}
 			}