2021-02-02 12:05:47 +01:00
|
|
|
// Package hhfm contains the HTTP Header Field Manipulation network experiment.
|
|
|
|
//
|
|
|
|
// See https://github.com/ooni/spec/blob/master/nettests/ts-006-header-field-manipulation.md
|
|
|
|
package hhfm
|
|
|
|
|
|
|
|
import (
|
|
|
|
"bytes"
|
|
|
|
"context"
|
|
|
|
"encoding/json"
|
|
|
|
"errors"
|
|
|
|
"fmt"
|
|
|
|
"net"
|
|
|
|
"net/http"
|
|
|
|
"sort"
|
|
|
|
"time"
|
|
|
|
|
|
|
|
"github.com/ooni/probe-cli/v3/internal/engine/experiment/urlgetter"
|
2022-01-03 13:53:23 +01:00
|
|
|
"github.com/ooni/probe-cli/v3/internal/model"
|
2021-09-28 12:42:01 +02:00
|
|
|
"github.com/ooni/probe-cli/v3/internal/netxlite"
|
2021-06-04 15:15:41 +02:00
|
|
|
"github.com/ooni/probe-cli/v3/internal/randx"
|
2022-06-02 00:50:55 +02:00
|
|
|
"github.com/ooni/probe-cli/v3/internal/tracex"
|
2021-02-02 12:05:47 +01:00
|
|
|
)
|
|
|
|
|
|
|
|
const (
|
|
|
|
testName = "http_header_field_manipulation"
|
|
|
|
testVersion = "0.2.0"
|
|
|
|
)
|
|
|
|
|
|
|
|
// Config contains the experiment config.
|
|
|
|
type Config struct{}
|
|
|
|
|
|
|
|
// TestKeys contains the experiment test keys.
|
|
|
|
//
|
|
|
|
// Here we are emitting for the same set of test keys that are
|
|
|
|
// produced by the MK implementation.
|
|
|
|
type TestKeys struct {
|
2022-05-31 21:53:01 +02:00
|
|
|
Agent string `json:"agent"`
|
|
|
|
Failure *string `json:"failure"`
|
|
|
|
Requests []tracex.RequestEntry `json:"requests"`
|
|
|
|
SOCKSProxy *string `json:"socksproxy"`
|
|
|
|
Tampering Tampering `json:"tampering"`
|
2021-02-02 12:05:47 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
// Tampering describes the detected forms of tampering.
|
|
|
|
//
|
|
|
|
// The meaning of these fields is described in the specification.
|
|
|
|
type Tampering struct {
|
|
|
|
HeaderFieldName bool `json:"header_field_name"`
|
|
|
|
HeaderFieldNumber bool `json:"header_field_number"`
|
|
|
|
HeaderFieldValue bool `json:"header_field_value"`
|
|
|
|
HeaderNameCapitalization bool `json:"header_name_capitalization"`
|
|
|
|
HeaderNameDiff []string `json:"header_name_diff"`
|
|
|
|
RequestLineCapitalization bool `json:"request_line_capitalization"`
|
|
|
|
Total bool `json:"total"`
|
|
|
|
}
|
|
|
|
|
|
|
|
// NewExperimentMeasurer creates a new ExperimentMeasurer.
|
|
|
|
func NewExperimentMeasurer(config Config) model.ExperimentMeasurer {
|
|
|
|
return Measurer{Config: config}
|
|
|
|
}
|
|
|
|
|
|
|
|
// Transport is the definition of http.RoundTripper used by this package.
|
|
|
|
type Transport interface {
|
|
|
|
RoundTrip(req *http.Request) (*http.Response, error)
|
|
|
|
CloseIdleConnections()
|
|
|
|
}
|
|
|
|
|
|
|
|
// Measurer performs the measurement.
|
|
|
|
type Measurer struct {
|
|
|
|
Config Config
|
|
|
|
Transport Transport // for testing
|
|
|
|
}
|
|
|
|
|
|
|
|
// ExperimentName implements ExperimentMeasurer.ExperiExperimentName.
|
|
|
|
func (m Measurer) ExperimentName() string {
|
|
|
|
return testName
|
|
|
|
}
|
|
|
|
|
|
|
|
// ExperimentVersion implements ExperimentMeasurer.ExperimentVersion.
|
|
|
|
func (m Measurer) ExperimentVersion() string {
|
|
|
|
return testVersion
|
|
|
|
}
|
|
|
|
|
|
|
|
var (
|
|
|
|
// ErrNoAvailableTestHelpers is emitted when there are no available test helpers.
|
|
|
|
ErrNoAvailableTestHelpers = errors.New("no available helpers")
|
|
|
|
|
|
|
|
// ErrInvalidHelperType is emitted when the helper type is invalid.
|
|
|
|
ErrInvalidHelperType = errors.New("invalid helper type")
|
|
|
|
)
|
|
|
|
|
|
|
|
// Run implements ExperimentMeasurer.Run.
|
|
|
|
func (m Measurer) Run(
|
|
|
|
ctx context.Context, sess model.ExperimentSession,
|
|
|
|
measurement *model.Measurement, callbacks model.ExperimentCallbacks,
|
|
|
|
) error {
|
|
|
|
ctx, cancel := context.WithTimeout(ctx, 30*time.Second)
|
|
|
|
defer cancel()
|
|
|
|
urlgetter.RegisterExtensions(measurement)
|
|
|
|
tk := new(TestKeys)
|
|
|
|
tk.Agent = "agent"
|
|
|
|
tk.Tampering.HeaderNameDiff = []string{}
|
|
|
|
measurement.TestKeys = tk
|
|
|
|
// parse helper
|
|
|
|
const helperName = "http-return-json-headers"
|
|
|
|
helpers, ok := sess.GetTestHelpersByName(helperName)
|
|
|
|
if !ok || len(helpers) < 1 {
|
|
|
|
return ErrNoAvailableTestHelpers
|
|
|
|
}
|
|
|
|
helper := helpers[0]
|
|
|
|
if helper.Type != "legacy" {
|
|
|
|
return ErrInvalidHelperType
|
|
|
|
}
|
|
|
|
measurement.TestHelpers = map[string]interface{}{
|
|
|
|
"backend": helper.Address,
|
|
|
|
}
|
|
|
|
// prepare request
|
|
|
|
req, err := http.NewRequest("GeT", helper.Address, nil)
|
|
|
|
if err != nil {
|
|
|
|
return err
|
|
|
|
}
|
|
|
|
headers := map[string]string{
|
2022-05-25 09:54:50 +02:00
|
|
|
randx.ChangeCapitalization("Accept"): model.HTTPHeaderAccept,
|
2021-02-02 12:05:47 +01:00
|
|
|
randx.ChangeCapitalization("Accept-Charset"): "ISO-8859-1,utf-8;q=0.7,*;q=0.3",
|
|
|
|
randx.ChangeCapitalization("Accept-Encoding"): "gzip,deflate,sdch",
|
2022-05-25 09:54:50 +02:00
|
|
|
randx.ChangeCapitalization("Accept-Language"): model.HTTPHeaderAcceptLanguage,
|
2021-02-02 12:05:47 +01:00
|
|
|
randx.ChangeCapitalization("Host"): randx.Letters(15) + ".com",
|
2022-05-25 09:54:50 +02:00
|
|
|
randx.ChangeCapitalization("User-Agent"): model.HTTPHeaderUserAgent,
|
2021-02-02 12:05:47 +01:00
|
|
|
}
|
|
|
|
for key, value := range headers {
|
|
|
|
// Implementation note: Golang will normalize the header names. We will use
|
|
|
|
// a custom dialer to restore the random capitalisation.
|
|
|
|
req.Header.Set(key, value)
|
|
|
|
}
|
|
|
|
req.Host = req.Header.Get("Host")
|
|
|
|
// fill tk.Requests[0]
|
|
|
|
tk.Requests = NewRequestEntryList(req, headers)
|
|
|
|
// prepare transport
|
|
|
|
txp := m.Transport
|
|
|
|
if txp == nil {
|
|
|
|
ht := http.DefaultTransport.(*http.Transport).Clone() // basically: use defaults
|
|
|
|
ht.DisableCompression = true // disable sending Accept: gzip
|
|
|
|
ht.ForceAttemptHTTP2 = false
|
|
|
|
ht.DialContext = Dialer{Headers: headers}.DialContext
|
|
|
|
txp = ht
|
|
|
|
}
|
|
|
|
defer txp.CloseIdleConnections()
|
|
|
|
// round trip and read body
|
|
|
|
// TODO(bassosimone): this implementation will lead to false positives if the
|
|
|
|
// network is really bad. Yet, this seems what MK does, so I'd rather start
|
|
|
|
// from that and then see to improve the robustness in the future.
|
|
|
|
resp, data, err := Transact(txp, req.WithContext(ctx), callbacks)
|
|
|
|
if err != nil {
|
2022-05-31 21:53:01 +02:00
|
|
|
tk.Failure = tracex.NewFailure(err)
|
2021-02-02 12:05:47 +01:00
|
|
|
tk.Requests[0].Failure = tk.Failure
|
|
|
|
tk.Tampering.Total = true
|
|
|
|
return nil // measurement did not fail, we measured tampering
|
|
|
|
}
|
|
|
|
// fill tk.Requests[0].Response
|
|
|
|
tk.Requests[0].Response = NewHTTPResponse(resp, data)
|
|
|
|
// parse response body
|
|
|
|
var jsonHeaders JSONHeaders
|
|
|
|
if err := json.Unmarshal(data, &jsonHeaders); err != nil {
|
2021-09-28 12:42:01 +02:00
|
|
|
failure := netxlite.FailureJSONParseError
|
2021-02-02 12:05:47 +01:00
|
|
|
tk.Failure = &failure
|
|
|
|
tk.Tampering.Total = true
|
|
|
|
return nil // measurement did not fail, we measured tampering
|
|
|
|
}
|
|
|
|
// fill tampering
|
|
|
|
tk.FillTampering(req, jsonHeaders, headers)
|
|
|
|
return nil
|
|
|
|
}
|
|
|
|
|
|
|
|
// Transact performs the HTTP transaction which consists of performing
|
|
|
|
// the HTTP round trip and then reading the body.
|
|
|
|
func Transact(txp Transport, req *http.Request,
|
|
|
|
callbacks model.ExperimentCallbacks) (*http.Response, []byte, error) {
|
|
|
|
// make sure that we return a wrapped error here
|
|
|
|
resp, data, err := transact(txp, req, callbacks)
|
2022-01-07 17:31:21 +01:00
|
|
|
if err != nil {
|
|
|
|
err = netxlite.NewTopLevelGenericErrWrapper(err)
|
|
|
|
}
|
2021-02-02 12:05:47 +01:00
|
|
|
return resp, data, err
|
|
|
|
}
|
|
|
|
|
|
|
|
func transact(txp Transport, req *http.Request,
|
|
|
|
callbacks model.ExperimentCallbacks) (*http.Response, []byte, error) {
|
|
|
|
callbacks.OnProgress(0.25, "sending request...")
|
|
|
|
resp, err := txp.RoundTrip(req)
|
|
|
|
callbacks.OnProgress(0.50, fmt.Sprintf("got reseponse headers... %+v", err))
|
|
|
|
if err != nil {
|
|
|
|
return nil, nil, err
|
|
|
|
}
|
|
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode != 200 {
|
|
|
|
return nil, nil, urlgetter.ErrHTTPRequestFailed
|
|
|
|
}
|
|
|
|
callbacks.OnProgress(0.75, "reading response body...")
|
2021-09-28 12:42:01 +02:00
|
|
|
data, err := netxlite.ReadAllContext(req.Context(), resp.Body)
|
2021-02-02 12:05:47 +01:00
|
|
|
callbacks.OnProgress(1.00, fmt.Sprintf("got reseponse body... %+v", err))
|
|
|
|
if err != nil {
|
|
|
|
return nil, nil, err
|
|
|
|
}
|
|
|
|
return resp, data, nil
|
|
|
|
}
|
|
|
|
|
|
|
|
// FillTampering fills the tampering structure in the TestKeys
|
|
|
|
// based on the value of other fields of the TestKeys, the original
|
|
|
|
// HTTP request, the response from the test helper, and the
|
|
|
|
// headers with modified capitalisation.
|
|
|
|
func (tk *TestKeys) FillTampering(
|
|
|
|
req *http.Request, jsonHeaders JSONHeaders, headers map[string]string) {
|
|
|
|
tk.Tampering.RequestLineCapitalization = (fmt.Sprintf(
|
|
|
|
"%s / HTTP/1.1", req.Method) != jsonHeaders.RequestLine)
|
|
|
|
tk.Tampering.HeaderFieldNumber = len(headers) != len(jsonHeaders.HeadersDict)
|
|
|
|
expectedHeaderKeys := make(map[string]string)
|
|
|
|
for key := range headers {
|
|
|
|
expectedHeaderKeys[http.CanonicalHeaderKey(key)] = key
|
|
|
|
}
|
|
|
|
receivedHeaderKeys := make(map[string]string)
|
|
|
|
for key := range jsonHeaders.HeadersDict {
|
|
|
|
receivedHeaderKeys[http.CanonicalHeaderKey(key)] = key
|
|
|
|
}
|
|
|
|
commonHeaderKeys := make(map[string]int)
|
|
|
|
for key := range expectedHeaderKeys {
|
|
|
|
commonHeaderKeys[key]++
|
|
|
|
}
|
|
|
|
for key := range receivedHeaderKeys {
|
|
|
|
commonHeaderKeys[key]++
|
|
|
|
}
|
|
|
|
for key, count := range commonHeaderKeys {
|
|
|
|
if count != 2 {
|
|
|
|
continue // not in common
|
|
|
|
}
|
|
|
|
expectedKey, receivedKey := expectedHeaderKeys[key], receivedHeaderKeys[key]
|
|
|
|
if expectedKey != receivedKey {
|
|
|
|
tk.Tampering.HeaderNameCapitalization = true
|
|
|
|
tk.Tampering.HeaderNameDiff = append(tk.Tampering.HeaderNameDiff, expectedKey)
|
|
|
|
tk.Tampering.HeaderNameDiff = append(tk.Tampering.HeaderNameDiff, receivedKey)
|
|
|
|
}
|
|
|
|
expectedValue := headers[expectedKey]
|
|
|
|
receivedValue := jsonHeaders.HeadersDict[receivedKey]
|
|
|
|
if len(receivedValue) != 1 || expectedValue != receivedValue[0] {
|
|
|
|
tk.Tampering.HeaderFieldValue = true
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2022-05-31 21:53:01 +02:00
|
|
|
// NewRequestEntryList creates a new []tracex.RequestEntry given a
|
2021-02-02 12:05:47 +01:00
|
|
|
// specific *http.Request and headers with random case.
|
2022-05-31 21:53:01 +02:00
|
|
|
func NewRequestEntryList(req *http.Request, headers map[string]string) (out []tracex.RequestEntry) {
|
|
|
|
out = []tracex.RequestEntry{{
|
|
|
|
Request: tracex.HTTPRequest{
|
|
|
|
Headers: make(map[string]tracex.MaybeBinaryValue),
|
|
|
|
HeadersList: []tracex.HTTPHeader{},
|
2021-02-02 12:05:47 +01:00
|
|
|
Method: req.Method,
|
|
|
|
URL: req.URL.String(),
|
|
|
|
},
|
|
|
|
}}
|
|
|
|
for key, value := range headers {
|
|
|
|
// Using the random capitalization headers here
|
2022-05-31 21:53:01 +02:00
|
|
|
mbv := tracex.MaybeBinaryValue{Value: value}
|
2021-02-02 12:05:47 +01:00
|
|
|
out[0].Request.Headers[key] = mbv
|
|
|
|
out[0].Request.HeadersList = append(out[0].Request.HeadersList,
|
2022-05-31 21:53:01 +02:00
|
|
|
tracex.HTTPHeader{Key: key, Value: mbv})
|
2021-02-02 12:05:47 +01:00
|
|
|
}
|
|
|
|
sort.Slice(out[0].Request.HeadersList, func(i, j int) bool {
|
|
|
|
return out[0].Request.HeadersList[i].Key < out[0].Request.HeadersList[j].Key
|
|
|
|
})
|
|
|
|
return
|
|
|
|
}
|
|
|
|
|
2022-05-31 21:53:01 +02:00
|
|
|
// NewHTTPResponse creates a new tracex.HTTPResponse given a
|
2021-02-02 12:05:47 +01:00
|
|
|
// specific *http.Response instance and its body.
|
2022-05-31 21:53:01 +02:00
|
|
|
func NewHTTPResponse(resp *http.Response, data []byte) (out tracex.HTTPResponse) {
|
|
|
|
out = tracex.HTTPResponse{
|
|
|
|
Body: tracex.HTTPBody{Value: string(data)},
|
2021-02-02 12:05:47 +01:00
|
|
|
Code: int64(resp.StatusCode),
|
2022-05-31 21:53:01 +02:00
|
|
|
Headers: make(map[string]tracex.MaybeBinaryValue),
|
|
|
|
HeadersList: []tracex.HTTPHeader{},
|
2021-02-02 12:05:47 +01:00
|
|
|
}
|
|
|
|
for key := range resp.Header {
|
2022-05-31 21:53:01 +02:00
|
|
|
mbv := tracex.MaybeBinaryValue{Value: resp.Header.Get(key)}
|
2021-02-02 12:05:47 +01:00
|
|
|
out.Headers[key] = mbv
|
2022-05-31 21:53:01 +02:00
|
|
|
out.HeadersList = append(out.HeadersList, tracex.HTTPHeader{Key: key, Value: mbv})
|
2021-02-02 12:05:47 +01:00
|
|
|
}
|
|
|
|
sort.Slice(out.HeadersList, func(i, j int) bool {
|
|
|
|
return out.HeadersList[i].Key < out.HeadersList[j].Key
|
|
|
|
})
|
|
|
|
return
|
|
|
|
}
|
|
|
|
|
|
|
|
// JSONHeaders contains the response from the backend server.
|
|
|
|
//
|
|
|
|
// Here we're defining only the fields we care about.
|
|
|
|
type JSONHeaders struct {
|
|
|
|
HeadersDict map[string][]string `json:"headers_dict"`
|
|
|
|
RequestLine string `json:"request_line"`
|
|
|
|
}
|
|
|
|
|
|
|
|
// Dialer is a dialer that performs headers transformations.
|
|
|
|
//
|
|
|
|
// Because Golang will canonicalize header names, we need to reintroduce
|
|
|
|
// the random capitalization when emitting the request.
|
|
|
|
//
|
|
|
|
// This implementation rests on the assumption that we shall use the
|
|
|
|
// same connection just once, which is guarantee by the implementation
|
|
|
|
// of HHFM above. If using this code elsewhere, make sure that you
|
|
|
|
// guarantee that the connection is used for a single request and that
|
|
|
|
// such a request does not contain any body.
|
|
|
|
type Dialer struct {
|
2022-01-07 18:33:37 +01:00
|
|
|
Dialer model.SimpleDialer // used for testing
|
2021-02-02 12:05:47 +01:00
|
|
|
Headers map[string]string
|
|
|
|
}
|
|
|
|
|
|
|
|
// DialContext dials a specific connection and arranges such that
|
|
|
|
// headers in the outgoing request are transformed.
|
|
|
|
func (d Dialer) DialContext(ctx context.Context, network, address string) (net.Conn, error) {
|
2021-06-08 19:40:17 +02:00
|
|
|
child := d.Dialer
|
|
|
|
if child == nil {
|
2021-06-09 09:42:31 +02:00
|
|
|
// TODO(bassosimone): figure out why using dialer.New here
|
|
|
|
// causes the experiment to fail with eof_error
|
|
|
|
child = &net.Dialer{Timeout: 15 * time.Second}
|
2021-02-02 12:05:47 +01:00
|
|
|
}
|
2021-06-08 19:40:17 +02:00
|
|
|
conn, err := child.DialContext(ctx, network, address)
|
2021-02-02 12:05:47 +01:00
|
|
|
if err != nil {
|
|
|
|
return nil, err
|
|
|
|
}
|
|
|
|
return Conn{Conn: conn, Headers: d.Headers}, nil
|
|
|
|
}
|
|
|
|
|
|
|
|
// Conn is a connection where headers in the outgoing request
|
|
|
|
// are transformed according to a transform table.
|
|
|
|
type Conn struct {
|
|
|
|
net.Conn
|
|
|
|
Headers map[string]string
|
|
|
|
}
|
|
|
|
|
|
|
|
// Write implements Conn.Write.
|
|
|
|
func (c Conn) Write(b []byte) (int, error) {
|
|
|
|
for key := range c.Headers {
|
|
|
|
b = bytes.Replace(b, []byte(http.CanonicalHeaderKey(key)+":"), []byte(key+":"), 1)
|
|
|
|
}
|
|
|
|
return c.Conn.Write(b)
|
|
|
|
}
|
|
|
|
|
|
|
|
// SummaryKeys contains summary keys for this experiment.
|
|
|
|
//
|
2022-05-09 09:33:18 +02:00
|
|
|
// Note that this structure is part of the ABI contract with ooniprobe
|
2021-02-02 12:05:47 +01:00
|
|
|
// therefore we should be careful when changing it.
|
|
|
|
type SummaryKeys struct {
|
|
|
|
IsAnomaly bool `json:"-"`
|
|
|
|
}
|
|
|
|
|
|
|
|
// GetSummaryKeys implements model.ExperimentMeasurer.GetSummaryKeys.
|
|
|
|
func (m Measurer) GetSummaryKeys(measurement *model.Measurement) (interface{}, error) {
|
|
|
|
sk := SummaryKeys{IsAnomaly: false}
|
|
|
|
tk, ok := measurement.TestKeys.(*TestKeys)
|
|
|
|
if !ok {
|
|
|
|
return sk, errors.New("invalid test keys type")
|
|
|
|
}
|
|
|
|
sk.IsAnomaly = (tk.Tampering.HeaderFieldName ||
|
|
|
|
tk.Tampering.HeaderFieldNumber ||
|
|
|
|
tk.Tampering.HeaderFieldValue ||
|
|
|
|
tk.Tampering.HeaderNameCapitalization ||
|
|
|
|
tk.Tampering.RequestLineCapitalization ||
|
|
|
|
tk.Tampering.Total)
|
|
|
|
return sk, nil
|
|
|
|
}
|