├── .codecov.yml ├── .dockerignore ├── .github └── workflows │ ├── ci.yaml │ └── release.yaml ├── .gitignore ├── .golangci.yaml ├── DEVELOPMENT.md ├── Dockerfile ├── LICENSE ├── Makefile ├── README.md ├── SECURITY.md ├── cmd └── go-httpbin │ └── main.go ├── examples ├── build-all └── custom-instrumentation │ ├── README.md │ ├── go.mod │ ├── go.sum │ └── main.go ├── go.mod ├── httpbin ├── cmd │ ├── cmd.go │ └── cmd_test.go ├── digest │ ├── digest.go │ └── digest_test.go ├── doc.go ├── example_test.go ├── handlers.go ├── handlers_test.go ├── helpers.go ├── helpers_test.go ├── httpbin.go ├── httpbin_test.go ├── middleware.go ├── middleware_test.go ├── options.go ├── responses.go ├── static │ ├── forms-post.html.tmpl │ ├── image.jpeg │ ├── image.png │ ├── image.svg │ ├── image.webp │ ├── index.html.tmpl │ ├── moby.html │ ├── sample.json │ ├── sample.xml │ └── utf8.html ├── static_assets.go ├── static_assets_test.go └── websocket │ ├── websocket.go │ ├── websocket_autobahn_test.go │ └── websocket_test.go ├── internal └── testing │ ├── assert │ └── assert.go │ └── must │ └── must.go └── kustomize ├── README.md ├── kustomization.yaml └── resources.yaml /.codecov.yml: -------------------------------------------------------------------------------- 1 | # To validate this config: 2 | # 3 | # cat .codecov.yml | curl --data-binary @- https://codecov.io/validate 4 | # 5 | # See https://docs.codecov.io/docs for more info 6 | 7 | # https://docs.codecov.io/docs/coverage-configuration 8 | coverage: 9 | precision: 2 10 | round: down 11 | range: "90..100" 12 | 13 | # https://docs.codecov.io/docs/commit-status 14 | status: 15 | project: 16 | default: 17 | target: auto 18 | base: auto 19 | threshold: 2% 20 | 21 | parsers: 22 | gcov: 23 | branch_detection: 24 | conditional: yes 25 | loop: yes 26 | method: no 27 | macro: no 28 | 29 | # https://docs.codecov.io/docs/pull-request-comments 30 | comment: 31 | layout: "header, diff, files" 32 | behavior: default 33 | require_changes: no 34 | 35 | ignore: 36 | - internal/testing 37 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | .* 2 | dist 3 | examples 4 | -------------------------------------------------------------------------------- /.github/workflows/ci.yaml: -------------------------------------------------------------------------------- 1 | name: ci 2 | 3 | on: 4 | push: 5 | branches: [main] # pushes TO main 6 | pull_request: 7 | branches: [main] # pull requests AGAINST main 8 | 9 | # cancel CI runs when a new commit is pushed to any branch except main 10 | concurrency: 11 | group: "test-${{ github.ref }}" 12 | cancel-in-progress: ${{ github.ref != 'refs/heads/main' }} 13 | 14 | jobs: 15 | test: 16 | runs-on: ubuntu-latest 17 | 18 | strategy: 19 | matrix: 20 | # build against the two latest releases, to match golang's release 21 | # policy: https://go.dev/doc/devel/release#policy 22 | go-version: 23 | - 'stable' 24 | - 'oldstable' 25 | 26 | steps: 27 | - uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 # v5.5.0 28 | with: 29 | go-version: ${{matrix.go-version}} 30 | 31 | - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 32 | 33 | - name: test 34 | run: make testci 35 | 36 | - uses: codecov/codecov-action@18283e04ce6e62d37312384ff67231eb8fd56d24 # v5.4.3 37 | if: ${{ matrix.go-version == 'stable' }} 38 | with: 39 | token: ${{ secrets.CODECOV_TOKEN }} 40 | fail_ci_if_error: true 41 | 42 | lint: 43 | runs-on: ubuntu-latest 44 | steps: 45 | - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 46 | 47 | - uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 # v5.5.0 48 | with: 49 | go-version: 'stable' 50 | 51 | - uses: golangci/golangci-lint-action@4afd733a84b1f43292c63897423277bb7f4313a9 # v8.0.0 52 | with: 53 | version: latest 54 | -------------------------------------------------------------------------------- /.github/workflows/release.yaml: -------------------------------------------------------------------------------- 1 | # This action pushes new amd64 and arm64 docker images to the Docker and GitHub 2 | # registries on every new release of the project. 3 | # 4 | # Cobbled together from these sources: 5 | # - https://github.com/docker/build-push-action/#usage 6 | # - https://docs.github.com/en/actions/publishing-packages/publishing-docker-images 7 | 8 | name: release 9 | 10 | "on": 11 | release: 12 | types: [published] 13 | 14 | # we do not build and push images for every commit, only for tagged releases. 15 | # uncomment this to enablle building for pull requests, to debug this 16 | # workflow. 17 | # 18 | # pull_request: 19 | # branches: [main] 20 | 21 | jobs: 22 | docker: 23 | runs-on: ubuntu-latest 24 | 25 | permissions: 26 | contents: read 27 | packages: write 28 | 29 | steps: 30 | - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 31 | - uses: docker/setup-qemu-action@29109295f81e9208d7d86ff1c6c12d2833863392 # v3.6.0 32 | - uses: docker/setup-buildx-action@b5ca514318bd6ebac0fb2aedd5d36ec1b5c232a2 # v3.10.0 33 | 34 | - uses: docker/login-action@74a5d142397b4f367a81961eba4e8cd7edddf772 # v3.4.0 35 | with: 36 | username: ${{ secrets.DOCKERHUB_USERNAME }} 37 | password: ${{ secrets.DOCKERHUB_TOKEN }} 38 | 39 | - uses: docker/login-action@74a5d142397b4f367a81961eba4e8cd7edddf772 # v3.4.0 40 | with: 41 | registry: ghcr.io 42 | username: ${{ github.repository_owner }} 43 | password: ${{ secrets.GITHUB_TOKEN }} 44 | 45 | - uses: docker/metadata-action@902fa8ec7d6ecbf8d84d538b9b233a880e428804 # v5.7.0 46 | with: 47 | images: | 48 | mccutchen/go-httpbin 49 | ghcr.io/${{ github.repository }} 50 | tags: | 51 | # For releases, use the standard tags and special "latest" tag 52 | type=semver,pattern={{version}},enable=${{ github.event_name == 'release' }} 53 | type=semver,pattern={{major}}.{{minor}},enable=${{ github.event_name == 'release' }} 54 | type=raw,value=latest,enable=${{ github.event_name == 'release' }} 55 | 56 | # For pull requests, use the commit SHA 57 | # 58 | # Note that this is disabled by default, but can be enabled for 59 | # debugging purposes by uncommenting the pull_request trigger at 60 | # top of the workflow. 61 | type=sha,format=short,enable=${{ github.event_name == 'pull_request' }} 62 | id: meta 63 | 64 | - uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0 65 | with: 66 | platforms: linux/amd64,linux/arm64 67 | push: true 68 | sbom: true 69 | provenance: mode=max 70 | tags: ${{ steps.meta.outputs.tags }} 71 | labels: ${{ steps.meta.outputs.labels }} 72 | annotations: ${{ steps.meta.outputs.annotations }} 73 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .* 2 | *.exe 3 | *.test 4 | *.prof 5 | dist/* 6 | coverage.txt 7 | /cmd/go-httpbin/go-httpbin 8 | -------------------------------------------------------------------------------- /.golangci.yaml: -------------------------------------------------------------------------------- 1 | version: "2" 2 | run: 3 | modules-download-mode: readonly 4 | 5 | linters: 6 | disable: 7 | - errcheck 8 | -------------------------------------------------------------------------------- /DEVELOPMENT.md: -------------------------------------------------------------------------------- 1 | # Development 2 | 3 | ## Local development 4 | 5 | For interactive local development, use `make run` to build and run go-httpbin 6 | or `make watch` to automatically re-build and re-run go-httpbin on every 7 | change: 8 | 9 | make run 10 | make watch 11 | 12 | By default, the server will listen on `http://127.0.0.1:8080`, but the host, 13 | port, or any other [configuration option][config] may be overridden by 14 | specifying the relevant environment variables: 15 | 16 | make run PORT=9999 17 | make run PORT=9999 MAX_DURATION=60s 18 | make watch HOST=0.0.0.0 PORT=8888 19 | 20 | ## Testing 21 | 22 | Run `make test` to run unit tests, using `TEST_ARGS` to pass arguments through 23 | to `go test`: 24 | 25 | make test 26 | make test TEST_ARGS="-v -race -run ^TestDelay" 27 | 28 | ### Integration tests 29 | 30 | go-httpbin includes its own minimal WebSocket echo server implementation, and 31 | we use the incredibly helpful [Autobahn Testsuite][] to ensure that the 32 | implementation conforms to the spec. 33 | 34 | These tests can be slow to run (~40 seconds on my machine), so they are not run 35 | by default when using `make test`. 36 | 37 | They are run automatically as part of our extended "CI" test suite, which is 38 | run on every pull request: 39 | 40 | make testci 41 | 42 | ### WebSocket development 43 | 44 | When working on the WebSocket implementation, it can also be useful to run 45 | those integration tests directly, like so: 46 | 47 | make testautobahn 48 | 49 | Use the `AUTOBAHN_CASES` var to run a specific subset of the Autobahn tests, 50 | which may or may not include wildcards: 51 | 52 | make testautobahn AUTOBAHN_CASES=6.* 53 | make testautobahn AUTOBAHN_CASES=6.5.* 54 | make testautobahn AUTOBAHN_CASES=6.5.4 55 | 56 | 57 | ### Test coverage 58 | 59 | We use [Codecov][] to measure and track test coverage as part of our continuous 60 | integration test suite. While we strive for as much coverage as possible and 61 | the Codecov CI check is configured with fairly strict requirements, 100% test 62 | coverage is not an explicit goal or requirement for all contributions. 63 | 64 | To view test coverage locally, use 65 | 66 | make testcover 67 | 68 | which will run the full suite of unit and integration tests and pop open a web 69 | browser to view coverage results. 70 | 71 | 72 | ## Linting and code style 73 | 74 | Run `make lint` to run our suite of linters and formatters, which include 75 | gofmt, [revive][], and [staticcheck][]: 76 | 77 | make lint 78 | 79 | 80 | ## Docker images 81 | 82 | To build a docker image locally: 83 | 84 | make image 85 | 86 | To build a docker image an push it to a remote repository: 87 | 88 | make imagepush 89 | 90 | By default, images will be tagged as `mccutchen/go-httpbin:${COMMIT}` with the 91 | current HEAD commit hash. 92 | 93 | Use `VERSION` to override the tag value 94 | 95 | make imagepush VERSION=v1.2.3 96 | 97 | or `DOCKER_TAG` to override the remote repo and version at once: 98 | 99 | make imagepush DOCKER_TAG=my-org/my-fork:v1.2.3 100 | 101 | ### Automated docker image builds 102 | 103 | When a new release is created, the [Release][] GitHub Actions workflow 104 | automatically builds and pushes new Docker images for both linux/amd64 and 105 | linux/arm64 architectures. 106 | 107 | 108 | [config]: /README.md#configuration 109 | [revive]: https://github.com/mgechev/revive 110 | [staticcheck]: https://staticcheck.dev/ 111 | [Release]: /.github/workflows/release.yaml 112 | [Codecov]: https://app.codecov.io/gh/mccutchen/go-httpbin 113 | [Autobahn Testsuite]: https://github.com/crossbario/autobahn-testsuite 114 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # syntax = docker/dockerfile:1.3 2 | FROM golang:1.23 AS build 3 | 4 | WORKDIR /go/src/github.com/mccutchen/go-httpbin 5 | 6 | COPY . . 7 | 8 | RUN --mount=type=cache,id=gobuild,target=/root/.cache/go-build \ 9 | make build buildtests 10 | 11 | FROM gcr.io/distroless/base 12 | 13 | COPY --from=build /go/src/github.com/mccutchen/go-httpbin/dist/go-httpbin* /bin/ 14 | 15 | EXPOSE 8080 16 | CMD ["/bin/go-httpbin"] 17 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2016 Will McCutchen 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | # The version that will be used in docker tags (e.g. to push a 2 | # go-httpbin:latest image use `make imagepush VERSION=latest)` 3 | VERSION ?= $(shell git rev-parse --short HEAD) 4 | DOCKER_TAG ?= mccutchen/go-httpbin:$(VERSION) 5 | 6 | # Built binaries will be placed here 7 | DIST_PATH ?= dist 8 | 9 | # Default flags used by the test, testci, testcover targets 10 | COVERAGE_PATH ?= coverage.txt 11 | COVERAGE_ARGS ?= -covermode=atomic -coverprofile=$(COVERAGE_PATH) 12 | TEST_ARGS ?= -race 13 | 14 | # 3rd party tools 15 | FMT := go run mvdan.cc/gofumpt@v0.7.0 16 | LINT := go run github.com/mgechev/revive@v1.7.0 17 | REFLEX := go run github.com/cespare/reflex@v0.3.1 18 | STATICCHECK := go run honnef.co/go/tools/cmd/staticcheck@2025.1.1 19 | 20 | # Host and port to use when running locally via `make run` or `make watch` 21 | HOST ?= 127.0.0.1 22 | PORT ?= 8080 23 | 24 | 25 | # ============================================================================= 26 | # build 27 | # ============================================================================= 28 | build: 29 | mkdir -p $(DIST_PATH) 30 | CGO_ENABLED=0 go build -ldflags="-s -w" -o $(DIST_PATH)/go-httpbin ./cmd/go-httpbin 31 | .PHONY: build 32 | 33 | buildexamples: build 34 | ./examples/build-all 35 | .PHONY: buildexamples 36 | 37 | buildtests: 38 | CGO_ENABLED=0 go test -ldflags="-s -w" -v -c -o $(DIST_PATH)/go-httpbin.test ./httpbin 39 | .PHONY: buildtests 40 | 41 | clean: 42 | rm -rf $(DIST_PATH) $(COVERAGE_PATH) .integrationtests 43 | .PHONY: clean 44 | 45 | 46 | # ============================================================================= 47 | # test & lint 48 | # ============================================================================= 49 | test: 50 | go test $(TEST_ARGS) ./... 51 | .PHONY: test 52 | 53 | # Test command to run for continuous integration, which includes code coverage 54 | # based on codecov.io's documentation: 55 | # https://github.com/codecov/example-go/blob/b85638743b972bd0bd2af63421fe513c6f968930/README.md 56 | testci: build buildexamples 57 | AUTOBAHN_TESTS=1 go test $(TEST_ARGS) $(COVERAGE_ARGS) ./... 58 | .PHONY: testci 59 | 60 | testcover: testci 61 | go tool cover -html=$(COVERAGE_PATH) 62 | .PHONY: testcover 63 | 64 | # Run the autobahn fuzzingclient test suite 65 | testautobahn: 66 | AUTOBAHN_TESTS=1 AUTOBAHN_OPEN_REPORT=1 go test -v -run ^TestWebSocketServer$$ $(TEST_ARGS) ./... 67 | .PHONY: autobahntests 68 | 69 | lint: 70 | test -z "$$($(FMT) -d -e .)" || (echo "Error: $(FMT) failed"; $(FMT) -d -e . ; exit 1) 71 | go vet ./... 72 | $(LINT) -set_exit_status ./... 73 | $(STATICCHECK) ./... 74 | .PHONY: lint 75 | 76 | 77 | # ============================================================================= 78 | # run locally 79 | # ============================================================================= 80 | run: build 81 | HOST=$(HOST) PORT=$(PORT) $(DIST_PATH)/go-httpbin 82 | .PHONY: run 83 | 84 | watch: 85 | $(REFLEX) -s -r '\.(go|html|tmpl)$$' make run 86 | .PHONY: watch 87 | 88 | 89 | # ============================================================================= 90 | # docker images 91 | # ============================================================================= 92 | image: 93 | DOCKER_BUILDKIT=1 docker build -t $(DOCKER_TAG) . 94 | .PHONY: image 95 | 96 | imagepush: 97 | docker buildx create --name httpbin 98 | docker buildx use httpbin 99 | docker buildx build --push --platform linux/amd64,linux/arm64 -t $(DOCKER_TAG) . 100 | docker buildx rm httpbin 101 | .PHONY: imagepush 102 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # go-httpbin 2 | 3 | A reasonably complete and well-tested golang port of [Kenneth Reitz][kr]'s 4 | [httpbin][httpbin-org] service, with zero dependencies outside the go stdlib. 5 | 6 | [![GoDoc](https://pkg.go.dev/badge/github.com/mccutchen/go-httpbin/v2)](https://pkg.go.dev/github.com/mccutchen/go-httpbin/v2) 7 | [![Build status](https://github.com/mccutchen/go-httpbin/actions/workflows/test.yaml/badge.svg)](https://github.com/mccutchen/go-httpbin/actions/workflows/test.yaml) 8 | [![Coverage](https://codecov.io/gh/mccutchen/go-httpbin/branch/main/graph/badge.svg)](https://codecov.io/gh/mccutchen/go-httpbin) 9 | [![Docker Pulls](https://badgen.net/docker/pulls/mccutchen/go-httpbin?icon=docker&label=pulls)](https://hub.docker.com/r/mccutchen/go-httpbin/) 10 | 11 | 12 | ## Usage 13 | 14 | ### Docker/OCI images 15 | 16 | Prebuilt images for the `linux/amd64` and `linux/arm64` architectures are 17 | automatically published to these public registries for every tagged release: 18 | - GitHub Container Registry: [ghcr.io/mccutchen/go-httpbin][ghcr] 19 | - **Note:** Only version `2.17` and later 20 | - Docker Hub: [mccutchen/go-httpbin][docker-hub] 21 | 22 | ```bash 23 | # Run http server 24 | $ docker run -P ghcr.io/mccutchen/go-httpbin 25 | 26 | # Run https server 27 | $ docker run -e HTTPS_CERT_FILE='/tmp/server.crt' -e HTTPS_KEY_FILE='/tmp/server.key' -p 8080:8080 -v /tmp:/tmp ghcr.io/mccutchen/go-httpbin 28 | ``` 29 | 30 | ### Kubernetes 31 | 32 | ``` 33 | $ kubectl apply -k github.com/mccutchen/go-httpbin/kustomize 34 | ``` 35 | 36 | See `./kustomize` directory for further information 37 | 38 | ### Standalone binary 39 | 40 | Follow the [Installation](#installation) instructions to install go-httpbin as 41 | a standalone binary, or use `go run` to install it on demand: 42 | 43 | Examples: 44 | 45 | ```bash 46 | # Run http server 47 | $ go run github.com/mccutchen/go-httpbin/v2/cmd/go-httpbin@latest -host 127.0.0.1 -port 8081 48 | 49 | # Run https server 50 | $ openssl genrsa -out server.key 2048 51 | $ openssl ecparam -genkey -name secp384r1 -out server.key 52 | $ openssl req -new -x509 -sha256 -key server.key -out server.crt -days 3650 53 | $ go run github.com/mccutchen/go-httpbin/v2/cmd/go-httpbin@latest -host 127.0.0.1 -port 8081 -https-cert-file ./server.crt -https-key-file ./server.key 54 | ``` 55 | 56 | ### Unit testing helper library 57 | 58 | The `github.com/mccutchen/go-httpbin/httpbin/v2` package can also be used as a 59 | library for testing an application's interactions with an upstream HTTP 60 | service, like so: 61 | 62 | ```go 63 | package httpbin_test 64 | 65 | import ( 66 | "net/http" 67 | "net/http/httptest" 68 | "os" 69 | "testing" 70 | "time" 71 | 72 | "github.com/mccutchen/go-httpbin/v2/httpbin" 73 | ) 74 | 75 | func TestSlowResponse(t *testing.T) { 76 | app := httpbin.New() 77 | testServer := httptest.NewServer(app) 78 | defer testServer.Close() 79 | 80 | client := http.Client{ 81 | Timeout: time.Duration(1 * time.Second), 82 | } 83 | 84 | _, err := client.Get(testServer.URL + "/delay/10") 85 | if !os.IsTimeout(err) { 86 | t.Fatalf("expected timeout error, got %s", err) 87 | } 88 | } 89 | ``` 90 | 91 | ### Configuration 92 | 93 | go-httpbin can be configured via either command line arguments or environment 94 | variables (or a combination of the two): 95 | 96 | | Argument| Env var | Documentation | Default | 97 | | - | - | - | - | 98 | | `-allowed-redirect-domains` | `ALLOWED_REDIRECT_DOMAINS` | Comma-separated list of domains the /redirect-to endpoint will allow | | 99 | | `-exclude-headers` | `EXCLUDE_HEADERS` | Drop platform-specific headers. Comma-separated list of headers key to drop, supporting wildcard suffix matching. For example: `"foo,bar,x-fc-*"` | - | 100 | | `-host` | `HOST` | Host to listen on | "0.0.0.0" | 101 | | `-https-cert-file` | `HTTPS_CERT_FILE` | HTTPS Server certificate file | | 102 | | `-https-key-file` | `HTTPS_KEY_FILE` | HTTPS Server private key file | | 103 | | `-log-format` | `LOG_FORMAT` | Log format (text or json) | "text" | 104 | | `-max-body-size` | `MAX_BODY_SIZE` | Maximum size of request or response, in bytes | 1048576 | 105 | | `-max-duration` | `MAX_DURATION` | Maximum duration a response may take | 10s | 106 | | `-port` | `PORT` | Port to listen on | 8080 | 107 | | `-prefix` | `PREFIX` | Prefix of path to listen on (must start with slash and does not end with slash) | | 108 | | `-srv-max-header-bytes` | `SRV_MAX_HEADER_BYTES` | Value to use for the http.Server's MaxHeaderBytes option | 16384 | 109 | | `-srv-read-header-timeout` | `SRV_READ_HEADER_TIMEOUT` | Value to use for the http.Server's ReadHeaderTimeout option | 1s | 110 | | `-srv-read-timeout` | `SRV_READ_TIMEOUT` | Value to use for the http.Server's ReadTimeout option | 5s | 111 | | `-use-real-hostname` | `USE_REAL_HOSTNAME` | Expose real hostname as reported by os.Hostname() in the /hostname endpoint | false | 112 | 113 | #### ⚠️ **HERE BE DRAGONS** ⚠️ 114 | 115 | These configuration options are dangerous and/or deprecated and should be 116 | avoided unless backwards compatibility is absolutely required. 117 | 118 | | Argument| Env var | Documentation | Default | 119 | | - | - | - | - | 120 | | `-unsafe-allow-dangerous-responses` | `UNSAFE_ALLOW_DANGEROUS_RESPONSES` | Allow endpoints to return unescaped HTML when clients control response Content-Type (enables XSS attacks) | false | 121 | 122 | **Notes:** 123 | - Command line arguments take precedence over environment variables. 124 | - See [Production considerations] for recommendations around safe configuration 125 | of public instances of go-httpbin 126 | 127 | 128 | ## Installation 129 | 130 | To add go-httpbin as a dependency to an existing golang project (e.g. for use 131 | in unit tests): 132 | 133 | ``` 134 | go get -u github.com/mccutchen/go-httpbin/v2 135 | ``` 136 | 137 | To install the `go-httpbin` binary: 138 | 139 | ``` 140 | go install github.com/mccutchen/go-httpbin/v2/cmd/go-httpbin@latest 141 | ``` 142 | 143 | 144 | ## Production considerations 145 | 146 | Before deploying an instance of go-httpbin on your own infrastructure on the 147 | public internet, consider tuning it appropriately: 148 | 149 | 1. **Restrict the domains to which the `/redirect-to` endpoint will send 150 | traffic to avoid the security issues of an open redirect** 151 | 152 | Use the `-allowed-redirect-domains` CLI argument or the 153 | `ALLOWED_REDIRECT_DOMAINS` env var to configure an appropriate allowlist. 154 | 155 | 2. **Tune per-request limits** 156 | 157 | Because go-httpbin allows clients send arbitrary data in request bodies and 158 | control the duration some requests (e.g. `/delay/60s`), it's important to 159 | properly tune limits to prevent misbehaving or malicious clients from taking 160 | too many resources. 161 | 162 | Use the `-max-body-size`/`MAX_BODY_SIZE` and `-max-duration`/`MAX_DURATION` 163 | CLI arguments or env vars to enforce appropriate limits on each request. 164 | 165 | 3. **Decide whether to expose real hostnames in the `/hostname` endpoint** 166 | 167 | By default, the `/hostname` endpoint serves a dummy hostname value, but it 168 | can be configured to serve the real underlying hostname (according to 169 | `os.Hostname()`) using the `-use-real-hostname` CLI argument or the 170 | `USE_REAL_HOSTNAME` env var to enable this functionality. 171 | 172 | Before enabling this, ensure that your hostnames do not reveal too much 173 | about your underlying infrastructure. 174 | 175 | 4. **Add custom instrumentation** 176 | 177 | By default, go-httpbin logs basic information about each request. To add 178 | more detailed instrumentation (metrics, structured logging, request 179 | tracing), you'll need to wrap this package in your own code, which you can 180 | then instrument as you would any net/http server. Some examples: 181 | 182 | - [examples/custom-instrumentation] instruments every request using DataDog, 183 | based on the built-in [Observer] mechanism. 184 | 185 | - [mccutchen/httpbingo.org] is the code that powers the public instance of 186 | go-httpbin deployed to [httpbingo.org], which adds customized structured 187 | logging using [zerolog] and further hardens the HTTP server against 188 | malicious clients by tuning lower-level timeouts and limits. 189 | 190 | 5. **Prevent leaking sensitive headers** 191 | 192 | By default, go-httpbin will return any request headers sent by the client 193 | (and any intermediate proxies) in the response. If go-httpbin is deployed 194 | into an environment where some incoming request headers might reveal 195 | sensitive information, use the `-exclude-headers` CLI argument or 196 | `EXCLUDE_HEADERS` env var to configure a denylist of sensitive header keys. 197 | 198 | For example, the Alibaba Cloud Function Compute platform adds 199 | [a variety of `x-fc-*` headers][alibaba-headers] to each incoming request, 200 | some of which might be sensitive. To have go-httpbin filter **all** of these 201 | headers in its own responses, set: 202 | 203 | EXCLUDE_HEADERS="x-fc-*" 204 | 205 | To have go-httpbin filter only specific headers, you can get more specific: 206 | 207 | EXCLUDE_HEADERS="x-fc-access-key-*,x-fc-security-token,x-fc-region" 208 | 209 | ## Development 210 | 211 | See [DEVELOPMENT.md][]. 212 | 213 | ## Security 214 | 215 | See [SECURITY.md][]. 216 | 217 | ## Motivation & prior art 218 | 219 | I've been a longtime user of [Kenneith Reitz][kr]'s original 220 | [httpbin.org][httpbin-org], and wanted to write a golang port for fun and to 221 | see how far I could get using only the stdlib. 222 | 223 | When I started this project, there were a handful of existing and incomplete 224 | golang ports, with the most promising being [ahmetb/go-httpbin][ahmet]. This 225 | project showed me how useful it might be to have an `httpbin` _library_ 226 | available for testing golang applications. 227 | 228 | ### Known differences from other httpbin versions 229 | 230 | Compared to [the original][httpbin-org]: 231 | - No `/brotli` endpoint (due to lack of support in Go's stdlib) 232 | - The `?show_env=1` query param is ignored (i.e. no special handling of 233 | runtime environment headers) 234 | - Response values which may be encoded as either a string or a list of strings 235 | will always be encoded as a list of strings (e.g. request headers, query 236 | params, form values) 237 | 238 | Compared to [ahmetb/go-httpbin][ahmet]: 239 | - No dependencies on 3rd party packages 240 | - More complete implementation of endpoints 241 | 242 | 243 | [ahmet]: https://github.com/ahmetb/go-httpbin 244 | [alibaba-headers]: https://www.alibabacloud.com/help/en/fc/user-guide/specification-details#section-3f8-5y1-i77 245 | [DEVELOPMENT.md]: ./DEVELOPMENT.md 246 | [docker-hub]: https://hub.docker.com/r/mccutchen/go-httpbin/ 247 | [examples/custom-instrumentation]: ./examples/custom-instrumentation/ 248 | [ghcr]: https://github.com/mccutchen/go-httpbin/pkgs/container/go-httpbin 249 | [httpbin-org]: https://httpbin.org/ 250 | [httpbin-repo]: https://github.com/kennethreitz/httpbin 251 | [httpbingo.org]: https://httpbingo.org/ 252 | [kr]: https://github.com/kennethreitz 253 | [mccutchen/httpbingo.org]: https://github.com/mccutchen/httpbingo.org 254 | [Observer]: https://pkg.go.dev/github.com/mccutchen/go-httpbin/v2/httpbin#Observer 255 | [Production considerations]: #production-considerations 256 | [SECURITY.md]: ./SECURITY.md 257 | [zerolog]: https://github.com/rs/zerolog 258 | -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | # Security Policy 2 | 3 | ## Reporting a Vulnerability 4 | 5 | The go-httpbin project takes security issues seriously. We appreciate your 6 | efforts to responsibly disclose your findings and will make every effort to 7 | acknowledge your contributions. 8 | 9 | To report a security vulnerability, please follow these steps: 10 | 11 | 1. **Do not** disclose the vulnerability publicly on GitHub issues, 12 | discussions, or elsewhere. 13 | 14 | 2. Submit your report via this repo's [New Security Issue][1] page, using 15 | GitHub's built-in security advisory feature 16 | 17 | 3. Please include the following information in your report: 18 | - A description of the vulnerability 19 | - Steps to reproduce the issue 20 | - Potential impact of the vulnerability 21 | - Any potential solutions you may have identified 22 | 23 | ## Response Process 24 | 25 | - You will receive an acknowledgment of your report through GitHub's security 26 | advisory system. 27 | 28 | - We will investigate the issue and provide updates on our progress through 29 | the advisory. We may ask for additional information or guidance. 30 | 31 | - Once the issue is resolved, we will publicly acknowledge your contribution 32 | (unless you prefer to remain anonymous). 33 | 34 | ## Scope 35 | 36 | This security policy applies to the [latest release][2] of go-httpbin. For 37 | older versions, please consider upgrading to the latest release. 38 | 39 | ## Support 40 | 41 | go-httpbin is a personal side project. While we take security seriously, 42 | please understand that response times may vary based on the severity of the 43 | reported issue and maintainer availability. 44 | 45 | Thank you for helping keep go-httpbin and its users secure! 46 | 47 | [1]: https://github.com/mccutchen/go-httpbin/security/advisories/new 48 | [2]: https://github.com/mccutchen/go-httpbin/releases 49 | -------------------------------------------------------------------------------- /cmd/go-httpbin/main.go: -------------------------------------------------------------------------------- 1 | // Package main implements the go-httpbin command line tool. 2 | package main 3 | 4 | import ( 5 | "os" 6 | 7 | "github.com/mccutchen/go-httpbin/v2/httpbin/cmd" 8 | ) 9 | 10 | func main() { 11 | os.Exit(cmd.Main()) 12 | } 13 | -------------------------------------------------------------------------------- /examples/build-all: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -euo pipefail 4 | 5 | cd "$(dirname "$0")" 6 | for dir in $(find . -name go.mod -mindepth 2 -maxdepth 2 | cut -d/ -f2); do 7 | pushd "$dir" >/dev/null 8 | echo "building example $dir ..." 1>&2 9 | go build -o /dev/null . 10 | popd >/dev/null 11 | done 12 | -------------------------------------------------------------------------------- /examples/custom-instrumentation/README.md: -------------------------------------------------------------------------------- 1 | # Custom Instrumentation 2 | 3 | This example demonstrates how to use go-httpbin's [`Observer`][1] mechanism to 4 | add custom instrumentation to a go-httpbin instance. 5 | 6 | An _observer_ is a function that will be called with an [`httpbin.Result`][2] 7 | struct after every request, which provides a hook for custom logging, metrics, 8 | or other instrumentation. 9 | 10 | Note: This does require building your own small wrapper around go-httpbin, as 11 | you can see in [main.go](./main.go) here. That's because go-httpbin has no 12 | dependencies outside of the Go stdlib, to make sure that it is as 13 | safe/lightweight as possible to include as a dependency in other applications' 14 | test suites where useful. 15 | 16 | [1]: https://pkg.go.dev/github.com/mccutchen/go-httpbin/v2/httpbin#Observer 17 | [2]: https://pkg.go.dev/github.com/mccutchen/go-httpbin/v2/httpbin#Result 18 | -------------------------------------------------------------------------------- /examples/custom-instrumentation/go.mod: -------------------------------------------------------------------------------- 1 | module httpbin-instrumentation 2 | 3 | go 1.22.0 4 | 5 | require ( 6 | github.com/DataDog/datadog-go v4.8.3+incompatible 7 | github.com/mccutchen/go-httpbin/v2 v2.14.1 8 | ) 9 | 10 | require ( 11 | github.com/Microsoft/go-winio v0.6.2 // indirect 12 | github.com/davecgh/go-spew v1.1.1 // indirect 13 | github.com/stretchr/objx v0.3.0 // indirect 14 | github.com/stretchr/testify v1.3.0 // indirect 15 | golang.org/x/sys v0.25.0 // indirect 16 | ) 17 | 18 | // Always build against the local version, to make it easier to update examples 19 | // in sync with changes to go-httpbin 20 | replace github.com/mccutchen/go-httpbin/v2 => ../.. 21 | -------------------------------------------------------------------------------- /examples/custom-instrumentation/go.sum: -------------------------------------------------------------------------------- 1 | github.com/DataDog/datadog-go v4.8.3+incompatible h1:fNGaYSuObuQb5nzeTQqowRAd9bpDIRRV4/gUtIBjh8Q= 2 | github.com/DataDog/datadog-go v4.8.3+incompatible/go.mod h1:LButxg5PwREeZtORoXG3tL4fMGNddJ+vMq1mwgfaqoQ= 3 | github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= 4 | github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= 5 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 6 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 7 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 8 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 9 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 10 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 11 | github.com/stretchr/objx v0.3.0 h1:NGXK3lHquSN08v5vWalVI/L8XU9hdzE/G6xsrze47As= 12 | github.com/stretchr/objx v0.3.0/go.mod h1:qt09Ya8vawLte6SNmTgCsAVtYtaKzEcn8ATUoHMkEqE= 13 | github.com/stretchr/testify v1.3.0 h1:TivCn/peBQ7UY8ooIcPgZFpTNSz0Q2U6UrFlUfqbe0Q= 14 | github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= 15 | golang.org/x/sys v0.25.0 h1:r+8e+loiHxRqhXVl6ML1nO3l1+oFoWbnlu2Ehimmi34= 16 | golang.org/x/sys v0.25.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 17 | -------------------------------------------------------------------------------- /examples/custom-instrumentation/main.go: -------------------------------------------------------------------------------- 1 | // Package main demonstrates how to instrument httpbin with custom metrics. 2 | package main 3 | 4 | import ( 5 | "fmt" 6 | "log" 7 | "net/http" 8 | 9 | "github.com/DataDog/datadog-go/statsd" 10 | 11 | "github.com/mccutchen/go-httpbin/v2/httpbin" 12 | ) 13 | 14 | func main() { 15 | statsdClient, _ := statsd.New("") 16 | 17 | app := httpbin.New( 18 | httpbin.WithObserver(datadogObserver(statsdClient)), 19 | ) 20 | 21 | listenAddr := "0.0.0.0:8080" 22 | http.ListenAndServe(listenAddr, app) 23 | } 24 | 25 | func datadogObserver(client statsd.ClientInterface) httpbin.Observer { 26 | return func(result httpbin.Result) { 27 | // Log the request 28 | log.Printf("%d %s %s %s", result.Status, result.Method, result.URI, result.Duration) 29 | 30 | // Submit a new distribution metric to datadog with tags that allow 31 | // graphing request rate, timing, errors broken down by 32 | // method/status/path. 33 | tags := []string{ 34 | fmt.Sprintf("method:%s", result.Method), 35 | fmt.Sprintf("status_code:%d", result.Status), 36 | fmt.Sprintf("status_class:%dxx", result.Status/100), 37 | fmt.Sprintf("uri:%s", result.URI), 38 | } 39 | client.Distribution("httpbin.request", float64(result.Duration.Milliseconds()), tags, 1.0) 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/mccutchen/go-httpbin/v2 2 | 3 | go 1.22.0 4 | -------------------------------------------------------------------------------- /httpbin/cmd/cmd.go: -------------------------------------------------------------------------------- 1 | // Package cmd implements the go-httpbin command line interface as a testable 2 | // package. 3 | package cmd 4 | 5 | import ( 6 | "bytes" 7 | "context" 8 | "flag" 9 | "fmt" 10 | "io" 11 | "log/slog" 12 | "net" 13 | "net/http" 14 | "os" 15 | "os/signal" 16 | "strconv" 17 | "strings" 18 | "syscall" 19 | "time" 20 | 21 | "github.com/mccutchen/go-httpbin/v2/httpbin" 22 | ) 23 | 24 | const ( 25 | defaultListenHost = "0.0.0.0" 26 | defaultListenPort = 8080 27 | defaultLogFormat = "text" 28 | defaultEnvPrefix = "HTTPBIN_ENV_" 29 | 30 | // Reasonable defaults for the underlying http.Server 31 | defaultSrvReadTimeout = 5 * time.Second 32 | defaultSrvReadHeaderTimeout = 1 * time.Second 33 | defaultSrvMaxHeaderBytes = 16 * 1024 // 16kb 34 | ) 35 | 36 | // Main is the main entrypoint for the go-httpbin binary. See loadConfig() for 37 | // command line argument parsing. 38 | func Main() int { 39 | return mainImpl(os.Args[1:], os.Getenv, os.Environ, os.Hostname, os.Stderr) 40 | } 41 | 42 | // mainImpl is the real implementation of Main(), extracted for better 43 | // testability. 44 | func mainImpl(args []string, getEnvVal func(string) string, getEnviron func() []string, getHostname func() (string, error), out io.Writer) int { 45 | cfg, err := loadConfig(args, getEnvVal, getEnviron, getHostname) 46 | if err != nil { 47 | if cfgErr, ok := err.(ConfigError); ok { 48 | // for -h/-help, just print usage and exit without error 49 | if cfgErr.Err == flag.ErrHelp { 50 | fmt.Fprint(out, cfgErr.Usage) 51 | return 0 52 | } 53 | 54 | // anything else indicates a problem with CLI arguments and/or 55 | // environment vars, so print error and usage and exit with an 56 | // error status. 57 | // 58 | // note: seems like there's consensus that an exit code of 2 is 59 | // often used to indicate a problem with the way a command was 60 | // called, e.g.: 61 | // https://stackoverflow.com/a/40484670/151221 62 | // https://linuxconfig.org/list-of-exit-codes-on-linux 63 | fmt.Fprintf(out, "error: %s\n\n%s", cfgErr.Err, cfgErr.Usage) 64 | return 2 65 | } 66 | fmt.Fprintf(out, "error: %s", err) 67 | return 1 68 | } 69 | 70 | logger := slog.New(slog.NewTextHandler(out, nil)) 71 | 72 | if cfg.LogFormat == "json" { 73 | // use structured logging if requested 74 | handler := slog.NewJSONHandler(out, nil) 75 | logger = slog.New(handler) 76 | } 77 | 78 | opts := []httpbin.OptionFunc{ 79 | httpbin.WithEnv(cfg.Env), 80 | httpbin.WithMaxBodySize(cfg.MaxBodySize), 81 | httpbin.WithMaxDuration(cfg.MaxDuration), 82 | httpbin.WithObserver(httpbin.StdLogObserver(logger)), 83 | httpbin.WithExcludeHeaders(cfg.ExcludeHeaders), 84 | } 85 | if cfg.Prefix != "" { 86 | opts = append(opts, httpbin.WithPrefix(cfg.Prefix)) 87 | } 88 | if cfg.RealHostname != "" { 89 | opts = append(opts, httpbin.WithHostname(cfg.RealHostname)) 90 | } 91 | if len(cfg.AllowedRedirectDomains) > 0 { 92 | opts = append(opts, httpbin.WithAllowedRedirectDomains(cfg.AllowedRedirectDomains)) 93 | } 94 | if cfg.UnsafeAllowDangerousResponses { 95 | opts = append(opts, httpbin.WithUnsafeAllowDangerousResponses()) 96 | } 97 | app := httpbin.New(opts...) 98 | 99 | srv := &http.Server{ 100 | Addr: net.JoinHostPort(cfg.ListenHost, strconv.Itoa(cfg.ListenPort)), 101 | Handler: app.Handler(), 102 | MaxHeaderBytes: cfg.SrvMaxHeaderBytes, 103 | ReadHeaderTimeout: cfg.SrvReadHeaderTimeout, 104 | ReadTimeout: cfg.SrvReadTimeout, 105 | } 106 | 107 | if err := listenAndServeGracefully(srv, cfg, logger); err != nil { 108 | logger.Error(fmt.Sprintf("error: %s", err)) 109 | return 1 110 | } 111 | 112 | return 0 113 | } 114 | 115 | // config holds the configuration needed to initialize and run go-httpbin as a 116 | // standalone server. 117 | type config struct { 118 | Env map[string]string 119 | AllowedRedirectDomains []string 120 | ListenHost string 121 | ExcludeHeaders string 122 | ListenPort int 123 | MaxBodySize int64 124 | MaxDuration time.Duration 125 | Prefix string 126 | RealHostname string 127 | TLSCertFile string 128 | TLSKeyFile string 129 | LogFormat string 130 | SrvMaxHeaderBytes int 131 | SrvReadHeaderTimeout time.Duration 132 | SrvReadTimeout time.Duration 133 | 134 | // If true, endpoints that allow clients to specify a response 135 | // Conntent-Type will NOT escape HTML entities in the response body, which 136 | // can enable (e.g.) reflected XSS attacks. 137 | // 138 | // This configuration is only supported for backwards compatibility if 139 | // absolutely necessary. 140 | UnsafeAllowDangerousResponses bool 141 | 142 | // temporary placeholders for arguments that need extra processing 143 | rawAllowedRedirectDomains string 144 | rawUseRealHostname bool 145 | } 146 | 147 | // ConfigError is used to signal an error with a command line argument or 148 | // environment variable. 149 | // 150 | // It carries the command's usage output, so that we can decouple configuration 151 | // parsing from error reporting for better testability. 152 | type ConfigError struct { 153 | Err error 154 | Usage string 155 | } 156 | 157 | // Error implements the error interface. 158 | func (e ConfigError) Error() string { 159 | return e.Err.Error() 160 | } 161 | 162 | // loadConfig parses command line arguments and env vars into a fully resolved 163 | // Config struct. Command line arguments take precedence over env vars. 164 | func loadConfig(args []string, getEnvVal func(string) string, getEnviron func() []string, getHostname func() (string, error)) (*config, error) { 165 | cfg := &config{} 166 | 167 | fs := flag.NewFlagSet("go-httpbin", flag.ContinueOnError) 168 | fs.BoolVar(&cfg.rawUseRealHostname, "use-real-hostname", false, "Expose value of os.Hostname() in the /hostname endpoint instead of dummy value") 169 | fs.DurationVar(&cfg.MaxDuration, "max-duration", httpbin.DefaultMaxDuration, "Maximum duration a response may take") 170 | fs.Int64Var(&cfg.MaxBodySize, "max-body-size", httpbin.DefaultMaxBodySize, "Maximum size of request or response, in bytes") 171 | fs.IntVar(&cfg.ListenPort, "port", defaultListenPort, "Port to listen on") 172 | fs.StringVar(&cfg.rawAllowedRedirectDomains, "allowed-redirect-domains", "", "Comma-separated list of domains the /redirect-to endpoint will allow") 173 | fs.StringVar(&cfg.ListenHost, "host", defaultListenHost, "Host to listen on") 174 | fs.StringVar(&cfg.Prefix, "prefix", "", "Path prefix (empty or start with slash and does not end with slash)") 175 | fs.StringVar(&cfg.TLSCertFile, "https-cert-file", "", "HTTPS Server certificate file") 176 | fs.StringVar(&cfg.TLSKeyFile, "https-key-file", "", "HTTPS Server private key file") 177 | fs.StringVar(&cfg.ExcludeHeaders, "exclude-headers", "", "Drop platform-specific headers. Comma-separated list of headers key to drop, supporting wildcard matching.") 178 | fs.StringVar(&cfg.LogFormat, "log-format", defaultLogFormat, "Log format (text or json)") 179 | fs.IntVar(&cfg.SrvMaxHeaderBytes, "srv-max-header-bytes", defaultSrvMaxHeaderBytes, "Value to use for the http.Server's MaxHeaderBytes option") 180 | fs.DurationVar(&cfg.SrvReadHeaderTimeout, "srv-read-header-timeout", defaultSrvReadHeaderTimeout, "Value to use for the http.Server's ReadHeaderTimeout option") 181 | fs.DurationVar(&cfg.SrvReadTimeout, "srv-read-timeout", defaultSrvReadTimeout, "Value to use for the http.Server's ReadTimeout option") 182 | 183 | // Here be dragons! This flag is only for backwards compatibility and 184 | // should not be used in production. 185 | fs.BoolVar(&cfg.UnsafeAllowDangerousResponses, "unsafe-allow-dangerous-responses", false, "Allow endpoints to return unescaped HTML when clients control response Content-Type (enables XSS attacks)") 186 | 187 | // in order to fully control error output whether CLI arguments or env vars 188 | // are used to configure the app, we need to take control away from the 189 | // flag-set, which by defaults prints errors automatically. 190 | // 191 | // so, we capture the "usage" output it would generate and then trick it 192 | // into generating no output on errors, since they'll be handled by the 193 | // caller. 194 | // 195 | // yes, this is goofy, but it makes the CLI testable! 196 | buf := &bytes.Buffer{} 197 | fs.SetOutput(buf) 198 | fs.Usage() 199 | usage := buf.String() 200 | fs.SetOutput(io.Discard) 201 | 202 | if err := fs.Parse(args); err != nil { 203 | return nil, ConfigError{err, usage} 204 | } 205 | 206 | // helper to generate a new ConfigError to return 207 | configErr := func(format string, a ...interface{}) error { 208 | return ConfigError{ 209 | Err: fmt.Errorf(format, a...), 210 | Usage: usage, 211 | } 212 | } 213 | 214 | var err error 215 | 216 | // Command line flags take precedence over environment vars, so we only 217 | // check for environment vars if we have default values for our command 218 | // line flags. 219 | if cfg.MaxBodySize == httpbin.DefaultMaxBodySize && getEnvVal("MAX_BODY_SIZE") != "" { 220 | cfg.MaxBodySize, err = strconv.ParseInt(getEnvVal("MAX_BODY_SIZE"), 10, 64) 221 | if err != nil { 222 | return nil, configErr("invalid value %#v for env var MAX_BODY_SIZE: parse error", getEnvVal("MAX_BODY_SIZE")) 223 | } 224 | } 225 | 226 | if cfg.MaxDuration == httpbin.DefaultMaxDuration && getEnvVal("MAX_DURATION") != "" { 227 | cfg.MaxDuration, err = time.ParseDuration(getEnvVal("MAX_DURATION")) 228 | if err != nil { 229 | return nil, configErr("invalid value %#v for env var MAX_DURATION: parse error", getEnvVal("MAX_DURATION")) 230 | } 231 | } 232 | if cfg.ListenHost == defaultListenHost && getEnvVal("HOST") != "" { 233 | cfg.ListenHost = getEnvVal("HOST") 234 | } 235 | if cfg.Prefix == "" { 236 | if prefix := getEnvVal("PREFIX"); prefix != "" { 237 | cfg.Prefix = prefix 238 | } 239 | } 240 | if cfg.Prefix != "" { 241 | if !strings.HasPrefix(cfg.Prefix, "/") { 242 | return nil, configErr("Prefix %#v must start with a slash", cfg.Prefix) 243 | } 244 | if strings.HasSuffix(cfg.Prefix, "/") { 245 | return nil, configErr("Prefix %#v must not end with a slash", cfg.Prefix) 246 | } 247 | } 248 | if cfg.ExcludeHeaders == "" && getEnvVal("EXCLUDE_HEADERS") != "" { 249 | cfg.ExcludeHeaders = getEnvVal("EXCLUDE_HEADERS") 250 | } 251 | if cfg.ListenPort == defaultListenPort && getEnvVal("PORT") != "" { 252 | cfg.ListenPort, err = strconv.Atoi(getEnvVal("PORT")) 253 | if err != nil { 254 | return nil, configErr("invalid value %#v for env var PORT: parse error", getEnvVal("PORT")) 255 | } 256 | } 257 | 258 | if cfg.TLSCertFile == "" && getEnvVal("HTTPS_CERT_FILE") != "" { 259 | cfg.TLSCertFile = getEnvVal("HTTPS_CERT_FILE") 260 | } 261 | if cfg.TLSKeyFile == "" && getEnvVal("HTTPS_KEY_FILE") != "" { 262 | cfg.TLSKeyFile = getEnvVal("HTTPS_KEY_FILE") 263 | } 264 | if cfg.TLSCertFile != "" || cfg.TLSKeyFile != "" { 265 | if cfg.TLSCertFile == "" || cfg.TLSKeyFile == "" { 266 | return nil, configErr("https cert and key must both be provided") 267 | } 268 | } 269 | if cfg.LogFormat == defaultLogFormat && getEnvVal("LOG_FORMAT") != "" { 270 | cfg.LogFormat = getEnvVal("LOG_FORMAT") 271 | } 272 | if cfg.LogFormat != "text" && cfg.LogFormat != "json" { 273 | return nil, configErr(`invalid log format %q, must be "text" or "json"`, cfg.LogFormat) 274 | } 275 | 276 | if getEnvBool(getEnvVal("USE_REAL_HOSTNAME")) { 277 | cfg.rawUseRealHostname = true 278 | } 279 | if cfg.rawUseRealHostname { 280 | cfg.RealHostname, err = getHostname() 281 | if err != nil { 282 | return nil, fmt.Errorf("could not look up real hostname: %w", err) 283 | } 284 | } 285 | 286 | // split comma-separated list of domains into a slice, if given 287 | if cfg.rawAllowedRedirectDomains == "" && getEnvVal("ALLOWED_REDIRECT_DOMAINS") != "" { 288 | cfg.rawAllowedRedirectDomains = getEnvVal("ALLOWED_REDIRECT_DOMAINS") 289 | } 290 | for _, domain := range strings.Split(cfg.rawAllowedRedirectDomains, ",") { 291 | if strings.TrimSpace(domain) != "" { 292 | cfg.AllowedRedirectDomains = append(cfg.AllowedRedirectDomains, strings.TrimSpace(domain)) 293 | } 294 | } 295 | 296 | // set the http.Server options 297 | if cfg.SrvMaxHeaderBytes == defaultSrvMaxHeaderBytes && getEnvVal("SRV_MAX_HEADER_BYTES") != "" { 298 | cfg.SrvMaxHeaderBytes, err = strconv.Atoi(getEnvVal("SRV_MAX_HEADER_BYTES")) 299 | if err != nil { 300 | return nil, configErr("invalid value %#v for env var SRV_MAX_HEADER_BYTES: parse error", getEnvVal("SRV_MAX_HEADER_BYTES")) 301 | } 302 | } 303 | if cfg.SrvReadHeaderTimeout == defaultSrvReadHeaderTimeout && getEnvVal("SRV_READ_HEADER_TIMEOUT") != "" { 304 | cfg.SrvReadHeaderTimeout, err = time.ParseDuration(getEnvVal("SRV_READ_HEADER_TIMEOUT")) 305 | if err != nil { 306 | return nil, configErr("invalid value %#v for env var SRV_READ_HEADER_TIMEOUT: parse error", getEnvVal("SRV_READ_HEADER_TIMEOUT")) 307 | } 308 | } 309 | if cfg.SrvReadTimeout == defaultSrvReadTimeout && getEnvVal("SRV_READ_TIMEOUT") != "" { 310 | cfg.SrvReadTimeout, err = time.ParseDuration(getEnvVal("SRV_READ_TIMEOUT")) 311 | if err != nil { 312 | return nil, configErr("invalid value %#v for env var SRV_READ_TIMEOUT: parse error", getEnvVal("SRV_READ_TIMEOUT")) 313 | } 314 | } 315 | 316 | if getEnvBool(getEnvVal("UNSAFE_ALLOW_DANGEROUS_RESPONSES")) { 317 | cfg.UnsafeAllowDangerousResponses = true 318 | } 319 | 320 | // reset temporary fields to their zero values 321 | cfg.rawAllowedRedirectDomains = "" 322 | cfg.rawUseRealHostname = false 323 | 324 | for _, envVar := range getEnviron() { 325 | name, value, _ := strings.Cut(envVar, "=") 326 | if !strings.HasPrefix(name, defaultEnvPrefix) { 327 | continue 328 | } 329 | if cfg.Env == nil { 330 | cfg.Env = make(map[string]string) 331 | } 332 | cfg.Env[name] = value 333 | } 334 | 335 | return cfg, nil 336 | } 337 | 338 | func getEnvBool(val string) bool { 339 | return val == "1" || val == "true" 340 | } 341 | 342 | func listenAndServeGracefully(srv *http.Server, cfg *config, logger *slog.Logger) error { 343 | doneCh := make(chan error, 1) 344 | 345 | go func() { 346 | sigCh := make(chan os.Signal, 1) 347 | signal.Notify(sigCh, syscall.SIGTERM, syscall.SIGINT) 348 | <-sigCh 349 | 350 | logger.Info("shutting down ...") 351 | ctx, cancel := context.WithTimeout(context.Background(), cfg.MaxDuration+1*time.Second) 352 | defer cancel() 353 | doneCh <- srv.Shutdown(ctx) 354 | }() 355 | 356 | var err error 357 | if cfg.TLSCertFile != "" && cfg.TLSKeyFile != "" { 358 | logger.Info(fmt.Sprintf("go-httpbin listening on https://%s", srv.Addr)) 359 | err = srv.ListenAndServeTLS(cfg.TLSCertFile, cfg.TLSKeyFile) 360 | } else { 361 | logger.Info(fmt.Sprintf("go-httpbin listening on http://%s", srv.Addr)) 362 | err = srv.ListenAndServe() 363 | } 364 | if err != nil && err != http.ErrServerClosed { 365 | return err 366 | } 367 | 368 | return <-doneCh 369 | } 370 | -------------------------------------------------------------------------------- /httpbin/digest/digest.go: -------------------------------------------------------------------------------- 1 | // Package digest provides a limited implementation of HTTP Digest 2 | // Authentication, as defined in RFC 2617. 3 | // 4 | // Only the "auth" QOP directive is handled at this time, and while support for 5 | // the SHA-256 algorithm is implemented here it does not actually work in 6 | // either Chrome or Firefox. 7 | // 8 | // For more info, see: 9 | // https://tools.ietf.org/html/rfc2617 10 | // https://en.wikipedia.org/wiki/Digest_access_authentication 11 | package digest 12 | 13 | import ( 14 | "crypto/md5" 15 | crypto_rand "crypto/rand" 16 | "crypto/sha256" 17 | "crypto/subtle" 18 | "fmt" 19 | "net/http" 20 | "strings" 21 | "time" 22 | ) 23 | 24 | // digestAlgorithm is an algorithm used to hash digest payloads 25 | type digestAlgorithm int 26 | 27 | // Digest algorithms supported by this package 28 | const ( 29 | MD5 digestAlgorithm = iota 30 | SHA256 31 | ) 32 | 33 | func (a digestAlgorithm) String() string { 34 | switch a { 35 | case MD5: 36 | return "MD5" 37 | case SHA256: 38 | return "SHA-256" 39 | } 40 | return "UNKNOWN" 41 | } 42 | 43 | // Check returns a bool indicating whether the request is correctly 44 | // authenticated for the given username and password. 45 | func Check(req *http.Request, username, password string) bool { 46 | auth := parseAuthorizationHeader(req.Header.Get("Authorization")) 47 | if auth == nil || auth.username != username { 48 | return false 49 | } 50 | expectedResponse := response(auth, password, req.Method, req.RequestURI) 51 | return compare(auth.response, expectedResponse) 52 | } 53 | 54 | // Challenge returns a WWW-Authenticate header value for the given realm and 55 | // algorithm. If an invalid realm or an unsupported algorithm is given 56 | func Challenge(realm string, algorithm digestAlgorithm) string { 57 | entropy := make([]byte, 32) 58 | crypto_rand.Read(entropy) 59 | 60 | opaqueVal := entropy[:16] 61 | nonceVal := fmt.Sprintf("%s:%x", time.Now(), entropy[16:31]) 62 | 63 | // we use MD5 to hash nonces regardless of hash used for authentication 64 | opaque := hash(opaqueVal, MD5) 65 | nonce := hash([]byte(nonceVal), MD5) 66 | 67 | return fmt.Sprintf("Digest qop=auth, realm=%#v, algorithm=%s, nonce=%s, opaque=%s", sanitizeRealm(realm), algorithm, nonce, opaque) 68 | } 69 | 70 | // sanitizeRealm tries to ensure that a given realm does not include any 71 | // characters that will trip up our extremely simplistic header parser. 72 | func sanitizeRealm(realm string) string { 73 | realm = strings.ReplaceAll(realm, `"`, "") 74 | realm = strings.ReplaceAll(realm, ",", "") 75 | return realm 76 | } 77 | 78 | // authorization is the result of parsing an Authorization header 79 | type authorization struct { 80 | algorithm digestAlgorithm 81 | cnonce string 82 | nc string 83 | nonce string 84 | opaque string 85 | qop string 86 | realm string 87 | response string 88 | uri string 89 | username string 90 | } 91 | 92 | // parseAuthorizationHeader parses an Authorization header into an 93 | // Authorization struct, given a an authorization header like: 94 | // 95 | // Authorization: Digest username="Mufasa", 96 | // realm="testrealm@host.com", 97 | // nonce="dcd98b7102dd2f0e8b11d0f600bfb0c093", 98 | // uri="/dir/index.html", 99 | // qop=auth, 100 | // nc=00000001, 101 | // cnonce="0a4f113b", 102 | // response="6629fae49393a05397450978507c4ef1", 103 | // opaque="5ccc069c403ebaf9f0171e9517f40e41" 104 | // 105 | // If the given value does not contain a Digest authorization header, or is in 106 | // some other way malformed, nil is returned. 107 | // 108 | // Example from Wikipedia: https://en.wikipedia.org/wiki/Digest_access_authentication#Example_with_explanation 109 | func parseAuthorizationHeader(value string) *authorization { 110 | if value == "" { 111 | return nil 112 | } 113 | 114 | parts := strings.SplitN(value, " ", 2) 115 | if parts[0] != "Digest" || len(parts) != 2 { 116 | return nil 117 | } 118 | 119 | authInfo := parts[1] 120 | auth := parseDictHeader(authInfo) 121 | 122 | algo := MD5 123 | if strings.ToLower(auth["algorithm"]) == "sha-256" { 124 | algo = SHA256 125 | } 126 | 127 | return &authorization{ 128 | algorithm: algo, 129 | cnonce: auth["cnonce"], 130 | nc: auth["nc"], 131 | nonce: auth["nonce"], 132 | opaque: auth["opaque"], 133 | qop: auth["qop"], 134 | realm: auth["realm"], 135 | response: auth["response"], 136 | uri: auth["uri"], 137 | username: auth["username"], 138 | } 139 | } 140 | 141 | // parseDictHeader is a simplistic, buggy, and incomplete implementation of 142 | // parsing key-value pairs from a header value into a map. 143 | func parseDictHeader(value string) map[string]string { 144 | pairs := strings.Split(value, ",") 145 | res := make(map[string]string, len(pairs)) 146 | for _, pair := range pairs { 147 | parts := strings.SplitN(strings.TrimSpace(pair), "=", 2) 148 | key := strings.TrimSpace(parts[0]) 149 | if len(key) == 0 { 150 | continue 151 | } 152 | val := "" 153 | if len(parts) > 1 { 154 | val = strings.TrimSpace(parts[1]) 155 | if strings.HasPrefix(val, `"`) && strings.HasSuffix(val, `"`) { 156 | val = val[1 : len(val)-1] 157 | } 158 | } 159 | res[key] = val 160 | } 161 | return res 162 | } 163 | 164 | // hash generates the hex digest of the given data using the given hashing 165 | // algorithm, which must be one of MD5 or SHA256. 166 | func hash(data []byte, algorithm digestAlgorithm) string { 167 | switch algorithm { 168 | case SHA256: 169 | return fmt.Sprintf("%x", sha256.Sum256(data)) 170 | default: 171 | return fmt.Sprintf("%x", md5.Sum(data)) 172 | } 173 | } 174 | 175 | // makeHA1 returns the HA1 hash, where 176 | // 177 | // HA1 = H(A1) = H(username:realm:password) 178 | // 179 | // and H is one of MD5 or SHA256. 180 | func makeHA1(realm, username, password string, algorithm digestAlgorithm) string { 181 | A1 := fmt.Sprintf("%s:%s:%s", username, realm, password) 182 | return hash([]byte(A1), algorithm) 183 | } 184 | 185 | // makeHA2 returns the HA2 hash, where 186 | // 187 | // HA2 = H(A2) = H(method:digestURI) 188 | // 189 | // and H is one of MD5 or SHA256. 190 | func makeHA2(auth *authorization, method, uri string) string { 191 | A2 := fmt.Sprintf("%s:%s", method, uri) 192 | return hash([]byte(A2), auth.algorithm) 193 | } 194 | 195 | // Response calculates the correct digest auth response. If the qop directive's 196 | // value is "auth" or "auth-int" , then compute the response as 197 | // 198 | // RESPONSE = H(HA1:nonce:nonceCount:clientNonce:qop:HA2) 199 | // 200 | // and if the qop directive is unspecified, then compute the response as 201 | // 202 | // RESPONSE = H(HA1:nonce:HA2) 203 | // 204 | // where H is one of MD5 or SHA256. 205 | func response(auth *authorization, password, method, uri string) string { 206 | ha1 := makeHA1(auth.realm, auth.username, password, auth.algorithm) 207 | ha2 := makeHA2(auth, method, uri) 208 | 209 | var r string 210 | if auth.qop == "auth" || auth.qop == "auth-int" { 211 | r = fmt.Sprintf("%s:%s:%s:%s:%s:%s", ha1, auth.nonce, auth.nc, auth.cnonce, auth.qop, ha2) 212 | } else { 213 | r = fmt.Sprintf("%s:%s:%s", ha1, auth.nonce, ha2) 214 | } 215 | return hash([]byte(r), auth.algorithm) 216 | } 217 | 218 | // compare is a constant-time string comparison 219 | func compare(x, y string) bool { 220 | return subtle.ConstantTimeCompare([]byte(x), []byte(y)) == 1 221 | } 222 | -------------------------------------------------------------------------------- /httpbin/digest/digest_test.go: -------------------------------------------------------------------------------- 1 | package digest 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | "reflect" 7 | "testing" 8 | ) 9 | 10 | // Well-formed examples from Wikipedia: 11 | // https://en.wikipedia.org/wiki/Digest_access_authentication#Example_with_explanation 12 | const ( 13 | exampleUsername = "Mufasa" 14 | examplePassword = "Circle Of Life" 15 | 16 | exampleAuthorization string = `Digest username="Mufasa", 17 | realm="testrealm@host.com", 18 | nonce="dcd98b7102dd2f0e8b11d0f600bfb0c093", 19 | uri="/dir/index.html", 20 | qop=auth, 21 | nc=00000001, 22 | cnonce="0a4f113b", 23 | response="6629fae49393a05397450978507c4ef1", 24 | opaque="5ccc069c403ebaf9f0171e9517f40e41"` 25 | ) 26 | 27 | func assertStringEquals(t *testing.T, expected, got string) { 28 | if expected != got { 29 | t.Errorf("Expected %#v, got %#v", expected, got) 30 | } 31 | } 32 | 33 | func buildRequest(method, uri, authHeader string) *http.Request { 34 | req, _ := http.NewRequest(method, uri, nil) 35 | req.RequestURI = uri 36 | if authHeader != "" { 37 | req.Header.Set("Authorization", authHeader) 38 | } 39 | return req 40 | } 41 | 42 | func TestCheck(t *testing.T) { 43 | t.Parallel() 44 | t.Run("missing authorization", func(t *testing.T) { 45 | t.Parallel() 46 | req := buildRequest("GET", "/dir/index.html", "") 47 | if Check(req, exampleUsername, examplePassword) != false { 48 | t.Error("Missing Authorization header should fail") 49 | } 50 | }) 51 | 52 | t.Run("wrong username", func(t *testing.T) { 53 | t.Parallel() 54 | req := buildRequest("GET", "/dir/index.html", exampleAuthorization) 55 | if Check(req, "Simba", examplePassword) != false { 56 | t.Error("Incorrect username should fail") 57 | } 58 | }) 59 | 60 | t.Run("wrong password", func(t *testing.T) { 61 | t.Parallel() 62 | req := buildRequest("GET", "/dir/index.html", exampleAuthorization) 63 | if Check(req, examplePassword, "foobar") != false { 64 | t.Error("Incorrect password should fail") 65 | } 66 | }) 67 | 68 | t.Run("ok", func(t *testing.T) { 69 | t.Parallel() 70 | req := buildRequest("GET", "/dir/index.html", exampleAuthorization) 71 | if Check(req, exampleUsername, examplePassword) != true { 72 | t.Error("Correct credentials should pass") 73 | } 74 | }) 75 | } 76 | 77 | func TestChallenge(t *testing.T) { 78 | t.Parallel() 79 | tests := []struct { 80 | realm string 81 | expectedRealm string 82 | algorithm digestAlgorithm 83 | expectedAlgorithm string 84 | }{ 85 | {"realm", "realm", MD5, "MD5"}, 86 | {"realm", "realm", SHA256, "SHA-256"}, 87 | {"realm with spaces", "realm with spaces", SHA256, "SHA-256"}, 88 | {`realm "with" "quotes"`, "realm with quotes", MD5, "MD5"}, 89 | {`spaces, "quotes," and commas`, "spaces quotes and commas", MD5, "MD5"}, 90 | } 91 | for _, test := range tests { 92 | challenge := Challenge(test.realm, test.algorithm) 93 | result := parseDictHeader(challenge) 94 | assertStringEquals(t, test.expectedRealm, result["realm"]) 95 | assertStringEquals(t, test.expectedAlgorithm, result["algorithm"]) 96 | } 97 | } 98 | 99 | func TestResponse(t *testing.T) { 100 | t.Parallel() 101 | auth := parseAuthorizationHeader(exampleAuthorization) 102 | expected := auth.response 103 | got := response(auth, examplePassword, "GET", "/dir/index.html") 104 | assertStringEquals(t, expected, got) 105 | } 106 | 107 | func TestHash(t *testing.T) { 108 | t.Parallel() 109 | tests := []struct { 110 | algorithm digestAlgorithm 111 | data []byte 112 | expected string 113 | }{ 114 | {SHA256, []byte("hello, world!\n"), "4dca0fd5f424a31b03ab807cbae77eb32bf2d089eed1cee154b3afed458de0dc"}, 115 | {MD5, []byte("hello, world!\n"), "910c8bc73110b0cd1bc5d2bcae782511"}, 116 | 117 | // Any unhandled hash results in MD5 being used 118 | {digestAlgorithm(10), []byte("hello, world!\n"), "910c8bc73110b0cd1bc5d2bcae782511"}, 119 | } 120 | for _, test := range tests { 121 | test := test 122 | t.Run(fmt.Sprintf("hash/%v", test.algorithm), func(t *testing.T) { 123 | t.Parallel() 124 | result := hash(test.data, test.algorithm) 125 | assertStringEquals(t, test.expected, result) 126 | }) 127 | } 128 | } 129 | 130 | func TestCompare(t *testing.T) { 131 | t.Parallel() 132 | if compare("foo", "bar") != false { 133 | t.Error("Expected foo != bar") 134 | } 135 | 136 | if compare("foo", "foo") != true { 137 | t.Error("Expected foo == foo") 138 | } 139 | } 140 | 141 | func TestParseDictHeader(t *testing.T) { 142 | t.Parallel() 143 | tests := []struct { 144 | input string 145 | expected map[string]string 146 | }{ 147 | {"foo=bar", map[string]string{"foo": "bar"}}, 148 | 149 | // keys without values get the empty string 150 | {"foo", map[string]string{"foo": ""}}, 151 | {"foo=bar, baz", map[string]string{"foo": "bar", "baz": ""}}, 152 | 153 | // no spaces required 154 | {"foo=bar,baz=quux", map[string]string{"foo": "bar", "baz": "quux"}}, 155 | 156 | // spaces are stripped 157 | {"foo=bar, baz=quux", map[string]string{"foo": "bar", "baz": "quux"}}, 158 | {"foo= bar, baz=quux", map[string]string{"foo": "bar", "baz": "quux"}}, 159 | {"foo=bar, baz = quux", map[string]string{"foo": "bar", "baz": "quux"}}, 160 | {" foo =bar, baz=quux", map[string]string{"foo": "bar", "baz": "quux"}}, 161 | {"foo=bar,baz = quux ", map[string]string{"foo": "bar", "baz": "quux"}}, 162 | 163 | // quotes around values are stripped 164 | {`foo="bar two three four", baz=quux`, map[string]string{"foo": "bar two three four", "baz": "quux"}}, 165 | {`foo=bar, baz=""`, map[string]string{"foo": "bar", "baz": ""}}, 166 | 167 | // quotes around keys are not stripped 168 | {`"foo"="bar", "baz two"=quux`, map[string]string{`"foo"`: "bar", `"baz two"`: "quux"}}, 169 | 170 | // spaces within quotes around values are preserved 171 | {`foo=bar, baz=" quux "`, map[string]string{"foo": "bar", "baz": " quux "}}, 172 | 173 | // commas values are NOT handled correctly 174 | {`foo="one, two, three", baz=quux`, map[string]string{"foo": `"one`, "two": "", `three"`: "", "baz": "quux"}}, 175 | {",,,", make(map[string]string)}, 176 | 177 | // trailing comma is okay 178 | {"foo=bar,", map[string]string{"foo": "bar"}}, 179 | {"foo=bar, ", map[string]string{"foo": "bar"}}, 180 | } 181 | 182 | for _, test := range tests { 183 | test := test 184 | t.Run(test.input, func(t *testing.T) { 185 | t.Parallel() 186 | results := parseDictHeader(test.input) 187 | if !reflect.DeepEqual(test.expected, results) { 188 | t.Errorf("expected %#v, got %#v", test.expected, results) 189 | } 190 | }) 191 | } 192 | } 193 | 194 | func TestParseAuthorizationHeader(t *testing.T) { 195 | t.Parallel() 196 | tests := []struct { 197 | input string 198 | expected *authorization 199 | }{ 200 | {"", nil}, 201 | {"Digest", nil}, 202 | {"Basic QWxhZGRpbjpPcGVuU2VzYW1l", nil}, 203 | 204 | // case sensitive on Digest 205 | {"digest username=u, realm=r, nonce=n", nil}, 206 | 207 | // incomplete headers are fine 208 | {"Digest username=u, realm=r, nonce=n", &authorization{ 209 | algorithm: MD5, 210 | username: "u", 211 | realm: "r", 212 | nonce: "n", 213 | }}, 214 | 215 | // algorithm can be either MD5 or SHA-256, with MD5 as default 216 | {"Digest username=u", &authorization{ 217 | algorithm: MD5, 218 | username: "u", 219 | }}, 220 | {"Digest algorithm=MD5, username=u", &authorization{ 221 | algorithm: MD5, 222 | username: "u", 223 | }}, 224 | {"Digest algorithm=md5, username=u", &authorization{ 225 | algorithm: MD5, 226 | username: "u", 227 | }}, 228 | {"Digest algorithm=SHA-256, username=u", &authorization{ 229 | algorithm: SHA256, 230 | username: "u", 231 | }}, 232 | {"Digest algorithm=foo, username=u", &authorization{ 233 | algorithm: MD5, 234 | username: "u", 235 | }}, 236 | {"Digest algorithm=SHA-512, username=u", &authorization{ 237 | algorithm: MD5, 238 | username: "u", 239 | }}, 240 | // algorithm not case sensitive 241 | {"Digest algorithm=sha-256, username=u", &authorization{ 242 | algorithm: SHA256, 243 | username: "u", 244 | }}, 245 | // but dash is required in SHA-256 is not recognized 246 | {"Digest algorithm=SHA256, username=u", &authorization{ 247 | algorithm: MD5, 248 | username: "u", 249 | }}, 250 | // session variants not recognized 251 | {"Digest algorithm=SHA-256-sess, username=u", &authorization{ 252 | algorithm: MD5, 253 | username: "u", 254 | }}, 255 | {"Digest algorithm=MD5-sess, username=u", &authorization{ 256 | algorithm: MD5, 257 | username: "u", 258 | }}, 259 | 260 | {exampleAuthorization, &authorization{ 261 | algorithm: MD5, 262 | cnonce: "0a4f113b", 263 | nc: "00000001", 264 | nonce: "dcd98b7102dd2f0e8b11d0f600bfb0c093", 265 | opaque: "5ccc069c403ebaf9f0171e9517f40e41", 266 | qop: "auth", 267 | realm: "testrealm@host.com", 268 | response: "6629fae49393a05397450978507c4ef1", 269 | uri: "/dir/index.html", 270 | username: exampleUsername, 271 | }}, 272 | } 273 | 274 | for i, test := range tests { 275 | test := test 276 | t.Run(fmt.Sprintf("%d", i), func(t *testing.T) { 277 | t.Parallel() 278 | got := parseAuthorizationHeader(test.input) 279 | if !reflect.DeepEqual(test.expected, got) { 280 | t.Errorf("expected %#v, got %#v", test.expected, got) 281 | } 282 | }) 283 | } 284 | } 285 | -------------------------------------------------------------------------------- /httpbin/doc.go: -------------------------------------------------------------------------------- 1 | // Package httpbin provides a simple HTTP request and response testing server, 2 | // modeled on the original httpbin.org Python project. 3 | package httpbin 4 | -------------------------------------------------------------------------------- /httpbin/example_test.go: -------------------------------------------------------------------------------- 1 | package httpbin_test 2 | 3 | import ( 4 | "net/http" 5 | "net/http/httptest" 6 | "os" 7 | "testing" 8 | "time" 9 | 10 | "github.com/mccutchen/go-httpbin/v2/httpbin" 11 | ) 12 | 13 | func TestSlowResponse(t *testing.T) { 14 | app := httpbin.New() 15 | testServer := httptest.NewServer(app) 16 | defer testServer.Close() 17 | 18 | client := http.Client{ 19 | Timeout: time.Duration(1 * time.Second), 20 | } 21 | 22 | _, err := client.Get(testServer.URL + "/delay/10") 23 | if !os.IsTimeout(err) { 24 | t.Fatalf("expected timeout error, got %s", err) 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /httpbin/helpers.go: -------------------------------------------------------------------------------- 1 | package httpbin 2 | 3 | import ( 4 | "bytes" 5 | crypto_rand "crypto/rand" 6 | "crypto/sha1" 7 | "encoding/base64" 8 | "encoding/json" 9 | "errors" 10 | "fmt" 11 | "io" 12 | "math/rand" 13 | "mime" 14 | "mime/multipart" 15 | "net/http" 16 | "net/url" 17 | "regexp" 18 | "strconv" 19 | "strings" 20 | "sync" 21 | "time" 22 | ) 23 | 24 | // requestHeaders takes in incoming request and returns an http.Header map 25 | // suitable for inclusion in our response data structures. 26 | // 27 | // This is necessary to ensure that the incoming Host and Transfer-Encoding 28 | // headers are included, because golang only exposes those values on the 29 | // http.Request struct itself. 30 | func getRequestHeaders(r *http.Request, fn headersProcessorFunc) http.Header { 31 | h := r.Header 32 | h.Set("Host", r.Host) 33 | if len(r.TransferEncoding) > 0 { 34 | h.Set("Transfer-Encoding", strings.Join(r.TransferEncoding, ",")) 35 | } 36 | if fn != nil { 37 | return fn(h) 38 | } 39 | return h 40 | } 41 | 42 | // getClientIP tries to get a reasonable value for the IP address of the 43 | // client making the request. Note that this value will likely be trivial to 44 | // spoof, so do not rely on it for security purposes. 45 | func getClientIP(r *http.Request) string { 46 | // Special case some hosting platforms that provide the value directly. 47 | if clientIP := r.Header.Get("Fly-Client-IP"); clientIP != "" { 48 | return clientIP 49 | } 50 | if clientIP := r.Header.Get("CF-Connecting-IP"); clientIP != "" { 51 | return clientIP 52 | } 53 | if clientIP := r.Header.Get("Fastly-Client-IP"); clientIP != "" { 54 | return clientIP 55 | } 56 | if clientIP := r.Header.Get("True-Client-IP"); clientIP != "" { 57 | return clientIP 58 | } 59 | 60 | // Try to pull a reasonable value from the X-Forwarded-For header, if 61 | // present, by taking the first entry in a comma-separated list of IPs. 62 | if forwardedFor := r.Header.Get("X-Forwarded-For"); forwardedFor != "" { 63 | return strings.TrimSpace(strings.SplitN(forwardedFor, ",", 2)[0]) 64 | } 65 | 66 | // Finally, fall back on the actual remote addr from the request. 67 | return r.RemoteAddr 68 | } 69 | 70 | func getURL(r *http.Request) *url.URL { 71 | scheme := r.Header.Get("X-Forwarded-Proto") 72 | if scheme == "" { 73 | scheme = r.Header.Get("X-Forwarded-Protocol") 74 | } 75 | if scheme == "" && r.Header.Get("X-Forwarded-Ssl") == "on" { 76 | scheme = "https" 77 | } 78 | if scheme == "" && r.TLS != nil { 79 | scheme = "https" 80 | } 81 | if scheme == "" { 82 | scheme = "http" 83 | } 84 | 85 | host := r.URL.Host 86 | if host == "" { 87 | host = r.Host 88 | } 89 | 90 | return &url.URL{ 91 | Scheme: scheme, 92 | Opaque: r.URL.Opaque, 93 | User: r.URL.User, 94 | Host: host, 95 | Path: r.URL.Path, 96 | RawPath: r.URL.RawPath, 97 | ForceQuery: r.URL.ForceQuery, 98 | RawQuery: r.URL.RawQuery, 99 | Fragment: r.URL.Fragment, 100 | } 101 | } 102 | 103 | func writeResponse(w http.ResponseWriter, status int, contentType string, body []byte) { 104 | w.Header().Set("Content-Type", contentType) 105 | w.WriteHeader(status) 106 | w.Write(body) 107 | } 108 | 109 | func mustMarshalJSON(w io.Writer, val interface{}) { 110 | encoder := json.NewEncoder(w) 111 | encoder.SetEscapeHTML(false) 112 | encoder.SetIndent("", " ") 113 | if err := encoder.Encode(val); err != nil { 114 | panic(err.Error()) 115 | } 116 | } 117 | 118 | func writeJSON(status int, w http.ResponseWriter, val interface{}) { 119 | w.Header().Set("Content-Type", jsonContentType) 120 | w.WriteHeader(status) 121 | mustMarshalJSON(w, val) 122 | } 123 | 124 | func writeHTML(w http.ResponseWriter, body []byte, status int) { 125 | writeResponse(w, status, htmlContentType, body) 126 | } 127 | 128 | func writeError(w http.ResponseWriter, code int, err error) { 129 | resp := errorRespnose{ 130 | Error: http.StatusText(code), 131 | StatusCode: code, 132 | } 133 | if err != nil { 134 | resp.Detail = err.Error() 135 | } 136 | writeJSON(code, w, resp) 137 | } 138 | 139 | // parseFiles handles reading the contents of files in a multipart FileHeader 140 | // and returning a map that can be used as the Files attribute of a response 141 | func parseFiles(fileHeaders map[string][]*multipart.FileHeader) (map[string][]string, error) { 142 | files := map[string][]string{} 143 | for k, fs := range fileHeaders { 144 | files[k] = []string{} 145 | 146 | for _, f := range fs { 147 | fh, err := f.Open() 148 | if err != nil { 149 | return nil, err 150 | } 151 | contents, err := io.ReadAll(fh) 152 | if err != nil { 153 | return nil, err 154 | } 155 | files[k] = append(files[k], string(contents)) 156 | } 157 | } 158 | return files, nil 159 | } 160 | 161 | // parseBody handles parsing a request body into our standard API response, 162 | // taking care to only consume the request body once based on the Content-Type 163 | // of the request. The given bodyResponse will be modified. 164 | // 165 | // Note: this function expects callers to limit the the maximum size of the 166 | // request body. See, e.g., the limitRequestSize middleware. 167 | func parseBody(r *http.Request, resp *bodyResponse) error { 168 | defer r.Body.Close() 169 | 170 | // Always set resp.Data to the incoming request body, in case we don't know 171 | // how to handle the content type 172 | body, err := io.ReadAll(r.Body) 173 | if err != nil { 174 | return err 175 | } 176 | 177 | // After reading the body to populate resp.Data, we need to re-wrap it in 178 | // an io.Reader for further processing below 179 | r.Body = io.NopCloser(bytes.NewBuffer(body)) 180 | 181 | // if we read an empty body, there's no need to do anything further 182 | if len(body) == 0 { 183 | return nil 184 | } 185 | 186 | // Always store the "raw" incoming request body 187 | resp.Data = string(body) 188 | 189 | contentType, _, _ := strings.Cut(r.Header.Get("Content-Type"), ";") 190 | 191 | switch contentType { 192 | case "text/html", "text/plain": 193 | // no need for extra parsing, string body is already set above 194 | return nil 195 | 196 | case "application/x-www-form-urlencoded": 197 | // r.ParseForm() does not populate r.PostForm for DELETE or GET 198 | // requests, but we need it to for compatibility with the httpbin 199 | // implementation, so we trick it with this ugly hack. 200 | if r.Method == http.MethodDelete || r.Method == http.MethodGet { 201 | originalMethod := r.Method 202 | r.Method = http.MethodPost 203 | defer func() { r.Method = originalMethod }() 204 | } 205 | if err := r.ParseForm(); err != nil { 206 | return err 207 | } 208 | resp.Form = r.PostForm 209 | 210 | case "multipart/form-data": 211 | // The memory limit here only restricts how many parts will be kept in 212 | // memory before overflowing to disk: 213 | // https://golang.org/pkg/net/http/#Request.ParseMultipartForm 214 | if err := r.ParseMultipartForm(1024); err != nil { 215 | return err 216 | } 217 | resp.Form = r.PostForm 218 | files, err := parseFiles(r.MultipartForm.File) 219 | if err != nil { 220 | return err 221 | } 222 | resp.Files = files 223 | 224 | case "application/json": 225 | if err := json.NewDecoder(r.Body).Decode(&resp.JSON); err != nil { 226 | return err 227 | } 228 | 229 | default: 230 | // If we don't have a special case for the content type, return it 231 | // encoded as base64 data url 232 | resp.Data = encodeData(body, contentType) 233 | } 234 | 235 | return nil 236 | } 237 | 238 | // return provided string as base64 encoded data url, with the given content type 239 | func encodeData(body []byte, contentType string) string { 240 | // If no content type is provided, default to application/octet-stream 241 | if contentType == "" { 242 | contentType = binaryContentType 243 | } 244 | data := base64.URLEncoding.EncodeToString(body) 245 | return string("data:" + contentType + ";base64," + data) 246 | } 247 | 248 | func parseStatusCode(input string) (int, error) { 249 | return parseBoundedStatusCode(input, 100, 599) 250 | } 251 | 252 | func parseBoundedStatusCode(input string, minVal, maxVal int) (int, error) { 253 | code, err := strconv.Atoi(input) 254 | if err != nil { 255 | return 0, fmt.Errorf("invalid status code: %q: %w", input, err) 256 | } 257 | if code < minVal || code > maxVal { 258 | return 0, fmt.Errorf("invalid status code: %d not in range [%d, %d]", code, minVal, maxVal) 259 | } 260 | return code, nil 261 | } 262 | 263 | // parseDuration takes a user's input as a string and attempts to convert it 264 | // into a time.Duration. If not given as a go-style duration string, the input 265 | // is assumed to be seconds as a float. 266 | func parseDuration(input string) (time.Duration, error) { 267 | d, err := time.ParseDuration(input) 268 | if err != nil { 269 | n, err := strconv.ParseFloat(input, 64) 270 | if err != nil { 271 | return 0, err 272 | } 273 | d = time.Duration(n*1000) * time.Millisecond 274 | } 275 | return d, nil 276 | } 277 | 278 | // parseBoundedDuration parses a time.Duration from user input and ensures that 279 | // it is within a given maximum and minimum time 280 | func parseBoundedDuration(input string, minVal, maxVal time.Duration) (time.Duration, error) { 281 | d, err := parseDuration(input) 282 | if err != nil { 283 | return 0, err 284 | } 285 | 286 | if d > maxVal { 287 | err = fmt.Errorf("duration %s longer than %s", d, maxVal) 288 | } else if d < minVal { 289 | err = fmt.Errorf("duration %s shorter than %s", d, minVal) 290 | } 291 | return d, err 292 | } 293 | 294 | // Returns a new rand.Rand from the given seed string. 295 | func parseSeed(rawSeed string) (*rand.Rand, error) { 296 | var seed int64 297 | if rawSeed != "" { 298 | var err error 299 | seed, err = strconv.ParseInt(rawSeed, 10, 64) 300 | if err != nil { 301 | return nil, err 302 | } 303 | } else { 304 | seed = time.Now().UnixNano() 305 | } 306 | 307 | src := rand.NewSource(seed) 308 | rng := rand.New(src) 309 | return rng, nil 310 | } 311 | 312 | // syntheticByteStream implements the ReadSeeker interface to allow reading 313 | // arbitrary subsets of bytes up to a maximum size given a function for 314 | // generating the byte at a given offset. 315 | type syntheticByteStream struct { 316 | mu sync.Mutex 317 | 318 | size int64 319 | factory func(int64) byte 320 | pausePerByte time.Duration 321 | 322 | // internal offset for tracking the current position in the stream 323 | offset int64 324 | } 325 | 326 | // newSyntheticByteStream returns a new stream of bytes of a specific size, 327 | // given a factory function for generating the byte at a given offset. 328 | func newSyntheticByteStream(size int64, duration time.Duration, factory func(int64) byte) io.ReadSeeker { 329 | return &syntheticByteStream{ 330 | size: size, 331 | pausePerByte: duration / time.Duration(size), 332 | factory: factory, 333 | } 334 | } 335 | 336 | // Read implements the Reader interface for syntheticByteStream 337 | func (s *syntheticByteStream) Read(p []byte) (int, error) { 338 | s.mu.Lock() 339 | defer s.mu.Unlock() 340 | 341 | start := s.offset 342 | end := start + int64(len(p)) 343 | var err error 344 | if end >= s.size { 345 | err = io.EOF 346 | end = s.size 347 | } 348 | 349 | for idx := start; idx < end; idx++ { 350 | p[idx-start] = s.factory(idx) 351 | } 352 | s.offset = end 353 | 354 | if s.pausePerByte > 0 { 355 | time.Sleep(s.pausePerByte * time.Duration(end-start)) 356 | } 357 | 358 | return int(end - start), err 359 | } 360 | 361 | // Seek implements the Seeker interface for syntheticByteStream 362 | func (s *syntheticByteStream) Seek(offset int64, whence int) (int64, error) { 363 | s.mu.Lock() 364 | defer s.mu.Unlock() 365 | 366 | switch whence { 367 | case io.SeekStart: 368 | s.offset = offset 369 | case io.SeekCurrent: 370 | s.offset += offset 371 | case io.SeekEnd: 372 | s.offset = s.size - offset 373 | default: 374 | return 0, errors.New("Seek: invalid whence") 375 | } 376 | 377 | if s.offset < 0 { 378 | return 0, errors.New("Seek: invalid offset") 379 | } 380 | 381 | return s.offset, nil 382 | } 383 | 384 | func sha1hash(input string) string { 385 | h := sha1.New() 386 | return fmt.Sprintf("%x", h.Sum([]byte(input))) 387 | } 388 | 389 | func uuidv4() string { 390 | buff := make([]byte, 16) 391 | if _, err := crypto_rand.Read(buff[:]); err != nil { 392 | panic(err) 393 | } 394 | buff[6] = (buff[6] & 0x0f) | 0x40 // Version 4 395 | buff[8] = (buff[8] & 0x3f) | 0x80 // Variant 10 396 | return fmt.Sprintf("%x-%x-%x-%x-%x", buff[0:4], buff[4:6], buff[6:8], buff[8:10], buff[10:]) 397 | } 398 | 399 | // base64Helper encapsulates a base64 operation (encode or decode) and its input 400 | // data. 401 | type base64Helper struct { 402 | maxLen int64 403 | operation string 404 | data string 405 | } 406 | 407 | // newBase64Helper creates a new base64Helper from a URL path, which should be 408 | // in one of two forms: 409 | // - /base64/ 410 | // - /base64// 411 | func newBase64Helper(r *http.Request, maxLen int64) *base64Helper { 412 | b := &base64Helper{ 413 | operation: r.PathValue("operation"), 414 | data: r.PathValue("data"), 415 | maxLen: maxLen, 416 | } 417 | if b.operation == "" { 418 | b.operation = "decode" 419 | } 420 | return b 421 | } 422 | 423 | // transform performs the base64 operation on the input data. 424 | func (b *base64Helper) transform() ([]byte, error) { 425 | if dataLen := int64(len(b.data)); dataLen == 0 { 426 | return nil, errors.New("no input data") 427 | } else if dataLen > b.maxLen { 428 | return nil, fmt.Errorf("input data exceeds max length of %d", b.maxLen) 429 | } 430 | 431 | switch b.operation { 432 | case "encode": 433 | return b.encode(), nil 434 | case "decode": 435 | result, err := b.decode() 436 | if err != nil { 437 | return nil, fmt.Errorf("base64 decode failed: %w", err) 438 | } 439 | return result, nil 440 | default: 441 | return nil, fmt.Errorf("invalid operation: %s", b.operation) 442 | } 443 | } 444 | 445 | func (b *base64Helper) encode() []byte { 446 | // always encode using the URL-safe character set 447 | buff := make([]byte, base64.URLEncoding.EncodedLen(len(b.data))) 448 | base64.URLEncoding.Encode(buff, []byte(b.data)) 449 | return buff 450 | } 451 | 452 | func (b *base64Helper) decode() ([]byte, error) { 453 | // first, try URL-safe encoding, then std encoding 454 | if result, err := base64.URLEncoding.DecodeString(b.data); err == nil { 455 | return result, nil 456 | } 457 | return base64.StdEncoding.DecodeString(b.data) 458 | } 459 | 460 | func wildCardToRegexp(pattern string) string { 461 | components := strings.Split(pattern, "*") 462 | if len(components) == 1 { 463 | // if len is 1, there are no *'s, return exact match pattern 464 | return "^" + pattern + "$" 465 | } 466 | var result strings.Builder 467 | for i, literal := range components { 468 | 469 | // Replace * with .* 470 | if i > 0 { 471 | result.WriteString(".*") 472 | } 473 | 474 | // Quote any regular expression meta characters in the 475 | // literal text. 476 | result.WriteString(regexp.QuoteMeta(literal)) 477 | } 478 | return "^" + result.String() + "$" 479 | } 480 | 481 | func createExcludeHeadersProcessor(excludeRegex *regexp.Regexp) headersProcessorFunc { 482 | return func(headers http.Header) http.Header { 483 | result := make(http.Header) 484 | for k, v := range headers { 485 | matched := excludeRegex.Match([]byte(k)) 486 | if matched { 487 | continue 488 | } 489 | result[k] = v 490 | } 491 | 492 | return result 493 | } 494 | } 495 | 496 | func createFullExcludeRegex(excludeHeaders string) *regexp.Regexp { 497 | // comma separated list of headers to exclude from response 498 | tmp := strings.Split(excludeHeaders, ",") 499 | 500 | tmpRegexStrings := make([]string, 0) 501 | for _, v := range tmp { 502 | s := strings.TrimSpace(v) 503 | if len(s) == 0 { 504 | continue 505 | } 506 | pattern := wildCardToRegexp(s) 507 | tmpRegexStrings = append(tmpRegexStrings, pattern) 508 | } 509 | 510 | if len(tmpRegexStrings) > 0 { 511 | tmpRegexStr := strings.Join(tmpRegexStrings, "|") 512 | result := regexp.MustCompile("(?i)" + "(" + tmpRegexStr + ")") 513 | return result 514 | } 515 | 516 | return nil 517 | } 518 | 519 | // weightedChoice represents a choice with its associated weight. 520 | type weightedChoice[T any] struct { 521 | Choice T 522 | Weight float64 523 | } 524 | 525 | // parseWeighteChoices parses a comma-separated list of choices in 526 | // choice:weight format, where weight is an optional floating point number. 527 | func parseWeightedChoices[T any](rawChoices string, parser func(string) (T, error)) ([]weightedChoice[T], error) { 528 | if rawChoices == "" { 529 | return nil, nil 530 | } 531 | 532 | var ( 533 | choicePairs = strings.Split(rawChoices, ",") 534 | choices = make([]weightedChoice[T], 0, len(choicePairs)) 535 | err error 536 | ) 537 | for _, choicePair := range choicePairs { 538 | weight := 1.0 539 | rawChoice, rawWeight, found := strings.Cut(choicePair, ":") 540 | if found { 541 | weight, err = strconv.ParseFloat(rawWeight, 64) 542 | if err != nil { 543 | return nil, fmt.Errorf("invalid weight value: %q", rawWeight) 544 | } 545 | } 546 | choice, err := parser(rawChoice) 547 | if err != nil { 548 | return nil, fmt.Errorf("invalid choice value: %q", rawChoice) 549 | } 550 | choices = append(choices, weightedChoice[T]{Choice: choice, Weight: weight}) 551 | } 552 | return choices, nil 553 | } 554 | 555 | // weightedRandomChoice returns a randomly chosen element from the weighted 556 | // choices, given as a slice of "choice:weight" strings where weight is a 557 | // floating point number. Weights do not need to sum to 1. 558 | func weightedRandomChoice[T any](choices []weightedChoice[T]) T { 559 | // Calculate total weight 560 | var totalWeight float64 561 | for _, wc := range choices { 562 | totalWeight += wc.Weight 563 | } 564 | randomNumber := rand.Float64() * totalWeight 565 | currentWeight := 0.0 566 | for _, wc := range choices { 567 | currentWeight += wc.Weight 568 | if randomNumber < currentWeight { 569 | return wc.Choice 570 | } 571 | } 572 | panic("failed to select a weighted random choice") 573 | } 574 | 575 | // Server-Timing header/trailer helpers. See MDN docs for reference: 576 | // https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Server-Timing 577 | type serverTiming struct { 578 | name string 579 | dur time.Duration 580 | desc string 581 | } 582 | 583 | func encodeServerTimings(timings []serverTiming) string { 584 | entries := make([]string, len(timings)) 585 | for i, t := range timings { 586 | ms := t.dur.Seconds() * 1e3 587 | entries[i] = fmt.Sprintf("%s;dur=%0.2f;desc=\"%s\"", t.name, ms, t.desc) 588 | } 589 | return strings.Join(entries, ", ") 590 | } 591 | 592 | // The following content types are considered safe enough to skip HTML-escaping 593 | // response bodies. 594 | // 595 | // See [1] for an example of the wide variety of unsafe content types, which 596 | // varies by browser vendor and could change in the future. 597 | // 598 | // [1]: https://github.com/BlackFan/content-type-research/blob/4e4347254/XSS.md 599 | var safeContentTypes = map[string]bool{ 600 | "text/plain": true, 601 | "application/json": true, 602 | "application/octet-string": true, 603 | } 604 | 605 | // isDangerousContentType determines whether the given Content-Type header 606 | // value could be unsafe (e.g. at risk of XSS) when rendered by a web browser. 607 | func isDangerousContentType(ct string) bool { 608 | mediatype, _, err := mime.ParseMediaType(ct) 609 | if err != nil { 610 | return true 611 | } 612 | return !safeContentTypes[mediatype] 613 | } 614 | -------------------------------------------------------------------------------- /httpbin/helpers_test.go: -------------------------------------------------------------------------------- 1 | package httpbin 2 | 3 | import ( 4 | "crypto/tls" 5 | "errors" 6 | "fmt" 7 | "io" 8 | "io/fs" 9 | "mime/multipart" 10 | "net/http" 11 | "net/url" 12 | "regexp" 13 | "strconv" 14 | "strings" 15 | "testing" 16 | "time" 17 | 18 | "github.com/mccutchen/go-httpbin/v2/internal/testing/assert" 19 | ) 20 | 21 | func mustParse(s string) *url.URL { 22 | u, e := url.Parse(s) 23 | if e != nil { 24 | panic(e) 25 | } 26 | return u 27 | } 28 | 29 | func TestGetURL(t *testing.T) { 30 | baseURL := mustParse("http://example.com/something?foo=bar") 31 | tests := []struct { 32 | name string 33 | input *http.Request 34 | expected *url.URL 35 | }{ 36 | { 37 | "basic test", 38 | &http.Request{ 39 | URL: baseURL, 40 | Header: http.Header{}, 41 | }, 42 | mustParse("http://example.com/something?foo=bar"), 43 | }, 44 | { 45 | "if TLS is not nil, scheme is https", 46 | &http.Request{ 47 | URL: baseURL, 48 | TLS: &tls.ConnectionState{}, 49 | Header: http.Header{}, 50 | }, 51 | mustParse("https://example.com/something?foo=bar"), 52 | }, 53 | { 54 | "if X-Forwarded-Proto is present, scheme is that value", 55 | &http.Request{ 56 | URL: baseURL, 57 | Header: http.Header{"X-Forwarded-Proto": {"https"}}, 58 | }, 59 | mustParse("https://example.com/something?foo=bar"), 60 | }, 61 | { 62 | "if X-Forwarded-Proto is present, scheme is that value (2)", 63 | &http.Request{ 64 | URL: baseURL, 65 | Header: http.Header{"X-Forwarded-Proto": {"bananas"}}, 66 | }, 67 | mustParse("bananas://example.com/something?foo=bar"), 68 | }, 69 | { 70 | "if X-Forwarded-Ssl is 'on', scheme is https", 71 | &http.Request{ 72 | URL: baseURL, 73 | Header: http.Header{"X-Forwarded-Ssl": {"on"}}, 74 | }, 75 | mustParse("https://example.com/something?foo=bar"), 76 | }, 77 | { 78 | "if request URL host is empty, host is request.host", 79 | &http.Request{ 80 | URL: mustParse("http:///just/a/path"), 81 | Host: "zombo.com", 82 | }, 83 | mustParse("http://zombo.com/just/a/path"), 84 | }, 85 | } 86 | 87 | for _, test := range tests { 88 | t.Run(test.name, func(t *testing.T) { 89 | res := getURL(test.input) 90 | assert.Equal(t, res.String(), test.expected.String(), "URL mismatch") 91 | }) 92 | } 93 | } 94 | 95 | func TestParseDuration(t *testing.T) { 96 | okTests := []struct { 97 | input string 98 | expected time.Duration 99 | }{ 100 | // go-style durations 101 | {"1s", time.Second}, 102 | {"500ms", 500 * time.Millisecond}, 103 | {"1.5h", 90 * time.Minute}, 104 | {"-10m", -10 * time.Minute}, 105 | 106 | // or floating point seconds 107 | {"1", time.Second}, 108 | {"0.25", 250 * time.Millisecond}, 109 | {"-25", -25 * time.Second}, 110 | {"-2.5", -2500 * time.Millisecond}, 111 | } 112 | for _, test := range okTests { 113 | test := test 114 | t.Run(fmt.Sprintf("ok/%s", test.input), func(t *testing.T) { 115 | t.Parallel() 116 | result, err := parseDuration(test.input) 117 | assert.NilError(t, err) 118 | assert.Equal(t, result, test.expected, "incorrect duration") 119 | }) 120 | } 121 | 122 | badTests := []struct { 123 | input string 124 | }{ 125 | {"foo"}, 126 | {"100foo"}, 127 | {"1/1"}, 128 | {"1.5.foo"}, 129 | {"0xFF"}, 130 | } 131 | for _, test := range badTests { 132 | test := test 133 | t.Run(fmt.Sprintf("bad/%s", test.input), func(t *testing.T) { 134 | t.Parallel() 135 | _, err := parseDuration(test.input) 136 | if err == nil { 137 | t.Fatalf("expected error parsing %v", test.input) 138 | } 139 | }) 140 | } 141 | } 142 | 143 | func TestSyntheticByteStream(t *testing.T) { 144 | t.Parallel() 145 | factory := func(offset int64) byte { 146 | return byte(offset) 147 | } 148 | 149 | t.Run("read", func(t *testing.T) { 150 | t.Parallel() 151 | s := newSyntheticByteStream(10, 0, factory) 152 | 153 | // read first half 154 | { 155 | p := make([]byte, 5) 156 | count, err := s.Read(p) 157 | assert.NilError(t, err) 158 | assert.Equal(t, count, 5, "incorrect number of bytes read") 159 | assert.DeepEqual(t, p, []byte{0, 1, 2, 3, 4}, "incorrect bytes read") 160 | } 161 | 162 | // read second half 163 | { 164 | p := make([]byte, 5) 165 | count, err := s.Read(p) 166 | assert.Error(t, err, io.EOF) 167 | assert.Equal(t, count, 5, "incorrect number of bytes read") 168 | assert.DeepEqual(t, p, []byte{5, 6, 7, 8, 9}, "incorrect bytes read") 169 | } 170 | 171 | // can't read any more 172 | { 173 | p := make([]byte, 5) 174 | count, err := s.Read(p) 175 | assert.Error(t, err, io.EOF) 176 | assert.Equal(t, count, 0, "incorrect number of bytes read") 177 | assert.DeepEqual(t, p, []byte{0, 0, 0, 0, 0}, "incorrect bytes read") 178 | } 179 | }) 180 | 181 | t.Run("read into too-large buffer", func(t *testing.T) { 182 | t.Parallel() 183 | s := newSyntheticByteStream(5, 0, factory) 184 | p := make([]byte, 10) 185 | count, err := s.Read(p) 186 | assert.Error(t, err, io.EOF) 187 | assert.Equal(t, count, 5, "incorrect number of bytes read") 188 | assert.DeepEqual(t, p, []byte{0, 1, 2, 3, 4, 0, 0, 0, 0, 0}, "incorrect bytes read") 189 | }) 190 | 191 | t.Run("seek", func(t *testing.T) { 192 | t.Parallel() 193 | s := newSyntheticByteStream(100, 0, factory) 194 | 195 | p := make([]byte, 5) 196 | s.Seek(10, io.SeekStart) 197 | count, err := s.Read(p) 198 | assert.NilError(t, err) 199 | assert.Equal(t, count, 5, "incorrect number of bytes read") 200 | assert.DeepEqual(t, p, []byte{10, 11, 12, 13, 14}, "incorrect bytes read") 201 | 202 | s.Seek(10, io.SeekCurrent) 203 | count, err = s.Read(p) 204 | assert.NilError(t, err) 205 | assert.Equal(t, count, 5, "incorrect number of bytes read") 206 | assert.DeepEqual(t, p, []byte{25, 26, 27, 28, 29}, "incorrect bytes read") 207 | 208 | s.Seek(10, io.SeekEnd) 209 | count, err = s.Read(p) 210 | assert.NilError(t, err) 211 | assert.Equal(t, count, 5, "incorrect number of bytes read") 212 | assert.DeepEqual(t, p, []byte{90, 91, 92, 93, 94}, "incorrect bytes read") 213 | 214 | _, err = s.Seek(10, 666) 215 | assert.Equal(t, err.Error(), "Seek: invalid whence", "incorrect error for invalid whence") 216 | 217 | _, err = s.Seek(-10, io.SeekStart) 218 | assert.Equal(t, err.Error(), "Seek: invalid offset", "incorrect error for invalid offset") 219 | }) 220 | 221 | t.Run("read over duration", func(t *testing.T) { 222 | t.Parallel() 223 | s := newSyntheticByteStream(10, 200*time.Millisecond, factory) 224 | 225 | // read first half 226 | { 227 | p := make([]byte, 5) 228 | start := time.Now() 229 | count, err := s.Read(p) 230 | elapsed := time.Since(start) 231 | 232 | assert.NilError(t, err) 233 | assert.Equal(t, count, 5, "incorrect number of bytes read") 234 | assert.DeepEqual(t, p, []byte{0, 1, 2, 3, 4}, "incorrect bytes read") 235 | assert.DurationRange(t, elapsed, 100*time.Millisecond, 175*time.Millisecond) 236 | } 237 | 238 | // read second half 239 | { 240 | p := make([]byte, 5) 241 | start := time.Now() 242 | count, err := s.Read(p) 243 | elapsed := time.Since(start) 244 | 245 | assert.Error(t, err, io.EOF) 246 | assert.Equal(t, count, 5, "incorrect number of bytes read") 247 | assert.DeepEqual(t, p, []byte{5, 6, 7, 8, 9}, "incorrect bytes read") 248 | assert.DurationRange(t, elapsed, 100*time.Millisecond, 175*time.Millisecond) 249 | } 250 | 251 | // can't read any more 252 | { 253 | p := make([]byte, 5) 254 | start := time.Now() 255 | count, err := s.Read(p) 256 | elapsed := time.Since(start) 257 | 258 | assert.Error(t, err, io.EOF) 259 | assert.Equal(t, count, 0, "incorrect number of bytes read") 260 | assert.DeepEqual(t, p, []byte{0, 0, 0, 0, 0}, "incorrect bytes read") 261 | 262 | // read should fail w/ EOF ~immediately 263 | assert.DurationRange(t, elapsed, 0, 25*time.Millisecond) 264 | } 265 | }) 266 | } 267 | 268 | func TestGetClientIP(t *testing.T) { 269 | t.Parallel() 270 | 271 | makeHeaders := func(m map[string]string) http.Header { 272 | h := make(http.Header, len(m)) 273 | for k, v := range m { 274 | h.Set(k, v) 275 | } 276 | return h 277 | } 278 | 279 | testCases := map[string]struct { 280 | given *http.Request 281 | want string 282 | }{ 283 | "custom platform headers take precedence": { 284 | given: &http.Request{ 285 | Header: makeHeaders(map[string]string{ 286 | "Fly-Client-IP": "9.9.9.9", 287 | "X-Forwarded-For": "1.1.1.1,2.2.2.2,3.3.3.3", 288 | }), 289 | RemoteAddr: "0.0.0.0", 290 | }, 291 | want: "9.9.9.9", 292 | }, 293 | "custom cloudflare header take precedence": { 294 | given: &http.Request{ 295 | Header: makeHeaders(map[string]string{ 296 | "CF-Connecting-IP": "9.9.9.9", 297 | "X-Forwarded-For": "1.1.1.1,2.2.2.2,3.3.3.3", 298 | }), 299 | RemoteAddr: "0.0.0.0", 300 | }, 301 | want: "9.9.9.9", 302 | }, 303 | "custom fastly header take precedence": { 304 | given: &http.Request{ 305 | Header: makeHeaders(map[string]string{ 306 | "Fastly-Client-IP": "9.9.9.9", 307 | "X-Forwarded-For": "1.1.1.1,2.2.2.2,3.3.3.3", 308 | }), 309 | RemoteAddr: "0.0.0.0", 310 | }, 311 | want: "9.9.9.9", 312 | }, 313 | "custom akamai header take precedence": { 314 | given: &http.Request{ 315 | Header: makeHeaders(map[string]string{ 316 | "True-Client-IP": "9.9.9.9", 317 | "X-Forwarded-For": "1.1.1.1,2.2.2.2,3.3.3.3", 318 | }), 319 | RemoteAddr: "0.0.0.0", 320 | }, 321 | want: "9.9.9.9", 322 | }, 323 | "x-forwarded-for is parsed": { 324 | given: &http.Request{ 325 | Header: makeHeaders(map[string]string{ 326 | "X-Forwarded-For": "1.1.1.1,2.2.2.2,3.3.3.3", 327 | }), 328 | RemoteAddr: "0.0.0.0", 329 | }, 330 | want: "1.1.1.1", 331 | }, 332 | "remoteaddr is fallback": { 333 | given: &http.Request{ 334 | RemoteAddr: "0.0.0.0", 335 | }, 336 | want: "0.0.0.0", 337 | }, 338 | } 339 | for name, tc := range testCases { 340 | tc := tc 341 | t.Run(name, func(t *testing.T) { 342 | t.Parallel() 343 | assert.Equal(t, getClientIP(tc.given), tc.want, "incorrect client ip") 344 | }) 345 | } 346 | } 347 | 348 | func TestParseFileDoesntExist(t *testing.T) { 349 | // set up a headers map where the filename doesn't exist, to test `f.Open` 350 | // throwing an error 351 | headers := map[string][]*multipart.FileHeader{ 352 | "fieldname": { 353 | { 354 | Filename: "bananas", 355 | }, 356 | }, 357 | } 358 | 359 | // expect a patherror 360 | _, err := parseFiles(headers) 361 | if _, ok := err.(*fs.PathError); !ok { 362 | t.Fatalf("Open(nonexist): error is %T, want *PathError", err) 363 | } 364 | } 365 | 366 | func TestWildcardHelpers(t *testing.T) { 367 | tests := []struct { 368 | pattern string 369 | name string 370 | input string 371 | expected bool 372 | }{ 373 | { 374 | "info-*", 375 | "basic test", 376 | "info-foo", 377 | true, 378 | }, 379 | { 380 | "info-*", 381 | "basic test case insensitive", 382 | "INFO-bar", 383 | true, 384 | }, 385 | { 386 | "info-*-foo", 387 | "a single wildcard in the middle of the string", 388 | "INFO-bar-foo", 389 | true, 390 | }, 391 | { 392 | "info-*-foo", 393 | "a single wildcard in the middle of the string", 394 | "INFO-bar-baz", 395 | false, 396 | }, 397 | { 398 | "info-*-foo-*-bar", 399 | "multiple wildcards in the string", 400 | "info-aaa-foo--bar", 401 | true, 402 | }, 403 | { 404 | "info-*-foo-*-bar", 405 | "multiple wildcards in the string", 406 | "info-aaa-foo-a-bar", 407 | true, 408 | }, 409 | { 410 | "info-*-foo-*-bar", 411 | "multiple wildcards in the string", 412 | "info-aaa-foo--bar123", 413 | false, 414 | }, 415 | } 416 | 417 | for _, test := range tests { 418 | t.Run(test.name, func(t *testing.T) { 419 | tmpRegexStr := wildCardToRegexp(test.pattern) 420 | regex := regexp.MustCompile("(?i)" + "(" + tmpRegexStr + ")") 421 | matched := regex.Match([]byte(test.input)) 422 | assert.Equal(t, matched, test.expected, "incorrect match") 423 | }) 424 | } 425 | } 426 | 427 | func TestCreateFullExcludeRegex(t *testing.T) { 428 | // tolerate unused comma 429 | excludeHeaders := "x-ignore-*,x-info-this-key,," 430 | regex := createFullExcludeRegex(excludeHeaders) 431 | tests := []struct { 432 | name string 433 | input string 434 | expected bool 435 | }{ 436 | { 437 | "basic test", 438 | "x-ignore-foo", 439 | true, 440 | }, 441 | { 442 | "basic test case insensitive", 443 | "X-IGNORE-bar", 444 | true, 445 | }, 446 | { 447 | "basic test 3", 448 | "x-info-this-key", 449 | true, 450 | }, 451 | { 452 | "basic test 4", 453 | "foo-bar", 454 | false, 455 | }, 456 | { 457 | "basic test 5", 458 | "x-info-this-key-foo", 459 | false, 460 | }, 461 | } 462 | 463 | for _, test := range tests { 464 | t.Run(test.name, func(t *testing.T) { 465 | matched := regex.Match([]byte(test.input)) 466 | assert.Equal(t, matched, test.expected, "incorrect match") 467 | }) 468 | } 469 | 470 | nilReturn := createFullExcludeRegex("") 471 | assert.Equal(t, nilReturn, nil, "incorrect match") 472 | } 473 | 474 | func TestParseWeightedChoices(t *testing.T) { 475 | testCases := []struct { 476 | given string 477 | want []weightedChoice[int] 478 | wantErr error 479 | }{ 480 | { 481 | given: "200:0.5,300:0.3,400:0.1,500:0.1", 482 | want: []weightedChoice[int]{ 483 | {Choice: 200, Weight: 0.5}, 484 | {Choice: 300, Weight: 0.3}, 485 | {Choice: 400, Weight: 0.1}, 486 | {Choice: 500, Weight: 0.1}, 487 | }, 488 | }, 489 | { 490 | given: "", 491 | want: nil, 492 | }, 493 | { 494 | given: "200,300,400", 495 | want: []weightedChoice[int]{ 496 | {Choice: 200, Weight: 1.0}, 497 | {Choice: 300, Weight: 1.0}, 498 | {Choice: 400, Weight: 1.0}, 499 | }, 500 | }, 501 | { 502 | given: "200", 503 | want: []weightedChoice[int]{ 504 | {Choice: 200, Weight: 1.0}, 505 | }, 506 | }, 507 | { 508 | given: "200:10,300,400:0.01", 509 | want: []weightedChoice[int]{ 510 | {Choice: 200, Weight: 10.0}, 511 | {Choice: 300, Weight: 1.0}, 512 | {Choice: 400, Weight: 0.01}, 513 | }, 514 | }, 515 | { 516 | given: "200:10,300,400:0.01", 517 | want: []weightedChoice[int]{ 518 | {Choice: 200, Weight: 10.0}, 519 | {Choice: 300, Weight: 1.0}, 520 | {Choice: 400, Weight: 0.01}, 521 | }, 522 | }, 523 | { 524 | given: "200:,300:1.0", 525 | wantErr: errors.New("invalid weight value: \"\""), 526 | }, 527 | { 528 | given: "200:1.0,300:foo", 529 | wantErr: errors.New("invalid weight value: \"foo\""), 530 | }, 531 | { 532 | given: "A:1.0,200:1.0", 533 | wantErr: errors.New("invalid choice value: \"A\""), 534 | }, 535 | } 536 | 537 | for _, tc := range testCases { 538 | tc := tc 539 | t.Run(tc.given, func(t *testing.T) { 540 | t.Parallel() 541 | got, err := parseWeightedChoices(tc.given, strconv.Atoi) 542 | assert.Error(t, err, tc.wantErr) 543 | assert.DeepEqual(t, got, tc.want, "incorrect weighted choices") 544 | }) 545 | } 546 | } 547 | 548 | func TestWeightedRandomChoice(t *testing.T) { 549 | iters := 1_000 550 | testCases := []string{ 551 | // weights sum to 1 552 | "A:0.5,B:0.3,C:0.1,D:0.1", 553 | // weights sum to 1 but are out of order 554 | "A:0.2,B:0.5,C:0.3", 555 | // weights do not sum to 1 556 | "A:5,B:1,C:0.5", 557 | // weights do not sum to 1 and are out of order 558 | "A:0.5,B:5,C:1", 559 | // one choice 560 | "A:1", 561 | } 562 | 563 | for _, tc := range testCases { 564 | tc := tc 565 | t.Run(tc, func(t *testing.T) { 566 | t.Parallel() 567 | choices, err := parseWeightedChoices(tc, func(s string) (string, error) { return s, nil }) 568 | assert.NilError(t, err) 569 | 570 | normalizedChoices := normalizeChoices(choices) 571 | t.Logf("given choices: %q", tc) 572 | t.Logf("parsed choices: %v", choices) 573 | t.Logf("normalized choices: %v", normalizedChoices) 574 | 575 | result := make(map[string]int, len(choices)) 576 | for i := 0; i < 1_000; i++ { 577 | choice := weightedRandomChoice(choices) 578 | result[choice]++ 579 | } 580 | 581 | for _, choice := range normalizedChoices { 582 | count := result[choice.Choice] 583 | ratio := float64(count) / float64(iters) 584 | assert.RoughlyEqual(t, ratio, choice.Weight, 0.05) 585 | } 586 | }) 587 | } 588 | } 589 | 590 | func TestIsDangerousContentType(t *testing.T) { 591 | testCases := []struct { 592 | contentType string 593 | dangerous bool 594 | }{ 595 | // We only cosider a handful of content types "safe", everything else 596 | // is considered dangerous by default. 597 | {"application/json", false}, 598 | {"application/octet-string", false}, 599 | {"text/plain", false}, 600 | 601 | // Content-Types that can be used for XSS, via: 602 | // https://github.com/BlackFan/content-type-research/blob/4e43747254XSS.md#content-type-that-can-be-used-for-xss 603 | {"application/mathml+xml", true}, 604 | {"application/rdf+xml", true}, 605 | {"application/vnd.wap.xhtml+xml", true}, 606 | {"application/xhtml+xml", true}, 607 | {"application/xml", true}, 608 | {"image/svg+xml", true}, 609 | {"multipart/x-mixed-replace", true}, 610 | {"text/cache-manifest", true}, 611 | {"text/html", true}, 612 | {"text/rdf", true}, 613 | {"text/vtt", true}, 614 | {"text/xml", true}, 615 | {"text/xsl", true}, 616 | {"text/xsl", true}, 617 | 618 | // weird edge cases 619 | {"", true}, 620 | {"html", true}, 621 | {"TEXT/HTML", true}, 622 | {"tExT/HtMl", true}, 623 | } 624 | params := []string{ 625 | "charset=utf-8", 626 | "charset=utf-8; boundary=foo", 627 | "charset=utf-8; boundary=foo; foo=bar", 628 | } 629 | // Suffixes that can trick or confuse browsers, via: 630 | // https://github.com/BlackFan/content-type-research/blob/4e43747254XSS.md#content-type-that-can-be-used-for-xss 631 | suffixTricks := []string{ 632 | "; x=x, text/html, foobar", 633 | "(xxx", 634 | " xxx", 635 | ",xxx", 636 | } 637 | for _, tc := range testCases { 638 | tc := tc 639 | 640 | // baseline test 641 | t.Run(tc.contentType, func(t *testing.T) { 642 | assert.Equal(t, isDangerousContentType(tc.contentType), tc.dangerous, "incorrect result") 643 | }) 644 | 645 | // ensure that valid mime params do not affect outcome 646 | for _, param := range params { 647 | contentType := tc.contentType + "; " + param 648 | t.Run(tc.contentType+param, func(t *testing.T) { 649 | assert.Equal(t, isDangerousContentType(contentType), tc.dangerous, "incorrect result") 650 | }) 651 | } 652 | 653 | // ensure that tricky variations/corruptions are always considered 654 | // dangerous 655 | for _, trick := range suffixTricks { 656 | contentType := tc.contentType + trick 657 | t.Run(contentType, func(t *testing.T) { 658 | assert.Equal(t, isDangerousContentType(contentType), true, "incorrect dangerous content type") 659 | }) 660 | } 661 | } 662 | } 663 | 664 | func normalizeChoices[T any](choices []weightedChoice[T]) []weightedChoice[T] { 665 | var totalWeight float64 666 | for _, wc := range choices { 667 | totalWeight += wc.Weight 668 | } 669 | normalized := make([]weightedChoice[T], 0, len(choices)) 670 | for _, wc := range choices { 671 | normalized = append(normalized, weightedChoice[T]{Choice: wc.Choice, Weight: wc.Weight / totalWeight}) 672 | } 673 | return normalized 674 | } 675 | 676 | func decodeServerTimings(headerVal string) map[string]serverTiming { 677 | if headerVal == "" { 678 | return nil 679 | } 680 | timings := map[string]serverTiming{} 681 | for _, entry := range strings.Split(headerVal, ",") { 682 | var t serverTiming 683 | for _, kv := range strings.Split(entry, ";") { 684 | kv = strings.TrimSpace(kv) 685 | key, val, _ := strings.Cut(kv, "=") 686 | switch key { 687 | case "dur": 688 | t.dur, _ = time.ParseDuration(val + "ms") 689 | case "desc": 690 | t.desc = strings.Trim(val, "\"") 691 | default: 692 | t.name = key 693 | } 694 | } 695 | if t.name != "" { 696 | timings[t.name] = t 697 | } 698 | } 699 | return timings 700 | } 701 | -------------------------------------------------------------------------------- /httpbin/httpbin.go: -------------------------------------------------------------------------------- 1 | package httpbin 2 | 3 | import ( 4 | "bytes" 5 | "net/http" 6 | "time" 7 | ) 8 | 9 | // Default configuration values 10 | const ( 11 | DefaultMaxBodySize int64 = 1024 * 1024 12 | DefaultMaxDuration = 10 * time.Second 13 | DefaultHostname = "go-httpbin" 14 | ) 15 | 16 | // DefaultParams defines default parameter values 17 | type DefaultParams struct { 18 | // for the /drip endpoint 19 | DripDuration time.Duration 20 | DripDelay time.Duration 21 | DripNumBytes int64 22 | 23 | // for the /sse endpoint 24 | SSECount int 25 | SSEDuration time.Duration 26 | SSEDelay time.Duration 27 | } 28 | 29 | // DefaultDefaultParams defines the DefaultParams that are used by default. In 30 | // general, these should match the original httpbin.org's defaults. 31 | var DefaultDefaultParams = DefaultParams{ 32 | DripDuration: 2 * time.Second, 33 | DripDelay: 2 * time.Second, 34 | DripNumBytes: 10, 35 | SSECount: 10, 36 | SSEDuration: 5 * time.Second, 37 | SSEDelay: 0, 38 | } 39 | 40 | type headersProcessorFunc func(h http.Header) http.Header 41 | 42 | // HTTPBin contains the business logic 43 | type HTTPBin struct { 44 | // Max size of an incoming request or generated response body, in bytes 45 | MaxBodySize int64 46 | 47 | // Max duration of a request, for those requests that allow user control 48 | // over timing (e.g. /delay) 49 | MaxDuration time.Duration 50 | 51 | // Observer called with the result of each handled request 52 | Observer Observer 53 | 54 | // Default parameter values 55 | DefaultParams DefaultParams 56 | 57 | // Set of hosts to which the /redirect-to endpoint will allow redirects 58 | AllowedRedirectDomains map[string]struct{} 59 | 60 | // If true, endpoints that allow clients to specify a response 61 | // Conntent-Type will NOT escape HTML entities in the response body, which 62 | // can enable (e.g.) reflected XSS attacks. 63 | // 64 | // This configuration is only supported for backwards compatibility if 65 | // absolutely necessary. 66 | unsafeAllowDangerousResponses bool 67 | 68 | // The operator-controlled environment variables filtered from 69 | // the process environment, based on named HTTPBIN_ prefix. 70 | env map[string]string 71 | 72 | // Pre-computed error message for the /redirect-to endpoint, based on 73 | // -allowed-redirect-domains/ALLOWED_REDIRECT_DOMAINS 74 | forbiddenRedirectError string 75 | 76 | // The hostname to expose via /hostname. 77 | hostname string 78 | 79 | // The app's http handler 80 | handler http.Handler 81 | 82 | // Optional prefix under which the app will be served 83 | prefix string 84 | 85 | // Pre-rendered templates 86 | indexHTML []byte 87 | formsPostHTML []byte 88 | 89 | // Pre-computed map of special cases for the /status endpoint 90 | statusSpecialCases map[int]*statusCase 91 | 92 | // Optional function to control which headers are excluded from the 93 | // /headers response 94 | excludeHeadersProcessor headersProcessorFunc 95 | 96 | // Max number of SSE events to send, based on rough estimate of single 97 | // event's size 98 | maxSSECount int64 99 | } 100 | 101 | // New creates a new HTTPBin instance 102 | func New(opts ...OptionFunc) *HTTPBin { 103 | h := &HTTPBin{ 104 | MaxBodySize: DefaultMaxBodySize, 105 | MaxDuration: DefaultMaxDuration, 106 | DefaultParams: DefaultDefaultParams, 107 | hostname: DefaultHostname, 108 | } 109 | for _, opt := range opts { 110 | opt(h) 111 | } 112 | 113 | // pre-compute some configuration values and pre-render templates 114 | tmplData := struct{ Prefix string }{Prefix: h.prefix} 115 | h.indexHTML = mustRenderTemplate("index.html.tmpl", tmplData) 116 | h.formsPostHTML = mustRenderTemplate("forms-post.html.tmpl", tmplData) 117 | h.statusSpecialCases = createSpecialCases(h.prefix) 118 | 119 | // compute max Server-Sent Event count based on max request size and rough 120 | // estimate of a single event's size on the wire 121 | var buf bytes.Buffer 122 | writeServerSentEvent(&buf, 999, time.Now()) 123 | h.maxSSECount = h.MaxBodySize / int64(buf.Len()) 124 | 125 | h.handler = h.Handler() 126 | return h 127 | } 128 | 129 | // ServeHTTP implememnts the http.Handler interface. 130 | func (h *HTTPBin) ServeHTTP(w http.ResponseWriter, r *http.Request) { 131 | h.handler.ServeHTTP(w, r) 132 | } 133 | 134 | // Assert that HTTPBin implements http.Handler interface 135 | var _ http.Handler = &HTTPBin{} 136 | 137 | // Handler returns an http.Handler that exposes all HTTPBin endpoints 138 | func (h *HTTPBin) Handler() http.Handler { 139 | mux := http.NewServeMux() 140 | 141 | // Endpoints restricted to specific methods 142 | mux.HandleFunc("DELETE /delete", h.RequestWithBody) 143 | mux.HandleFunc("GET /{$}", h.Index) 144 | mux.HandleFunc("GET /encoding/utf8", h.UTF8) 145 | mux.HandleFunc("GET /forms/post", h.FormsPost) 146 | mux.HandleFunc("GET /get", h.Get) 147 | mux.HandleFunc("GET /websocket/echo", h.WebSocketEcho) 148 | mux.HandleFunc("HEAD /head", h.Get) 149 | mux.HandleFunc("PATCH /patch", h.RequestWithBody) 150 | mux.HandleFunc("POST /post", h.RequestWithBody) 151 | mux.HandleFunc("PUT /put", h.RequestWithBody) 152 | 153 | // Endpoints that accept any methods 154 | mux.HandleFunc("/absolute-redirect/{numRedirects}", h.AbsoluteRedirect) 155 | mux.HandleFunc("/anything", h.Anything) 156 | mux.HandleFunc("/anything/", h.Anything) 157 | mux.HandleFunc("/base64/{data}", h.Base64) 158 | mux.HandleFunc("/base64/{operation}/{data}", h.Base64) 159 | mux.HandleFunc("/basic-auth/{user}/{password}", h.BasicAuth) 160 | mux.HandleFunc("/bearer", h.Bearer) 161 | mux.HandleFunc("/bytes/{numBytes}", h.Bytes) 162 | mux.HandleFunc("/cache", h.Cache) 163 | mux.HandleFunc("/cache/{numSeconds}", h.CacheControl) 164 | mux.HandleFunc("/cookies", h.Cookies) 165 | mux.HandleFunc("/cookies/delete", h.DeleteCookies) 166 | mux.HandleFunc("/cookies/set", h.SetCookies) 167 | mux.HandleFunc("/deflate", h.Deflate) 168 | mux.HandleFunc("/delay/{duration}", h.Delay) 169 | mux.HandleFunc("/deny", h.Deny) 170 | mux.HandleFunc("/digest-auth/{qop}/{user}/{password}", h.DigestAuth) 171 | mux.HandleFunc("/digest-auth/{qop}/{user}/{password}/{algorithm}", h.DigestAuth) 172 | mux.HandleFunc("/drip", h.Drip) 173 | mux.HandleFunc("/dump/request", h.DumpRequest) 174 | mux.HandleFunc("/env", h.Env) 175 | mux.HandleFunc("/etag/{etag}", h.ETag) 176 | mux.HandleFunc("/gzip", h.Gzip) 177 | mux.HandleFunc("/headers", h.Headers) 178 | mux.HandleFunc("/hidden-basic-auth/{user}/{password}", h.HiddenBasicAuth) 179 | mux.HandleFunc("/hostname", h.Hostname) 180 | mux.HandleFunc("/html", h.HTML) 181 | mux.HandleFunc("/image", h.ImageAccept) 182 | mux.HandleFunc("/image/{kind}", h.Image) 183 | mux.HandleFunc("/ip", h.IP) 184 | mux.HandleFunc("/json", h.JSON) 185 | mux.HandleFunc("/links/{numLinks}", h.Links) 186 | mux.HandleFunc("/links/{numLinks}/{offset}", h.Links) 187 | mux.HandleFunc("/range/{numBytes}", h.Range) 188 | mux.HandleFunc("/redirect-to", h.RedirectTo) 189 | mux.HandleFunc("/redirect/{numRedirects}", h.Redirect) 190 | mux.HandleFunc("/relative-redirect/{numRedirects}", h.RelativeRedirect) 191 | mux.HandleFunc("/response-headers", h.ResponseHeaders) 192 | mux.HandleFunc("/robots.txt", h.Robots) 193 | mux.HandleFunc("/sse", h.SSE) 194 | mux.HandleFunc("/status/{code}", h.Status) 195 | mux.HandleFunc("/stream-bytes/{numBytes}", h.StreamBytes) 196 | mux.HandleFunc("/stream/{numLines}", h.Stream) 197 | mux.HandleFunc("/trailers", h.Trailers) 198 | mux.HandleFunc("/unstable", h.Unstable) 199 | mux.HandleFunc("/user-agent", h.UserAgent) 200 | mux.HandleFunc("/uuid", h.UUID) 201 | mux.HandleFunc("/xml", h.XML) 202 | 203 | // existing httpbin endpoints that we do not support 204 | mux.HandleFunc("/brotli", notImplementedHandler) 205 | 206 | // Apply global middleware 207 | var handler http.Handler 208 | handler = mux 209 | handler = limitRequestSize(h.MaxBodySize, handler) 210 | handler = preflight(handler) 211 | handler = autohead(handler) 212 | 213 | if h.prefix != "" { 214 | handler = http.StripPrefix(h.prefix, handler) 215 | } 216 | 217 | if h.Observer != nil { 218 | handler = observe(h.Observer, handler) 219 | } 220 | 221 | return handler 222 | } 223 | 224 | func (h *HTTPBin) setExcludeHeaders(excludeHeaders string) { 225 | regex := createFullExcludeRegex(excludeHeaders) 226 | if regex != nil { 227 | h.excludeHeadersProcessor = createExcludeHeadersProcessor(regex) 228 | } 229 | } 230 | 231 | // mustEscapeResponse returns true if the response body should be HTML-escaped 232 | // to prevent XSS and similar attacks when rendered by a web browser. 233 | func (h *HTTPBin) mustEscapeResponse(contentType string) bool { 234 | if h.unsafeAllowDangerousResponses { 235 | return false 236 | } 237 | return isDangerousContentType(contentType) 238 | } 239 | -------------------------------------------------------------------------------- /httpbin/httpbin_test.go: -------------------------------------------------------------------------------- 1 | package httpbin 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | "net/http/httptest" 7 | "testing" 8 | "time" 9 | ) 10 | 11 | func TestNew(t *testing.T) { 12 | t.Parallel() 13 | h := New() 14 | if h.MaxBodySize != DefaultMaxBodySize { 15 | t.Fatalf("expected default MaxBodySize == %d, got %#v", DefaultMaxBodySize, h.MaxBodySize) 16 | } 17 | if h.MaxDuration != DefaultMaxDuration { 18 | t.Fatalf("expected default MaxDuration == %s, got %#v", DefaultMaxDuration, h.MaxDuration) 19 | } 20 | if h.Observer != nil { 21 | t.Fatalf("expected default Observer == nil, got %#v", h.Observer) 22 | } 23 | } 24 | 25 | func TestNewOptions(t *testing.T) { 26 | t.Parallel() 27 | maxDuration := 1 * time.Second 28 | maxBodySize := int64(1024) 29 | observer := func(_ Result) {} 30 | 31 | h := New( 32 | WithMaxBodySize(maxBodySize), 33 | WithMaxDuration(maxDuration), 34 | WithObserver(observer), 35 | ) 36 | 37 | if h.MaxBodySize != maxBodySize { 38 | t.Fatalf("expected MaxBodySize == %d, got %#v", maxBodySize, h.MaxBodySize) 39 | } 40 | if h.MaxDuration != maxDuration { 41 | t.Fatalf("expected MaxDuration == %s, got %#v", maxDuration, h.MaxDuration) 42 | } 43 | if h.Observer == nil { 44 | t.Fatalf("expected non-nil Observer") 45 | } 46 | } 47 | 48 | func TestNewObserver(t *testing.T) { 49 | t.Parallel() 50 | expectedStatus := http.StatusTeapot 51 | 52 | observed := false 53 | observer := func(r Result) { 54 | observed = true 55 | if r.Status != expectedStatus { 56 | t.Fatalf("expected result status = %d, got %d", expectedStatus, r.Status) 57 | } 58 | } 59 | 60 | h := New(WithObserver(observer)) 61 | 62 | r, _ := http.NewRequest("GET", fmt.Sprintf("/status/%d", expectedStatus), nil) 63 | w := httptest.NewRecorder() 64 | h.Handler().ServeHTTP(w, r) 65 | 66 | if observed == false { 67 | t.Fatalf("observer never called") 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /httpbin/middleware.go: -------------------------------------------------------------------------------- 1 | package httpbin 2 | 3 | import ( 4 | "bufio" 5 | "context" 6 | "fmt" 7 | "log/slog" 8 | "net" 9 | "net/http" 10 | "time" 11 | ) 12 | 13 | func preflight(h http.Handler) http.Handler { 14 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 15 | origin := r.Header.Get("Origin") 16 | if origin == "" { 17 | origin = "*" 18 | } 19 | respHeader := w.Header() 20 | respHeader.Set("Access-Control-Allow-Origin", origin) 21 | respHeader.Set("Access-Control-Allow-Credentials", "true") 22 | 23 | if r.Method == "OPTIONS" { 24 | w.Header().Set("Access-Control-Allow-Methods", "GET, POST, HEAD, PUT, DELETE, PATCH, OPTIONS") 25 | w.Header().Set("Access-Control-Max-Age", "3600") 26 | if r.Header.Get("Access-Control-Request-Headers") != "" { 27 | w.Header().Set("Access-Control-Allow-Headers", r.Header.Get("Access-Control-Request-Headers")) 28 | } 29 | w.WriteHeader(200) 30 | return 31 | } 32 | 33 | h.ServeHTTP(w, r) 34 | }) 35 | } 36 | 37 | func limitRequestSize(maxSize int64, h http.Handler) http.Handler { 38 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 39 | if r.Body != nil { 40 | r.Body = http.MaxBytesReader(w, r.Body, maxSize) 41 | } 42 | h.ServeHTTP(w, r) 43 | }) 44 | } 45 | 46 | // headResponseWriter implements http.ResponseWriter in order to discard the 47 | // body of the response 48 | type headResponseWriter struct { 49 | *metaResponseWriter 50 | } 51 | 52 | func (hw *headResponseWriter) Write(b []byte) (int, error) { 53 | return len(b), nil 54 | } 55 | 56 | // autohead automatically discards the body of responses to HEAD requests 57 | func autohead(h http.Handler) http.Handler { 58 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 59 | if r.Method == "HEAD" { 60 | w = &headResponseWriter{&metaResponseWriter{w: w}} 61 | } 62 | h.ServeHTTP(w, r) 63 | }) 64 | } 65 | 66 | // testMode enables additional safety checks to be enabled in the test suite. 67 | var testMode = false 68 | 69 | // metaResponseWriter implements http.ResponseWriter and http.Flusher in order 70 | // to record a response's status code and body size for logging purposes. 71 | type metaResponseWriter struct { 72 | w http.ResponseWriter 73 | status int 74 | size int64 75 | } 76 | 77 | func (mw *metaResponseWriter) Write(b []byte) (int, error) { 78 | size, err := mw.w.Write(b) 79 | mw.size += int64(size) 80 | return size, err 81 | } 82 | 83 | func (mw *metaResponseWriter) WriteHeader(s int) { 84 | if testMode && mw.status != 0 { 85 | panic(fmt.Errorf("HTTP status already set to %d, cannot set to %d", mw.status, s)) 86 | } 87 | mw.w.WriteHeader(s) 88 | mw.status = s 89 | } 90 | 91 | func (mw *metaResponseWriter) Flush() { 92 | f := mw.w.(http.Flusher) 93 | f.Flush() 94 | } 95 | 96 | func (mw *metaResponseWriter) Header() http.Header { 97 | return mw.w.Header() 98 | } 99 | 100 | func (mw *metaResponseWriter) Status() int { 101 | if mw.status == 0 { 102 | return http.StatusOK 103 | } 104 | return mw.status 105 | } 106 | 107 | func (mw *metaResponseWriter) Size() int64 { 108 | return mw.size 109 | } 110 | 111 | func (mw *metaResponseWriter) Hijack() (net.Conn, *bufio.ReadWriter, error) { 112 | return mw.w.(http.Hijacker).Hijack() 113 | } 114 | 115 | func observe(o Observer, h http.Handler) http.Handler { 116 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 117 | mw := &metaResponseWriter{w: w} 118 | t := time.Now() 119 | h.ServeHTTP(mw, r) 120 | o(Result{ 121 | Status: mw.Status(), 122 | Method: r.Method, 123 | URI: r.URL.RequestURI(), 124 | Size: mw.Size(), 125 | Duration: time.Since(t), 126 | UserAgent: r.Header.Get("User-Agent"), 127 | ClientIP: getClientIP(r), 128 | }) 129 | }) 130 | } 131 | 132 | // Result is the result of handling a request, used for instrumentation 133 | type Result struct { 134 | Status int 135 | Method string 136 | URI string 137 | Size int64 138 | Duration time.Duration 139 | UserAgent string 140 | ClientIP string 141 | } 142 | 143 | // Observer is a function that will be called with the details of a handled 144 | // request, which can be used for logging, instrumentation, etc 145 | type Observer func(result Result) 146 | 147 | // StdLogObserver creates an Observer that will log each request in structured 148 | // format using the given stdlib logger 149 | func StdLogObserver(l *slog.Logger) Observer { 150 | return func(result Result) { 151 | logLevel := slog.LevelInfo 152 | if result.Status >= 500 { 153 | logLevel = slog.LevelError 154 | } else if result.Status >= 400 && result.Status < 500 { 155 | logLevel = slog.LevelWarn 156 | } 157 | l.LogAttrs( 158 | context.Background(), 159 | logLevel, 160 | fmt.Sprintf("%d %s %s %.1fms", result.Status, result.Method, result.URI, result.Duration.Seconds()*1e3), 161 | slog.Int("status", result.Status), 162 | slog.String("method", result.Method), 163 | slog.String("uri", result.URI), 164 | slog.Int64("size_bytes", result.Size), 165 | slog.Float64("duration_ms", result.Duration.Seconds()*1e3), 166 | slog.String("user_agent", result.UserAgent), 167 | slog.String("client_ip", result.ClientIP), 168 | ) 169 | } 170 | } 171 | -------------------------------------------------------------------------------- /httpbin/middleware_test.go: -------------------------------------------------------------------------------- 1 | package httpbin 2 | 3 | import ( 4 | "net/http" 5 | "net/http/httptest" 6 | "testing" 7 | 8 | "github.com/mccutchen/go-httpbin/v2/internal/testing/assert" 9 | ) 10 | 11 | func TestTestMode(t *testing.T) { 12 | // This test ensures that we use testMode in our test suite, and ensures 13 | // that it is working as expected. 14 | assert.Equal(t, testMode, true, "expected testMode to be turned on in test suite") 15 | 16 | // We want to ensure that, in testMode, a handler calling WriteHeader twice 17 | // will cause a panic. This happens most often when we forget to return 18 | // early after writing an error response, and has helped identify and fix 19 | // some subtly broken error handling. 20 | observer := func(_ Result) {} 21 | handler := observe(observer, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { 22 | w.WriteHeader(http.StatusBadRequest) 23 | w.WriteHeader(http.StatusOK) 24 | })) 25 | 26 | defer func() { 27 | r := recover() 28 | if r == nil { 29 | t.Fatalf("expected to catch panic") 30 | } 31 | err, ok := r.(error) 32 | assert.Equal(t, ok, true, "expected panic to be an error") 33 | assert.Equal(t, err.Error(), "HTTP status already set to 400, cannot set to 200", "incorrect panic error message") 34 | }() 35 | 36 | w := httptest.NewRecorder() 37 | r := httptest.NewRequest(http.MethodGet, "/", nil) 38 | handler.ServeHTTP(w, r) 39 | } 40 | -------------------------------------------------------------------------------- /httpbin/options.go: -------------------------------------------------------------------------------- 1 | package httpbin 2 | 3 | import ( 4 | "fmt" 5 | "sort" 6 | "strings" 7 | "time" 8 | ) 9 | 10 | // OptionFunc uses the "functional options" pattern to customize an HTTPBin 11 | // instance 12 | type OptionFunc func(*HTTPBin) 13 | 14 | // WithDefaultParams sets the default params handlers will use 15 | func WithDefaultParams(defaultParams DefaultParams) OptionFunc { 16 | return func(h *HTTPBin) { 17 | h.DefaultParams = defaultParams 18 | } 19 | } 20 | 21 | // WithMaxBodySize sets the maximum amount of memory 22 | func WithMaxBodySize(m int64) OptionFunc { 23 | return func(h *HTTPBin) { 24 | h.MaxBodySize = m 25 | } 26 | } 27 | 28 | // WithMaxDuration sets the maximum amount of time httpbin may take to respond 29 | func WithMaxDuration(d time.Duration) OptionFunc { 30 | return func(h *HTTPBin) { 31 | h.MaxDuration = d 32 | } 33 | } 34 | 35 | // WithHostname sets the hostname to return via the /hostname endpoint. 36 | func WithHostname(s string) OptionFunc { 37 | return func(h *HTTPBin) { 38 | h.hostname = s 39 | } 40 | } 41 | 42 | // WithObserver sets the request observer callback 43 | func WithObserver(o Observer) OptionFunc { 44 | return func(h *HTTPBin) { 45 | h.Observer = o 46 | } 47 | } 48 | 49 | // WithEnv sets the HTTPBIN_-prefixed environment variables reported 50 | // by the /env endpoint. 51 | func WithEnv(env map[string]string) OptionFunc { 52 | return func(h *HTTPBin) { 53 | h.env = env 54 | } 55 | } 56 | 57 | // WithExcludeHeaders sets the headers to exclude in outgoing responses, to 58 | // prevent possible information leakage. 59 | func WithExcludeHeaders(excludeHeaders string) OptionFunc { 60 | return func(h *HTTPBin) { 61 | h.setExcludeHeaders(excludeHeaders) 62 | } 63 | } 64 | 65 | // WithPrefix sets the path prefix 66 | func WithPrefix(p string) OptionFunc { 67 | return func(h *HTTPBin) { 68 | h.prefix = p 69 | } 70 | } 71 | 72 | // WithAllowedRedirectDomains limits the domains to which the /redirect-to 73 | // endpoint will redirect traffic. 74 | func WithAllowedRedirectDomains(hosts []string) OptionFunc { 75 | return func(h *HTTPBin) { 76 | hostSet := make(map[string]struct{}, len(hosts)) 77 | formattedListItems := make([]string, 0, len(hosts)) 78 | for _, host := range hosts { 79 | hostSet[host] = struct{}{} 80 | formattedListItems = append(formattedListItems, fmt.Sprintf("- %s", host)) 81 | } 82 | h.AllowedRedirectDomains = hostSet 83 | 84 | sort.Strings(formattedListItems) 85 | h.forbiddenRedirectError = fmt.Sprintf(`Forbidden redirect URL. Please be careful with this link. 86 | 87 | Allowed redirect destinations: 88 | %s`, strings.Join(formattedListItems, "\n")) 89 | } 90 | } 91 | 92 | // WithUnsafeAllowDangerousResponses means endpoints that allow clients to 93 | // specify a response Conntent-Type WILL NOT escape HTML entities in the 94 | // response body, which can enable (e.g.) reflected XSS attacks. 95 | // 96 | // This configuration is only supported for backwards compatibility if 97 | // absolutely necessary. 98 | func WithUnsafeAllowDangerousResponses() OptionFunc { 99 | return func(h *HTTPBin) { 100 | h.unsafeAllowDangerousResponses = true 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /httpbin/responses.go: -------------------------------------------------------------------------------- 1 | package httpbin 2 | 3 | import ( 4 | "net/http" 5 | "net/url" 6 | ) 7 | 8 | const ( 9 | binaryContentType = "application/octet-stream" 10 | htmlContentType = "text/html; charset=utf-8" 11 | jsonContentType = "application/json; charset=utf-8" 12 | sseContentType = "text/event-stream; charset=utf-8" 13 | textContentType = "text/plain; charset=utf-8" 14 | ) 15 | 16 | type envResponse struct { 17 | Env map[string]string `json:"env"` 18 | } 19 | 20 | type headersResponse struct { 21 | Headers http.Header `json:"headers"` 22 | } 23 | 24 | type ipResponse struct { 25 | Origin string `json:"origin"` 26 | } 27 | 28 | type userAgentResponse struct { 29 | UserAgent string `json:"user-agent"` 30 | } 31 | 32 | // A generic response for any incoming request that should not contain a body 33 | // (GET, HEAD, OPTIONS, etc). 34 | type noBodyResponse struct { 35 | Args url.Values `json:"args"` 36 | Headers http.Header `json:"headers"` 37 | Method string `json:"method"` 38 | Origin string `json:"origin"` 39 | URL string `json:"url"` 40 | 41 | Deflated bool `json:"deflated,omitempty"` 42 | Gzipped bool `json:"gzipped,omitempty"` 43 | } 44 | 45 | // A generic response for any incoming request that might contain a body (POST, 46 | // PUT, PATCH, etc). 47 | type bodyResponse struct { 48 | Args url.Values `json:"args"` 49 | Headers http.Header `json:"headers"` 50 | Method string `json:"method"` 51 | Origin string `json:"origin"` 52 | URL string `json:"url"` 53 | 54 | Data string `json:"data"` 55 | Files url.Values `json:"files"` 56 | Form url.Values `json:"form"` 57 | JSON interface{} `json:"json"` 58 | } 59 | 60 | type cookiesResponse map[string]string 61 | 62 | type authResponse struct { 63 | Authorized bool `json:"authorized"` 64 | User string `json:"user"` 65 | } 66 | 67 | // An actual stream response body will be made up of one or more of these 68 | // structs, encoded as JSON and separated by newlines 69 | type streamResponse struct { 70 | ID int `json:"id"` 71 | Args url.Values `json:"args"` 72 | Headers http.Header `json:"headers"` 73 | Origin string `json:"origin"` 74 | URL string `json:"url"` 75 | } 76 | 77 | type uuidResponse struct { 78 | UUID string `json:"uuid"` 79 | } 80 | 81 | type bearerResponse struct { 82 | Authenticated bool `json:"authenticated"` 83 | Token string `json:"token"` 84 | } 85 | 86 | type hostnameResponse struct { 87 | Hostname string `json:"hostname"` 88 | } 89 | 90 | type errorRespnose struct { 91 | StatusCode int `json:"status_code"` 92 | Error string `json:"error"` 93 | Detail string `json:"detail,omitempty"` 94 | } 95 | 96 | type serverSentEvent struct { 97 | ID int `json:"id"` 98 | Timestamp int64 `json:"timestamp"` 99 | } 100 | -------------------------------------------------------------------------------- /httpbin/static/forms-post.html.tmpl: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 |
8 |

9 |

10 |

11 |
12 | Pizza Size 13 |

14 |

15 |

16 |
17 |
18 | Pizza Toppings 19 |

20 |

21 |

22 |

23 |
24 |

25 |

26 |

27 |
28 | 29 | 30 | -------------------------------------------------------------------------------- /httpbin/static/image.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mccutchen/go-httpbin/3855b8847d90daed19276b8a0893482575e16813/httpbin/static/image.jpeg -------------------------------------------------------------------------------- /httpbin/static/image.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mccutchen/go-httpbin/3855b8847d90daed19276b8a0893482575e16813/httpbin/static/image.png -------------------------------------------------------------------------------- /httpbin/static/image.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | SVG Logo 4 | 5 | 7 | 8 | 15 | 16 | 23 | 24 | 33 | 34 | 35 | 40 | 41 | 46 | 47 | 52 | 53 | 58 | 59 | 64 | 65 | 70 | 71 | 76 | 77 | 82 | 83 | 84 | 85 | 104 | 105 | 157 | 158 | 159 | 160 | 169 | 170 | 181 | 182 | 183 | SVG 184 | 215 | 227 | 228 | 256 | 257 | 258 | 259 | 260 | -------------------------------------------------------------------------------- /httpbin/static/image.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mccutchen/go-httpbin/3855b8847d90daed19276b8a0893482575e16813/httpbin/static/image.webp -------------------------------------------------------------------------------- /httpbin/static/index.html.tmpl: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | go-httpbin(1): HTTP Client Testing Service 7 | 43 | 49 | 50 | 51 | 52 | 53 | 54 |
55 |

go-httpbin(1)

56 |

A golang port of the venerable httpbin.org HTTP request & response testing service.

57 | 58 |

ENDPOINTS

59 | 60 | 124 | 125 |

DESCRIPTION

126 | 127 |

Testing an HTTP Library can become difficult sometimes. RequestBin is fantastic for testing POST requests, but doesn't let you control the response. This exists to cover all kinds of HTTP scenarios. Additional endpoints are being considered.

128 | 129 |

All endpoint responses are JSON-encoded.

130 | 131 |

EXAMPLES

132 | 133 |

$ curl https://httpbingo.org/ip

134 | 135 |
{"origin":"73.238.9.52, 77.83.142.42"}
136 | 
137 | 138 |

$ curl https://httpbingo.org/user-agent

139 | 140 |
{"user-agent":"curl/7.64.1"}
141 | 142 |

$ curl https://httpbingo.org/get?foo=bar

143 | 144 |
{
145 |   "args": {
146 |     "foo": [
147 |       "bar"
148 |     ]
149 |   },
150 |   "headers": {
151 |     "Accept": [
152 |       "*/*"
153 |     ],
154 |     "Host": [
155 |       "httpbingo.org"
156 |     ],
157 |     "User-Agent": [
158 |       "curl/7.64.1"
159 |     ]
160 |   },
161 |   "origin": "73.238.9.52, 77.83.142.42",
162 |   "url": "https://httpbingo.org/get?foo=bar"
163 | }
164 | 
165 | 166 |

$ curl https://httpbingo.org/dump/request?foo=bar

167 | 168 |
GET /dump/request?foo=bar HTTP/1.1
169 | Host: httpbingo.org
170 | Accept: */*
171 | User-Agent: curl/7.64.1
172 | 
173 | 174 |

$ curl -I https://httpbingo.org/status/418

175 | 176 |
HTTP/1.1 418 I'm a teapot
177 | Access-Control-Allow-Credentials: true
178 | Access-Control-Allow-Origin: *
179 | X-More-Info: http://tools.ietf.org/html/rfc2324
180 | Date: Tue, 13 Jul 2021 13:12:37 GMT
181 | Content-Length: 0
182 | 
183 | 184 | 185 |

AUTHOR

186 | 187 |

Ported to Go by Will McCutchen.

188 |

From the original Kenneth Reitz project.

189 | 190 |

SEE ALSO

191 | 192 |

httpbin.org — the original httpbin

193 | 194 |
195 | 196 | 197 | 198 | 199 | 200 | -------------------------------------------------------------------------------- /httpbin/static/moby.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 |

Herman Melville - Moby-Dick

7 | 8 |
9 |

10 | Availing himself of the mild, summer-cool weather that now reigned in these latitudes, and in preparation for the peculiarly active pursuits shortly to be anticipated, Perth, the begrimed, blistered old blacksmith, had not removed his portable forge to the hold again, after concluding his contributory work for Ahab's leg, but still retained it on deck, fast lashed to ringbolts by the foremast; being now almost incessantly invoked by the headsmen, and harpooneers, and bowsmen to do some little job for them; altering, or repairing, or new shaping their various weapons and boat furniture. Often he would be surrounded by an eager circle, all waiting to be served; holding boat-spades, pike-heads, harpoons, and lances, and jealously watching his every sooty movement, as he toiled. Nevertheless, this old man's was a patient hammer wielded by a patient arm. No murmur, no impatience, no petulance did come from him. Silent, slow, and solemn; bowing over still further his chronically broken back, he toiled away, as if toil were life itself, and the heavy beating of his hammer the heavy beating of his heart. And so it was.—Most miserable! A peculiar walk in this old man, a certain slight but painful appearing yawing in his gait, had at an early period of the voyage excited the curiosity of the mariners. And to the importunity of their persisted questionings he had finally given in; and so it came to pass that every one now knew the shameful story of his wretched fate. Belated, and not innocently, one bitter winter's midnight, on the road running between two country towns, the blacksmith half-stupidly felt the deadly numbness stealing over him, and sought refuge in a leaning, dilapidated barn. The issue was, the loss of the extremities of both feet. Out of this revelation, part by part, at last came out the four acts of the gladness, and the one long, and as yet uncatastrophied fifth act of the grief of his life's drama. He was an old man, who, at the age of nearly sixty, had postponedly encountered that thing in sorrow's technicals called ruin. He had been an artisan of famed excellence, and with plenty to do; owned a house and garden; embraced a youthful, daughter-like, loving wife, and three blithe, ruddy children; every Sunday went to a cheerful-looking church, planted in a grove. But one night, under cover of darkness, and further concealed in a most cunning disguisement, a desperate burglar slid into his happy home, and robbed them all of everything. And darker yet to tell, the blacksmith himself did ignorantly conduct this burglar into his family's heart. It was the Bottle Conjuror! Upon the opening of that fatal cork, forth flew the fiend, and shrivelled up his home. Now, for prudent, most wise, and economic reasons, the blacksmith's shop was in the basement of his dwelling, but with a separate entrance to it; so that always had the young and loving healthy wife listened with no unhappy nervousness, but with vigorous pleasure, to the stout ringing of her young-armed old husband's hammer; whose reverberations, muffled by passing through the floors and walls, came up to her, not unsweetly, in her nursery; and so, to stout Labor's iron lullaby, the blacksmith's infants were rocked to slumber. Oh, woe on woe! Oh, Death, why canst thou not sometimes be timely? Hadst thou taken this old blacksmith to thyself ere his full ruin came upon him, then had the young widow had a delicious grief, and her orphans a truly venerable, legendary sire to dream of in their after years; and all of them a care-killing competency. 11 |

12 |
13 | 14 | 15 | -------------------------------------------------------------------------------- /httpbin/static/sample.json: -------------------------------------------------------------------------------- 1 | { 2 | "slideshow": { 3 | "author": "Yours Truly", 4 | "date": "date of publication", 5 | "slides": [ 6 | { 7 | "title": "Wake up to WonderWidgets!", 8 | "type": "all" 9 | }, 10 | { 11 | "items": [ 12 | "Why WonderWidgets are great", 13 | "Who buys WonderWidgets" 14 | ], 15 | "title": "Overview", 16 | "type": "all" 17 | } 18 | ], 19 | "title": "Sample Slide Show" 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /httpbin/static/sample.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 10 | 11 | 12 | 13 | Wake up to WonderWidgets! 14 | 15 | 16 | 17 | 18 | Overview 19 | Why WonderWidgets are great 20 | 21 | Who buys WonderWidgets 22 | 23 | 24 | 25 | -------------------------------------------------------------------------------- /httpbin/static/utf8.html: -------------------------------------------------------------------------------- 1 |

Unicode Demo

2 | 3 |

Taken from http://www.cl.cam.ac.uk/~mgk25/ucs/examples/UTF-8-demo.txt

5 | 6 |
  7 | 
  8 | UTF-8 encoded sample plain-text file
  9 | ‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾
 10 | 
 11 | Markus Kuhn [ˈmaʳkʊs kuːn]  — 2002-07-25
 12 | 
 13 | 
 14 | The ASCII compatible UTF-8 encoding used in this plain-text file
 15 | is defined in Unicode, ISO 10646-1, and RFC 2279.
 16 | 
 17 | 
 18 | Using Unicode/UTF-8, you can write in emails and source code things such as
 19 | 
 20 | Mathematics and sciences:
 21 | 
 22 |   ∮ E⋅da = Q,  n → ∞, ∑ f(i) = ∏ g(i),      ⎧⎡⎛┌─────┐⎞⎤⎫
 23 |                                             ⎪⎢⎜│a²+b³ ⎟⎥⎪
 24 |   ∀x∈ℝ: ⌈x⌉ = −⌊−x⌋, α ∧ ¬β = ¬(¬α ∨ β),    ⎪⎢⎜│───── ⎟⎥⎪
 25 |                                             ⎪⎢⎜⎷ c₈   ⎟⎥⎪
 26 |   ℕ ⊆ ℕ₀ ⊂ ℤ ⊂ ℚ ⊂ ℝ ⊂ ℂ,                   ⎨⎢⎜       ⎟⎥⎬
 27 |                                             ⎪⎢⎜ ∞     ⎟⎥⎪
 28 |   ⊥ < a ≠ b ≡ c ≤ d ≪ ⊤ ⇒ (⟦A⟧ ⇔ ⟪B⟫),      ⎪⎢⎜ ⎲     ⎟⎥⎪
 29 |                                             ⎪⎢⎜ ⎳aⁱ-bⁱ⎟⎥⎪
 30 |   2H₂ + O₂ ⇌ 2H₂O, R = 4.7 kΩ, ⌀ 200 mm     ⎩⎣⎝i=1    ⎠⎦⎭
 31 | 
 32 | Linguistics and dictionaries:
 33 | 
 34 |   ði ıntəˈnæʃənəl fəˈnɛtık əsoʊsiˈeıʃn
 35 |   Y [ˈʏpsilɔn], Yen [jɛn], Yoga [ˈjoːgɑ]
 36 | 
 37 | APL:
 38 | 
 39 |   ((V⍳V)=⍳⍴V)/V←,V    ⌷←⍳→⍴∆∇⊃‾⍎⍕⌈
 40 | 
 41 | Nicer typography in plain text files:
 42 | 
 43 |   ╔══════════════════════════════════════════╗
 44 |   ║                                          ║
 45 |   ║   • ‘single’ and “double” quotes         ║
 46 |   ║                                          ║
 47 |   ║   • Curly apostrophes: “We’ve been here” ║
 48 |   ║                                          ║
 49 |   ║   • Latin-1 apostrophe and accents: '´`  ║
 50 |   ║                                          ║
 51 |   ║   • ‚deutsche‘ „Anführungszeichen“       ║
 52 |   ║                                          ║
 53 |   ║   • †, ‡, ‰, •, 3–4, —, −5/+5, ™, …      ║
 54 |   ║                                          ║
 55 |   ║   • ASCII safety test: 1lI|, 0OD, 8B     ║
 56 |   ║                      ╭─────────╮         ║
 57 |   ║   • the euro symbol: │ 14.95 € │         ║
 58 |   ║                      ╰─────────╯         ║
 59 |   ╚══════════════════════════════════════════╝
 60 | 
 61 | Combining characters:
 62 | 
 63 |   STARGΛ̊TE SG-1, a = v̇ = r̈, a⃑ ⊥ b⃑
 64 | 
 65 | Greek (in Polytonic):
 66 | 
 67 |   The Greek anthem:
 68 | 
 69 |   Σὲ γνωρίζω ἀπὸ τὴν κόψη
 70 |   τοῦ σπαθιοῦ τὴν τρομερή,
 71 |   σὲ γνωρίζω ἀπὸ τὴν ὄψη
 72 |   ποὺ μὲ βία μετράει τὴ γῆ.
 73 | 
 74 |   ᾿Απ᾿ τὰ κόκκαλα βγαλμένη
 75 |   τῶν ῾Ελλήνων τὰ ἱερά
 76 |   καὶ σὰν πρῶτα ἀνδρειωμένη
 77 |   χαῖρε, ὦ χαῖρε, ᾿Ελευθεριά!
 78 | 
 79 |   From a speech of Demosthenes in the 4th century BC:
 80 | 
 81 |   Οὐχὶ ταὐτὰ παρίσταταί μοι γιγνώσκειν, ὦ ἄνδρες ᾿Αθηναῖοι,
 82 |   ὅταν τ᾿ εἰς τὰ πράγματα ἀποβλέψω καὶ ὅταν πρὸς τοὺς
 83 |   λόγους οὓς ἀκούω· τοὺς μὲν γὰρ λόγους περὶ τοῦ
 84 |   τιμωρήσασθαι Φίλιππον ὁρῶ γιγνομένους, τὰ δὲ πράγματ᾿
 85 |   εἰς τοῦτο προήκοντα,  ὥσθ᾿ ὅπως μὴ πεισόμεθ᾿ αὐτοὶ
 86 |   πρότερον κακῶς σκέψασθαι δέον. οὐδέν οὖν ἄλλο μοι δοκοῦσιν
 87 |   οἱ τὰ τοιαῦτα λέγοντες ἢ τὴν ὑπόθεσιν, περὶ ἧς βουλεύεσθαι,
 88 |   οὐχὶ τὴν οὖσαν παριστάντες ὑμῖν ἁμαρτάνειν. ἐγὼ δέ, ὅτι μέν
 89 |   ποτ᾿ ἐξῆν τῇ πόλει καὶ τὰ αὑτῆς ἔχειν ἀσφαλῶς καὶ Φίλιππον
 90 |   τιμωρήσασθαι, καὶ μάλ᾿ ἀκριβῶς οἶδα· ἐπ᾿ ἐμοῦ γάρ, οὐ πάλαι
 91 |   γέγονεν ταῦτ᾿ ἀμφότερα· νῦν μέντοι πέπεισμαι τοῦθ᾿ ἱκανὸν
 92 |   προλαβεῖν ἡμῖν εἶναι τὴν πρώτην, ὅπως τοὺς συμμάχους
 93 |   σώσομεν. ἐὰν γὰρ τοῦτο βεβαίως ὑπάρξῃ, τότε καὶ περὶ τοῦ
 94 |   τίνα τιμωρήσεταί τις καὶ ὃν τρόπον ἐξέσται σκοπεῖν· πρὶν δὲ
 95 |   τὴν ἀρχὴν ὀρθῶς ὑποθέσθαι, μάταιον ἡγοῦμαι περὶ τῆς
 96 |   τελευτῆς ὁντινοῦν ποιεῖσθαι λόγον.
 97 | 
 98 |   Δημοσθένους, Γ´ ᾿Ολυνθιακὸς
 99 | 
100 | Georgian:
101 | 
102 |   From a Unicode conference invitation:
103 | 
104 |   გთხოვთ ახლავე გაიაროთ რეგისტრაცია Unicode-ის მეათე საერთაშორისო
105 |   კონფერენციაზე დასასწრებად, რომელიც გაიმართება 10-12 მარტს,
106 |   ქ. მაინცში, გერმანიაში. კონფერენცია შეჰკრებს ერთად მსოფლიოს
107 |   ექსპერტებს ისეთ დარგებში როგორიცაა ინტერნეტი და Unicode-ი,
108 |   ინტერნაციონალიზაცია და ლოკალიზაცია, Unicode-ის გამოყენება
109 |   ოპერაციულ სისტემებსა, და გამოყენებით პროგრამებში, შრიფტებში,
110 |   ტექსტების დამუშავებასა და მრავალენოვან კომპიუტერულ სისტემებში.
111 | 
112 | Russian:
113 | 
114 |   From a Unicode conference invitation:
115 | 
116 |   Зарегистрируйтесь сейчас на Десятую Международную Конференцию по
117 |   Unicode, которая состоится 10-12 марта 1997 года в Майнце в Германии.
118 |   Конференция соберет широкий круг экспертов по  вопросам глобального
119 |   Интернета и Unicode, локализации и интернационализации, воплощению и
120 |   применению Unicode в различных операционных системах и программных
121 |   приложениях, шрифтах, верстке и многоязычных компьютерных системах.
122 | 
123 | Thai (UCS Level 2):
124 | 
125 |   Excerpt from a poetry on The Romance of The Three Kingdoms (a Chinese
126 |   classic 'San Gua'):
127 | 
128 |   [----------------------------|------------------------]
129 |     ๏ แผ่นดินฮั่นเสื่อมโทรมแสนสังเวช  พระปกเกศกองบู๊กู้ขึ้นใหม่
130 |   สิบสองกษัตริย์ก่อนหน้าแลถัดไป       สององค์ไซร้โง่เขลาเบาปัญญา
131 |     ทรงนับถือขันทีเป็นที่พึ่ง           บ้านเมืองจึงวิปริตเป็นนักหนา
132 |   โฮจิ๋นเรียกทัพทั่วหัวเมืองมา         หมายจะฆ่ามดชั่วตัวสำคัญ
133 |     เหมือนขับไสไล่เสือจากเคหา      รับหมาป่าเข้ามาเลยอาสัญ
134 |   ฝ่ายอ้องอุ้นยุแยกให้แตกกัน          ใช้สาวนั้นเป็นชนวนชื่นชวนใจ
135 |     พลันลิฉุยกุยกีกลับก่อเหตุ          ช่างอาเพศจริงหนาฟ้าร้องไห้
136 |   ต้องรบราฆ่าฟันจนบรรลัย           ฤๅหาใครค้ำชูกู้บรรลังก์ ฯ
137 | 
138 |   (The above is a two-column text. If combining characters are handled
139 |   correctly, the lines of the second column should be aligned with the
140 |   | character above.)
141 | 
142 | Ethiopian:
143 | 
144 |   Proverbs in the Amharic language:
145 | 
146 |   ሰማይ አይታረስ ንጉሥ አይከሰስ።
147 |   ብላ ካለኝ እንደአባቴ በቆመጠኝ።
148 |   ጌጥ ያለቤቱ ቁምጥና ነው።
149 |   ደሀ በሕልሙ ቅቤ ባይጠጣ ንጣት በገደለው።
150 |   የአፍ ወለምታ በቅቤ አይታሽም።
151 |   አይጥ በበላ ዳዋ ተመታ።
152 |   ሲተረጉሙ ይደረግሙ።
153 |   ቀስ በቀስ፥ ዕንቁላል በእግሩ ይሄዳል።
154 |   ድር ቢያብር አንበሳ ያስር።
155 |   ሰው እንደቤቱ እንጅ እንደ ጉረቤቱ አይተዳደርም።
156 |   እግዜር የከፈተውን ጉሮሮ ሳይዘጋው አይድርም።
157 |   የጎረቤት ሌባ፥ ቢያዩት ይስቅ ባያዩት ያጠልቅ።
158 |   ሥራ ከመፍታት ልጄን ላፋታት።
159 |   ዓባይ ማደሪያ የለው፥ ግንድ ይዞ ይዞራል።
160 |   የእስላም አገሩ መካ የአሞራ አገሩ ዋርካ።
161 |   ተንጋሎ ቢተፉ ተመልሶ ባፉ።
162 |   ወዳጅህ ማር ቢሆን ጨርስህ አትላሰው።
163 |   እግርህን በፍራሽህ ልክ ዘርጋ።
164 | 
165 | Runes:
166 | 
167 |   ᚻᛖ ᚳᚹᚫᚦ ᚦᚫᛏ ᚻᛖ ᛒᚢᛞᛖ ᚩᚾ ᚦᚫᛗ ᛚᚪᚾᛞᛖ ᚾᚩᚱᚦᚹᛖᚪᚱᛞᚢᛗ ᚹᛁᚦ ᚦᚪ ᚹᛖᛥᚫ
168 | 
169 |   (Old English, which transcribed into Latin reads 'He cwaeth that he
170 |   bude thaem lande northweardum with tha Westsae.' and means 'He said
171 |   that he lived in the northern land near the Western Sea.')
172 | 
173 | Braille:
174 | 
175 |   ⡌⠁⠧⠑ ⠼⠁⠒  ⡍⠜⠇⠑⠹⠰⠎ ⡣⠕⠌
176 | 
177 |   ⡍⠜⠇⠑⠹ ⠺⠁⠎ ⠙⠑⠁⠙⠒ ⠞⠕ ⠃⠑⠛⠔ ⠺⠊⠹⠲ ⡹⠻⠑ ⠊⠎ ⠝⠕ ⠙⠳⠃⠞
178 |   ⠱⠁⠞⠑⠧⠻ ⠁⠃⠳⠞ ⠹⠁⠞⠲ ⡹⠑ ⠗⠑⠛⠊⠌⠻ ⠕⠋ ⠙⠊⠎ ⠃⠥⠗⠊⠁⠇ ⠺⠁⠎
179 |   ⠎⠊⠛⠝⠫ ⠃⠹ ⠹⠑ ⠊⠇⠻⠛⠹⠍⠁⠝⠂ ⠹⠑ ⠊⠇⠻⠅⠂ ⠹⠑ ⠥⠝⠙⠻⠞⠁⠅⠻⠂
180 |   ⠁⠝⠙ ⠹⠑ ⠡⠊⠑⠋ ⠍⠳⠗⠝⠻⠲ ⡎⠊⠗⠕⠕⠛⠑ ⠎⠊⠛⠝⠫ ⠊⠞⠲ ⡁⠝⠙
181 |   ⡎⠊⠗⠕⠕⠛⠑⠰⠎ ⠝⠁⠍⠑ ⠺⠁⠎ ⠛⠕⠕⠙ ⠥⠏⠕⠝ ⠰⡡⠁⠝⠛⠑⠂ ⠋⠕⠗ ⠁⠝⠹⠹⠔⠛ ⠙⠑
182 |   ⠡⠕⠎⠑ ⠞⠕ ⠏⠥⠞ ⠙⠊⠎ ⠙⠁⠝⠙ ⠞⠕⠲
183 | 
184 |   ⡕⠇⠙ ⡍⠜⠇⠑⠹ ⠺⠁⠎ ⠁⠎ ⠙⠑⠁⠙ ⠁⠎ ⠁ ⠙⠕⠕⠗⠤⠝⠁⠊⠇⠲
185 | 
186 |   ⡍⠔⠙⠖ ⡊ ⠙⠕⠝⠰⠞ ⠍⠑⠁⠝ ⠞⠕ ⠎⠁⠹ ⠹⠁⠞ ⡊ ⠅⠝⠪⠂ ⠕⠋ ⠍⠹
187 |   ⠪⠝ ⠅⠝⠪⠇⠫⠛⠑⠂ ⠱⠁⠞ ⠹⠻⠑ ⠊⠎ ⠏⠜⠞⠊⠊⠥⠇⠜⠇⠹ ⠙⠑⠁⠙ ⠁⠃⠳⠞
188 |   ⠁ ⠙⠕⠕⠗⠤⠝⠁⠊⠇⠲ ⡊ ⠍⠊⠣⠞ ⠙⠁⠧⠑ ⠃⠑⠲ ⠔⠊⠇⠔⠫⠂ ⠍⠹⠎⠑⠇⠋⠂ ⠞⠕
189 |   ⠗⠑⠛⠜⠙ ⠁ ⠊⠕⠋⠋⠔⠤⠝⠁⠊⠇ ⠁⠎ ⠹⠑ ⠙⠑⠁⠙⠑⠌ ⠏⠊⠑⠊⠑ ⠕⠋ ⠊⠗⠕⠝⠍⠕⠝⠛⠻⠹
190 |   ⠔ ⠹⠑ ⠞⠗⠁⠙⠑⠲ ⡃⠥⠞ ⠹⠑ ⠺⠊⠎⠙⠕⠍ ⠕⠋ ⠳⠗ ⠁⠝⠊⠑⠌⠕⠗⠎
191 |   ⠊⠎ ⠔ ⠹⠑ ⠎⠊⠍⠊⠇⠑⠆ ⠁⠝⠙ ⠍⠹ ⠥⠝⠙⠁⠇⠇⠪⠫ ⠙⠁⠝⠙⠎
192 |   ⠩⠁⠇⠇ ⠝⠕⠞ ⠙⠊⠌⠥⠗⠃ ⠊⠞⠂ ⠕⠗ ⠹⠑ ⡊⠳⠝⠞⠗⠹⠰⠎ ⠙⠕⠝⠑ ⠋⠕⠗⠲ ⡹⠳
193 |   ⠺⠊⠇⠇ ⠹⠻⠑⠋⠕⠗⠑ ⠏⠻⠍⠊⠞ ⠍⠑ ⠞⠕ ⠗⠑⠏⠑⠁⠞⠂ ⠑⠍⠏⠙⠁⠞⠊⠊⠁⠇⠇⠹⠂ ⠹⠁⠞
194 |   ⡍⠜⠇⠑⠹ ⠺⠁⠎ ⠁⠎ ⠙⠑⠁⠙ ⠁⠎ ⠁ ⠙⠕⠕⠗⠤⠝⠁⠊⠇⠲
195 | 
196 |   (The first couple of paragraphs of "A Christmas Carol" by Dickens)
197 | 
198 | Compact font selection example text:
199 | 
200 |   ABCDEFGHIJKLMNOPQRSTUVWXYZ /0123456789
201 |   abcdefghijklmnopqrstuvwxyz £©µÀÆÖÞßéöÿ
202 |   –—‘“”„†•…‰™œŠŸž€ ΑΒΓΔΩαβγδω АБВГДабвгд
203 |   ∀∂∈ℝ∧∪≡∞ ↑↗↨↻⇣ ┐┼╔╘░►☺♀ fi�⑀₂ἠḂӥẄɐː⍎אԱა
204 | 
205 | Greetings in various languages:
206 | 
207 |   Hello world, Καλημέρα κόσμε, コンニチハ
208 | 
209 | Box drawing alignment tests:                                          █
210 |                                                                       ▉
211 |   ╔══╦══╗  ┌──┬──┐  ╭──┬──╮  ╭──┬──╮  ┏━━┳━━┓  ┎┒┏┑   ╷  ╻ ┏┯┓ ┌┰┐    ▊ ╱╲╱╲╳╳╳
212 |   ║┌─╨─┐║  │╔═╧═╗│  │╒═╪═╕│  │╓─╁─╖│  ┃┌─╂─┐┃  ┗╃╄┙  ╶┼╴╺╋╸┠┼┨ ┝╋┥    ▋ ╲╱╲╱╳╳╳
213 |   ║│╲ ╱│║  │║   ║│  ││ │ ││  │║ ┃ ║│  ┃│ ╿ │┃  ┍╅╆┓   ╵  ╹ ┗┷┛ └┸┘    ▌ ╱╲╱╲╳╳╳
214 |   ╠╡ ╳ ╞╣  ├╢   ╟┤  ├┼─┼─┼┤  ├╫─╂─╫┤  ┣┿╾┼╼┿┫  ┕┛┖┚     ┌┄┄┐ ╎ ┏┅┅┓ ┋ ▍ ╲╱╲╱╳╳╳
215 |   ║│╱ ╲│║  │║   ║│  ││ │ ││  │║ ┃ ║│  ┃│ ╽ │┃  ░░▒▒▓▓██ ┊  ┆ ╎ ╏  ┇ ┋ ▎
216 |   ║└─╥─┘║  │╚═╤═╝│  │╘═╪═╛│  │╙─╀─╜│  ┃└─╂─┘┃  ░░▒▒▓▓██ ┊  ┆ ╎ ╏  ┇ ┋ ▏
217 |   ╚══╩══╝  └──┴──┘  ╰──┴──╯  ╰──┴──╯  ┗━━┻━━┛  ▗▄▖▛▀▜   └╌╌┘ ╎ ┗╍╍┛ ┋  ▁▂▃▄▅▆▇█
218 |                                                ▝▀▘▙▄▟
219 | 
220 | 
221 | -------------------------------------------------------------------------------- /httpbin/static_assets.go: -------------------------------------------------------------------------------- 1 | package httpbin 2 | 3 | import ( 4 | "bytes" 5 | "embed" 6 | "path" 7 | "text/template" 8 | ) 9 | 10 | //go:embed static/* 11 | var staticAssets embed.FS 12 | 13 | // staticAsset loads an embedded static asset by name. 14 | func staticAsset(name string) ([]byte, error) { 15 | return staticAssets.ReadFile(path.Join("static", name)) 16 | } 17 | 18 | // mustStaticAsset loads an embedded static asset by name, panicking on error. 19 | func mustStaticAsset(name string) []byte { 20 | b, err := staticAsset(name) 21 | if err != nil { 22 | panic(err) 23 | } 24 | return b 25 | } 26 | 27 | func mustRenderTemplate(name string, data any) []byte { 28 | t := template.Must(template.New(name).Parse(string(mustStaticAsset(name)))).Option("missingkey=error") 29 | var buf bytes.Buffer 30 | if err := t.Execute(&buf, data); err != nil { 31 | panic(err) 32 | } 33 | return buf.Bytes() 34 | } 35 | -------------------------------------------------------------------------------- /httpbin/static_assets_test.go: -------------------------------------------------------------------------------- 1 | package httpbin 2 | 3 | import "testing" 4 | 5 | // Silly tests just to increase code coverage scores 6 | 7 | func TestMustStaticAsset(t *testing.T) { 8 | defer func() { 9 | // recover from panic if one occured. Set err to nil otherwise. 10 | if err := recover(); err == nil { 11 | t.Fatalf("expected to recover from panic, got nil") 12 | } 13 | }() 14 | mustStaticAsset("xxxyyyzzz") 15 | } 16 | 17 | func TestMustRenderTemplate(t *testing.T) { 18 | t.Run("invalid template name", func(t *testing.T) { 19 | defer func() { 20 | // recover from panic if one occured. Set err to nil otherwise. 21 | if err := recover(); err == nil { 22 | t.Fatalf("expected to recover from panic, got nil") 23 | } 24 | }() 25 | mustRenderTemplate("xxxyyyzzz", nil) 26 | }) 27 | 28 | t.Run("invalid template data", func(t *testing.T) { 29 | defer func() { 30 | // recover from panic if one occured. Set err to nil otherwise. 31 | if err := recover(); err == nil { 32 | t.Fatalf("expected to recover from panic, got nil") 33 | } 34 | }() 35 | mustRenderTemplate("index.html.tmpl", nil) 36 | }) 37 | } 38 | -------------------------------------------------------------------------------- /httpbin/websocket/websocket.go: -------------------------------------------------------------------------------- 1 | // Package websocket implements a basic websocket server. 2 | package websocket 3 | 4 | import ( 5 | "bufio" 6 | "context" 7 | "crypto/sha1" 8 | "encoding/base64" 9 | "encoding/binary" 10 | "errors" 11 | "fmt" 12 | "io" 13 | "net/http" 14 | "strings" 15 | "time" 16 | "unicode/utf8" 17 | ) 18 | 19 | const requiredVersion = "13" 20 | 21 | // Opcode is a websocket OPCODE. 22 | type Opcode uint8 23 | 24 | // See the RFC for the set of defined opcodes: 25 | // https://datatracker.ietf.org/doc/html/rfc6455#section-5.2 26 | const ( 27 | OpcodeContinuation Opcode = 0x0 28 | OpcodeText Opcode = 0x1 29 | OpcodeBinary Opcode = 0x2 30 | OpcodeClose Opcode = 0x8 31 | OpcodePing Opcode = 0x9 32 | OpcodePong Opcode = 0xA 33 | ) 34 | 35 | // StatusCode is a websocket status code. 36 | type StatusCode uint16 37 | 38 | // See the RFC for the set of defined status codes: 39 | // https://datatracker.ietf.org/doc/html/rfc6455#section-7.4.1 40 | const ( 41 | StatusNormalClosure StatusCode = 1000 42 | StatusGoingAway StatusCode = 1001 43 | StatusProtocolError StatusCode = 1002 44 | StatusUnsupported StatusCode = 1003 45 | StatusNoStatusRcvd StatusCode = 1005 46 | StatusAbnormalClose StatusCode = 1006 47 | StatusUnsupportedPayload StatusCode = 1007 48 | StatusPolicyViolation StatusCode = 1008 49 | StatusTooLarge StatusCode = 1009 50 | StatusTlSHandshake StatusCode = 1015 51 | StatusServerError StatusCode = 1011 52 | ) 53 | 54 | // Frame is a websocket protocol frame. 55 | type Frame struct { 56 | Fin bool 57 | RSV1 bool 58 | RSV3 bool 59 | RSV2 bool 60 | Opcode Opcode 61 | Payload []byte 62 | } 63 | 64 | // Message is an application-level message from the client, which may be 65 | // constructed from one or more individual protocol frames. 66 | type Message struct { 67 | Binary bool 68 | Payload []byte 69 | } 70 | 71 | // Handler handles a single websocket message. If the returned message is 72 | // non-nil, it will be sent to the client. If an error is returned, the 73 | // connection will be closed. 74 | type Handler func(ctx context.Context, msg *Message) (*Message, error) 75 | 76 | // EchoHandler is a Handler that echoes each incoming message back to the 77 | // client. 78 | var EchoHandler Handler = func(_ context.Context, msg *Message) (*Message, error) { 79 | return msg, nil 80 | } 81 | 82 | // Limits define the limits imposed on a websocket connection. 83 | type Limits struct { 84 | MaxDuration time.Duration 85 | MaxFragmentSize int 86 | MaxMessageSize int 87 | } 88 | 89 | // WebSocket is a websocket connection. 90 | type WebSocket struct { 91 | w http.ResponseWriter 92 | r *http.Request 93 | maxDuration time.Duration 94 | maxFragmentSize int 95 | maxMessageSize int 96 | handshook bool 97 | } 98 | 99 | // New creates a new websocket. 100 | func New(w http.ResponseWriter, r *http.Request, limits Limits) *WebSocket { 101 | return &WebSocket{ 102 | w: w, 103 | r: r, 104 | maxDuration: limits.MaxDuration, 105 | maxFragmentSize: limits.MaxFragmentSize, 106 | maxMessageSize: limits.MaxMessageSize, 107 | } 108 | } 109 | 110 | // Handshake validates the request and performs the WebSocket handshake. If 111 | // Handshake returns nil, only websocket frames should be written to the 112 | // response writer. 113 | func (s *WebSocket) Handshake() error { 114 | if s.handshook { 115 | panic("websocket: handshake already completed") 116 | } 117 | 118 | if strings.ToLower(s.r.Header.Get("Upgrade")) != "websocket" { 119 | return fmt.Errorf("missing required `Upgrade: websocket` header") 120 | } 121 | if v := s.r.Header.Get("Sec-Websocket-Version"); v != requiredVersion { 122 | return fmt.Errorf("only websocket version %q is supported, got %q", requiredVersion, v) 123 | } 124 | 125 | clientKey := s.r.Header.Get("Sec-Websocket-Key") 126 | if clientKey == "" { 127 | return fmt.Errorf("missing required `Sec-Websocket-Key` header") 128 | } 129 | 130 | s.w.Header().Set("Connection", "upgrade") 131 | s.w.Header().Set("Upgrade", "websocket") 132 | s.w.Header().Set("Sec-Websocket-Accept", acceptKey(clientKey)) 133 | s.w.WriteHeader(http.StatusSwitchingProtocols) 134 | 135 | s.handshook = true 136 | return nil 137 | } 138 | 139 | // Serve handles a websocket connection after the handshake has been completed. 140 | func (s *WebSocket) Serve(handler Handler) { 141 | if !s.handshook { 142 | panic("websocket: serve: handshake not completed") 143 | } 144 | 145 | hj, ok := s.w.(http.Hijacker) 146 | if !ok { 147 | panic("websocket: serve: server does not support hijacking") 148 | } 149 | 150 | conn, buf, err := hj.Hijack() 151 | if err != nil { 152 | panic(fmt.Errorf("websocket: serve: hijack failed: %s", err)) 153 | } 154 | defer conn.Close() 155 | 156 | // best effort attempt to ensure that our websocket conenctions do not 157 | // exceed the maximum request duration 158 | conn.SetDeadline(time.Now().Add(s.maxDuration)) 159 | 160 | // errors intentionally ignored here. it's serverLoop's responsibility to 161 | // properly close the websocket connection with a useful error message, and 162 | // any unexpected error returned from serverLoop is not actionable. 163 | _ = s.serveLoop(s.r.Context(), buf, handler) 164 | } 165 | 166 | func (s *WebSocket) serveLoop(ctx context.Context, buf *bufio.ReadWriter, handler Handler) error { 167 | var currentMsg *Message 168 | 169 | for { 170 | select { 171 | case <-ctx.Done(): 172 | return nil 173 | default: 174 | } 175 | 176 | frame, err := nextFrame(buf) 177 | if err != nil { 178 | return writeCloseFrame(buf, StatusServerError, err) 179 | } 180 | 181 | if err := validateFrame(frame, s.maxFragmentSize); err != nil { 182 | return writeCloseFrame(buf, StatusProtocolError, err) 183 | } 184 | 185 | switch frame.Opcode { 186 | case OpcodeBinary, OpcodeText: 187 | if currentMsg != nil { 188 | return writeCloseFrame(buf, StatusProtocolError, errors.New("expected continuation frame")) 189 | } 190 | if frame.Opcode == OpcodeText && !utf8.Valid(frame.Payload) { 191 | return writeCloseFrame(buf, StatusUnsupportedPayload, errors.New("invalid UTF-8")) 192 | } 193 | currentMsg = &Message{ 194 | Binary: frame.Opcode == OpcodeBinary, 195 | Payload: frame.Payload, 196 | } 197 | case OpcodeContinuation: 198 | if currentMsg == nil { 199 | return writeCloseFrame(buf, StatusProtocolError, errors.New("unexpected continuation frame")) 200 | } 201 | if !currentMsg.Binary && !utf8.Valid(frame.Payload) { 202 | return writeCloseFrame(buf, StatusUnsupportedPayload, errors.New("invalid UTF-8")) 203 | } 204 | currentMsg.Payload = append(currentMsg.Payload, frame.Payload...) 205 | if len(currentMsg.Payload) > s.maxMessageSize { 206 | return writeCloseFrame(buf, StatusTooLarge, fmt.Errorf("message size %d exceeds maximum of %d bytes", len(currentMsg.Payload), s.maxMessageSize)) 207 | } 208 | case OpcodeClose: 209 | return writeCloseFrame(buf, StatusNormalClosure, nil) 210 | case OpcodePing: 211 | frame.Opcode = OpcodePong 212 | if err := writeFrame(buf, frame); err != nil { 213 | return err 214 | } 215 | continue 216 | case OpcodePong: 217 | continue 218 | default: 219 | return writeCloseFrame(buf, StatusProtocolError, fmt.Errorf("unsupported opcode: %v", frame.Opcode)) 220 | } 221 | 222 | if frame.Fin { 223 | resp, err := handler(ctx, currentMsg) 224 | if err != nil { 225 | return writeCloseFrame(buf, StatusServerError, err) 226 | } 227 | if resp == nil { 228 | continue 229 | } 230 | for _, respFrame := range frameResponse(resp, s.maxFragmentSize) { 231 | if err := writeFrame(buf, respFrame); err != nil { 232 | return err 233 | } 234 | } 235 | currentMsg = nil 236 | } 237 | } 238 | } 239 | 240 | func nextFrame(buf *bufio.ReadWriter) (*Frame, error) { 241 | bb := make([]byte, 2) 242 | if _, err := io.ReadFull(buf, bb); err != nil { 243 | return nil, err 244 | } 245 | 246 | b0 := bb[0] 247 | b1 := bb[1] 248 | 249 | var ( 250 | fin = b0&0b10000000 != 0 251 | rsv1 = b0&0b01000000 != 0 252 | rsv2 = b0&0b00100000 != 0 253 | rsv3 = b0&0b00010000 != 0 254 | opcode = Opcode(b0 & 0b00001111) 255 | ) 256 | 257 | // Per https://datatracker.ietf.org/doc/html/rfc6455#section-5.2, all 258 | // client frames must be masked. 259 | if masked := b1 & 0b10000000; masked == 0 { 260 | return nil, fmt.Errorf("received unmasked client frame") 261 | } 262 | 263 | var payloadLength uint64 264 | switch { 265 | case b1-128 <= 125: 266 | // Payload length is directly represented in the second byte 267 | payloadLength = uint64(b1 - 128) 268 | case b1-128 == 126: 269 | // Payload length is represented in the next 2 bytes (16-bit unsigned integer) 270 | var l uint16 271 | if err := binary.Read(buf, binary.BigEndian, &l); err != nil { 272 | return nil, err 273 | } 274 | payloadLength = uint64(l) 275 | case b1-128 == 127: 276 | // Payload length is represented in the next 8 bytes (64-bit unsigned integer) 277 | if err := binary.Read(buf, binary.BigEndian, &payloadLength); err != nil { 278 | return nil, err 279 | } 280 | } 281 | 282 | mask := make([]byte, 4) 283 | if _, err := io.ReadFull(buf, mask); err != nil { 284 | return nil, err 285 | } 286 | 287 | payload := make([]byte, payloadLength) 288 | if _, err := io.ReadFull(buf, payload); err != nil { 289 | return nil, err 290 | } 291 | 292 | for i, b := range payload { 293 | payload[i] = b ^ mask[i%4] 294 | } 295 | 296 | return &Frame{ 297 | Fin: fin, 298 | RSV1: rsv1, 299 | RSV2: rsv2, 300 | RSV3: rsv3, 301 | Opcode: opcode, 302 | Payload: payload, 303 | }, nil 304 | } 305 | 306 | func writeFrame(dst *bufio.ReadWriter, frame *Frame) error { 307 | // FIN, RSV1-3, OPCODE 308 | var b1 byte 309 | if frame.Fin { 310 | b1 |= 0b10000000 311 | } 312 | if frame.RSV1 { 313 | b1 |= 0b01000000 314 | } 315 | if frame.RSV2 { 316 | b1 |= 0b00100000 317 | } 318 | if frame.RSV3 { 319 | b1 |= 0b00010000 320 | } 321 | b1 |= uint8(frame.Opcode) & 0b00001111 322 | if err := dst.WriteByte(b1); err != nil { 323 | return err 324 | } 325 | 326 | // payload length 327 | payloadLen := int64(len(frame.Payload)) 328 | switch { 329 | case payloadLen <= 125: 330 | if err := dst.WriteByte(byte(payloadLen)); err != nil { 331 | return err 332 | } 333 | case payloadLen <= 65535: 334 | if err := dst.WriteByte(126); err != nil { 335 | return err 336 | } 337 | if err := binary.Write(dst, binary.BigEndian, uint16(payloadLen)); err != nil { 338 | return err 339 | } 340 | default: 341 | if err := dst.WriteByte(127); err != nil { 342 | return err 343 | } 344 | if err := binary.Write(dst, binary.BigEndian, payloadLen); err != nil { 345 | return err 346 | } 347 | } 348 | 349 | // payload 350 | if _, err := dst.Write(frame.Payload); err != nil { 351 | return err 352 | } 353 | 354 | return dst.Flush() 355 | } 356 | 357 | // writeCloseFrame writes a close frame to the wire, with an optional error 358 | // message. 359 | func writeCloseFrame(dst *bufio.ReadWriter, code StatusCode, err error) error { 360 | var payload []byte 361 | payload = binary.BigEndian.AppendUint16(payload, uint16(code)) 362 | if err != nil { 363 | payload = append(payload, []byte(err.Error())...) 364 | } 365 | return writeFrame(dst, &Frame{ 366 | Fin: true, 367 | Opcode: OpcodeClose, 368 | Payload: payload, 369 | }) 370 | } 371 | 372 | // frameResponse splits a message into N frames with payloads of at most 373 | // fragmentSize bytes. 374 | func frameResponse(msg *Message, fragmentSize int) []*Frame { 375 | var result []*Frame 376 | 377 | fin := false 378 | opcode := OpcodeText 379 | if msg.Binary { 380 | opcode = OpcodeBinary 381 | } 382 | 383 | offset := 0 384 | dataLen := len(msg.Payload) 385 | for { 386 | if offset > 0 { 387 | opcode = OpcodeContinuation 388 | } 389 | end := offset + fragmentSize 390 | if end >= dataLen { 391 | fin = true 392 | end = dataLen 393 | } 394 | result = append(result, &Frame{ 395 | Fin: fin, 396 | Opcode: opcode, 397 | Payload: msg.Payload[offset:end], 398 | }) 399 | if fin { 400 | break 401 | } 402 | } 403 | return result 404 | } 405 | 406 | var reservedStatusCodes = map[uint16]bool{ 407 | // Explicitly reserved by RFC section 7.4.1 Defined Status Codes: 408 | // https://datatracker.ietf.org/doc/html/rfc6455#section-7.4.1 409 | 1004: true, 410 | 1005: true, 411 | 1006: true, 412 | 1015: true, 413 | // Apparently reserved, according to the autobahn testsuite's fuzzingclient 414 | // tests, though it's not clear to me why, based on the RFC. 415 | // 416 | // See: https://github.com/crossbario/autobahn-testsuite 417 | 1016: true, 418 | 1100: true, 419 | 2000: true, 420 | 2999: true, 421 | } 422 | 423 | func validateFrame(frame *Frame, maxFragmentSize int) error { 424 | // We do not support any extensions, per the spec all RSV bits must be 0: 425 | // https://datatracker.ietf.org/doc/html/rfc6455#section-5.2 426 | if frame.RSV1 || frame.RSV2 || frame.RSV3 { 427 | return fmt.Errorf("frame has unsupported RSV bits set") 428 | } 429 | 430 | switch frame.Opcode { 431 | case OpcodeContinuation, OpcodeText, OpcodeBinary: 432 | if len(frame.Payload) > maxFragmentSize { 433 | return fmt.Errorf("frame payload size %d exceeds maximum of %d bytes", len(frame.Payload), maxFragmentSize) 434 | } 435 | case OpcodeClose, OpcodePing, OpcodePong: 436 | // All control frames MUST have a payload length of 125 bytes or less 437 | // and MUST NOT be fragmented. 438 | // https://datatracker.ietf.org/doc/html/rfc6455#section-5.5 439 | if len(frame.Payload) > 125 { 440 | return fmt.Errorf("frame payload size %d exceeds 125 bytes", len(frame.Payload)) 441 | } 442 | if !frame.Fin { 443 | return fmt.Errorf("control frame %v must not be fragmented", frame.Opcode) 444 | } 445 | } 446 | 447 | if frame.Opcode == OpcodeClose { 448 | if len(frame.Payload) == 0 { 449 | return nil 450 | } 451 | if len(frame.Payload) == 1 { 452 | return fmt.Errorf("close frame payload must be at least 2 bytes") 453 | } 454 | 455 | code := binary.BigEndian.Uint16(frame.Payload[:2]) 456 | if code < 1000 || code >= 5000 { 457 | return fmt.Errorf("close frame status code %d out of range", code) 458 | } 459 | if reservedStatusCodes[code] { 460 | return fmt.Errorf("close frame status code %d is reserved", code) 461 | } 462 | 463 | if len(frame.Payload) > 2 { 464 | if !utf8.Valid(frame.Payload[2:]) { 465 | return errors.New("close frame payload must be vaid UTF-8") 466 | } 467 | } 468 | } 469 | 470 | return nil 471 | } 472 | 473 | func acceptKey(clientKey string) string { 474 | // Magic value comes from RFC 6455 section 1.3: Opening Handshake 475 | // https://www.rfc-editor.org/rfc/rfc6455#section-1.3 476 | h := sha1.New() 477 | io.WriteString(h, clientKey+"258EAFA5-E914-47DA-95CA-C5AB0DC85B11") 478 | return base64.StdEncoding.EncodeToString(h.Sum(nil)) 479 | } 480 | -------------------------------------------------------------------------------- /httpbin/websocket/websocket_autobahn_test.go: -------------------------------------------------------------------------------- 1 | package websocket_test 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "net/http" 7 | "net/http/httptest" 8 | "net/url" 9 | "os" 10 | "os/exec" 11 | "path" 12 | "path/filepath" 13 | "runtime" 14 | "strings" 15 | "testing" 16 | "time" 17 | 18 | "github.com/mccutchen/go-httpbin/v2/httpbin/websocket" 19 | "github.com/mccutchen/go-httpbin/v2/internal/testing/assert" 20 | ) 21 | 22 | const autobahnImage = "crossbario/autobahn-testsuite:0.8.2" 23 | 24 | var defaultIncludedTestCases = []string{ 25 | "*", 26 | } 27 | 28 | var defaultExcludedTestCases = []string{ 29 | // These cases all seem to rely on the server accepting fragmented text 30 | // frames with invalid utf8 payloads, but the spec seems to indicate that 31 | // every text fragment must be valid utf8 on its own. 32 | "6.2.3", 33 | "6.2.4", 34 | "6.4.2", 35 | 36 | // Compression extensions are not supported 37 | "12.*", 38 | "13.*", 39 | } 40 | 41 | func TestWebSocketServer(t *testing.T) { 42 | t.Parallel() 43 | 44 | if os.Getenv("AUTOBAHN_TESTS") != "1" { 45 | t.Skipf("set AUTOBAHN_TESTS=1 to run autobahn integration tests") 46 | } 47 | 48 | includedTestCases := defaultIncludedTestCases 49 | excludedTestCases := defaultExcludedTestCases 50 | if userTestCases := os.Getenv("AUTOBAHN_CASES"); userTestCases != "" { 51 | t.Logf("using AUTOBAHN_CASES=%q", userTestCases) 52 | includedTestCases = strings.Split(userTestCases, ",") 53 | excludedTestCases = []string{} 54 | } 55 | 56 | srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 57 | ws := websocket.New(w, r, websocket.Limits{ 58 | MaxDuration: 30 * time.Second, 59 | MaxFragmentSize: 1024 * 1024 * 16, 60 | MaxMessageSize: 1024 * 1024 * 16, 61 | }) 62 | if err := ws.Handshake(); err != nil { 63 | http.Error(w, err.Error(), http.StatusBadRequest) 64 | return 65 | } 66 | ws.Serve(websocket.EchoHandler) 67 | })) 68 | defer srv.Close() 69 | 70 | testDir := newTestDir(t) 71 | t.Logf("test dir: %s", testDir) 72 | 73 | targetURL := newAutobahnTargetURL(t, srv) 74 | t.Logf("target url: %s", targetURL) 75 | 76 | autobahnCfg := map[string]any{ 77 | "servers": []map[string]string{ 78 | { 79 | "agent": "go-httpbin", 80 | "url": targetURL, 81 | }, 82 | }, 83 | "outdir": "/testdir/report", 84 | "cases": includedTestCases, 85 | "exclude-cases": excludedTestCases, 86 | } 87 | 88 | autobahnCfgFile, err := os.Create(path.Join(testDir, "autobahn.json")) 89 | assert.NilError(t, err) 90 | assert.NilError(t, json.NewEncoder(autobahnCfgFile).Encode(autobahnCfg)) 91 | autobahnCfgFile.Close() 92 | 93 | pullCmd := exec.Command("docker", "pull", autobahnImage) 94 | runCmd(t, pullCmd) 95 | 96 | testCmd := exec.Command( 97 | "docker", 98 | "run", 99 | "--net=host", 100 | "--rm", 101 | "-v", testDir+":/testdir:rw", 102 | autobahnImage, 103 | "wstest", "-m", "fuzzingclient", "--spec", "/testdir/autobahn.json", 104 | ) 105 | runCmd(t, testCmd) 106 | 107 | summary := loadSummary(t, testDir) 108 | if len(summary) == 0 { 109 | t.Fatalf("empty autobahn test summary; check autobahn logs for problems connecting to test server at %q", targetURL) 110 | } 111 | 112 | for _, results := range summary { 113 | for caseName, result := range results { 114 | result := result 115 | t.Run("autobahn/"+caseName, func(t *testing.T) { 116 | if result.Behavior == "FAILED" || result.BehaviorClose == "FAILED" { 117 | report := loadReport(t, testDir, result.ReportFile) 118 | t.Errorf("description: %s", report.Description) 119 | t.Errorf("expectation: %s", report.Expectation) 120 | t.Errorf("result: %s", report.Result) 121 | t.Errorf("close: %s", report.ResultClose) 122 | } 123 | }) 124 | } 125 | } 126 | 127 | t.Logf("autobahn test report: %s", path.Join(testDir, "report/index.html")) 128 | if os.Getenv("AUTOBAHN_OPEN_REPORT") != "" { 129 | runCmd(t, exec.Command("open", path.Join(testDir, "report/index.html"))) 130 | } 131 | } 132 | 133 | // newAutobahnTargetURL returns the URL that the autobahn test suite should use 134 | // to connect to the given httptest server. 135 | // 136 | // On Macs, the docker engine is running inside an implicit VM, so even with 137 | // --net=host, we need to use the special hostname to escape the VM. 138 | // 139 | // See the Docker Desktop docs[1] for more information. This same special 140 | // hostname seems to work across Docker Desktop for Mac, OrbStack, and Colima. 141 | // 142 | // [1]: https://docs.docker.com/desktop/networking/#i-want-to-connect-from-a-container-to-a-service-on-the-host 143 | func newAutobahnTargetURL(t *testing.T, srv *httptest.Server) string { 144 | t.Helper() 145 | u, err := url.Parse(srv.URL) 146 | assert.NilError(t, err) 147 | 148 | var host string 149 | switch runtime.GOOS { 150 | case "darwin": 151 | host = "host.docker.internal" 152 | default: 153 | host = "127.0.0.1" 154 | } 155 | 156 | return fmt.Sprintf("ws://%s:%s/websocket/echo", host, u.Port()) 157 | } 158 | 159 | func runCmd(t *testing.T, cmd *exec.Cmd) { 160 | t.Helper() 161 | t.Logf("running command: %s", cmd.String()) 162 | cmd.Stdout = os.Stderr 163 | cmd.Stderr = os.Stderr 164 | assert.NilError(t, cmd.Run()) 165 | } 166 | 167 | func newTestDir(t *testing.T) string { 168 | t.Helper() 169 | 170 | // package tests are run with the package as the working directory, but we 171 | // want to store our integration test output in the repo root 172 | testDir, err := filepath.Abs(path.Join( 173 | "..", "..", ".integrationtests", fmt.Sprintf("autobahn-test-%d", time.Now().Unix()), 174 | )) 175 | 176 | assert.NilError(t, err) 177 | assert.NilError(t, os.MkdirAll(testDir, 0o755)) 178 | return testDir 179 | } 180 | 181 | func loadSummary(t *testing.T, testDir string) autobahnReportSummary { 182 | t.Helper() 183 | f, err := os.Open(path.Join(testDir, "report", "index.json")) 184 | assert.NilError(t, err) 185 | defer f.Close() 186 | var summary autobahnReportSummary 187 | assert.NilError(t, json.NewDecoder(f).Decode(&summary)) 188 | return summary 189 | } 190 | 191 | func loadReport(t *testing.T, testDir string, reportFile string) autobahnReportResult { 192 | t.Helper() 193 | reportPath := path.Join(testDir, "report", reportFile) 194 | t.Logf("report path: %s", reportPath) 195 | f, err := os.Open(reportPath) 196 | assert.NilError(t, err) 197 | var report autobahnReportResult 198 | assert.NilError(t, json.NewDecoder(f).Decode(&report)) 199 | return report 200 | } 201 | 202 | type autobahnReportSummary map[string]map[string]autobahnReportResult 203 | 204 | type autobahnReportResult struct { 205 | Behavior string `json:"behavior"` 206 | BehaviorClose string `json:"behaviorClose"` 207 | Description string `json:"description"` 208 | Expectation string `json:"expectation"` 209 | ReportFile string `json:"reportfile"` 210 | Result string `json:"result"` 211 | ResultClose string `json:"resultClose"` 212 | } 213 | -------------------------------------------------------------------------------- /httpbin/websocket/websocket_test.go: -------------------------------------------------------------------------------- 1 | package websocket_test 2 | 3 | import ( 4 | "bufio" 5 | "fmt" 6 | "io" 7 | "net" 8 | "net/http" 9 | "net/http/httptest" 10 | "os" 11 | "strings" 12 | "sync" 13 | "testing" 14 | "time" 15 | 16 | "github.com/mccutchen/go-httpbin/v2/httpbin/websocket" 17 | "github.com/mccutchen/go-httpbin/v2/internal/testing/assert" 18 | ) 19 | 20 | func TestHandshake(t *testing.T) { 21 | testCases := map[string]struct { 22 | reqHeaders map[string]string 23 | wantStatus int 24 | wantRespHeaders map[string]string 25 | }{ 26 | "valid handshake": { 27 | reqHeaders: map[string]string{ 28 | "Connection": "upgrade", 29 | "Upgrade": "websocket", 30 | "Sec-WebSocket-Key": "dGhlIHNhbXBsZSBub25jZQ==", 31 | "Sec-WebSocket-Version": "13", 32 | }, 33 | wantRespHeaders: map[string]string{ 34 | "Connection": "upgrade", 35 | "Upgrade": "websocket", 36 | "Sec-Websocket-Accept": "s3pPLMBiTxaQ9kYGzzhZRbK+xOo=", 37 | }, 38 | wantStatus: http.StatusSwitchingProtocols, 39 | }, 40 | "valid handshake, header values case insensitive": { 41 | reqHeaders: map[string]string{ 42 | "Connection": "Upgrade", 43 | "Upgrade": "WebSocket", 44 | "Sec-WebSocket-Key": "dGhlIHNhbXBsZSBub25jZQ==", 45 | "Sec-WebSocket-Version": "13", 46 | }, 47 | wantRespHeaders: map[string]string{ 48 | "Connection": "upgrade", 49 | "Upgrade": "websocket", 50 | "Sec-Websocket-Accept": "s3pPLMBiTxaQ9kYGzzhZRbK+xOo=", 51 | }, 52 | wantStatus: http.StatusSwitchingProtocols, 53 | }, 54 | "missing Connection header is okay": { 55 | reqHeaders: map[string]string{ 56 | "Upgrade": "websocket", 57 | "Sec-WebSocket-Key": "dGhlIHNhbXBsZSBub25jZQ==", 58 | "Sec-WebSocket-Version": "13", 59 | }, 60 | wantStatus: http.StatusSwitchingProtocols, 61 | }, 62 | "incorrect Connection header is also okay": { 63 | reqHeaders: map[string]string{ 64 | "Connection": "foo", 65 | "Upgrade": "websocket", 66 | "Sec-WebSocket-Key": "dGhlIHNhbXBsZSBub25jZQ==", 67 | "Sec-WebSocket-Version": "13", 68 | }, 69 | wantStatus: http.StatusSwitchingProtocols, 70 | }, 71 | "missing Upgrade header": { 72 | reqHeaders: map[string]string{ 73 | "Connection": "Upgrade", 74 | "Sec-WebSocket-Key": "dGhlIHNhbXBsZSBub25jZQ==", 75 | "Sec-WebSocket-Version": "13", 76 | }, 77 | wantStatus: http.StatusBadRequest, 78 | }, 79 | "incorrect Upgrade header": { 80 | reqHeaders: map[string]string{ 81 | "Connection": "Upgrade", 82 | "Upgrade": "http/2", 83 | "Sec-WebSocket-Key": "dGhlIHNhbXBsZSBub25jZQ==", 84 | "Sec-WebSocket-Version": "13", 85 | }, 86 | wantStatus: http.StatusBadRequest, 87 | }, 88 | "missing version": { 89 | reqHeaders: map[string]string{ 90 | "Connection": "upgrade", 91 | "Upgrade": "websocket", 92 | "Sec-WebSocket-Key": "dGhlIHNhbXBsZSBub25jZQ==", 93 | }, 94 | wantStatus: http.StatusBadRequest, 95 | }, 96 | "incorrect version": { 97 | reqHeaders: map[string]string{ 98 | "Connection": "upgrade", 99 | "Upgrade": "websocket", 100 | "Sec-WebSocket-Key": "dGhlIHNhbXBsZSBub25jZQ==", 101 | "Sec-WebSocket-Version": "12", 102 | }, 103 | wantStatus: http.StatusBadRequest, 104 | }, 105 | "missing Sec-WebSocket-Key": { 106 | reqHeaders: map[string]string{ 107 | "Connection": "upgrade", 108 | "Upgrade": "websocket", 109 | "Sec-WebSocket-Version": "13", 110 | }, 111 | wantStatus: http.StatusBadRequest, 112 | }, 113 | } 114 | for name, tc := range testCases { 115 | tc := tc 116 | t.Run(name, func(t *testing.T) { 117 | t.Parallel() 118 | 119 | srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 120 | ws := websocket.New(w, r, websocket.Limits{}) 121 | if err := ws.Handshake(); err != nil { 122 | http.Error(w, err.Error(), http.StatusBadRequest) 123 | return 124 | } 125 | ws.Serve(websocket.EchoHandler) 126 | })) 127 | defer srv.Close() 128 | 129 | req, _ := http.NewRequest(http.MethodGet, srv.URL, nil) 130 | for k, v := range tc.reqHeaders { 131 | req.Header.Set(k, v) 132 | } 133 | 134 | resp, err := http.DefaultClient.Do(req) 135 | assert.NilError(t, err) 136 | 137 | assert.StatusCode(t, resp, tc.wantStatus) 138 | for k, v := range tc.wantRespHeaders { 139 | assert.Equal(t, resp.Header.Get(k), v, "incorrect value for %q response header", k) 140 | } 141 | }) 142 | } 143 | } 144 | 145 | func TestHandshakeOrder(t *testing.T) { 146 | handshakeReq := httptest.NewRequest(http.MethodGet, "/websocket/echo", nil) 147 | for k, v := range map[string]string{ 148 | "Connection": "upgrade", 149 | "Upgrade": "websocket", 150 | "Sec-WebSocket-Key": "dGhlIHNhbXBsZSBub25jZQ==", 151 | "Sec-WebSocket-Version": "13", 152 | } { 153 | handshakeReq.Header.Set(k, v) 154 | } 155 | 156 | t.Run("double handshake", func(t *testing.T) { 157 | w := httptest.NewRecorder() 158 | ws := websocket.New(w, handshakeReq, websocket.Limits{}) 159 | 160 | // first handshake succeeds 161 | assert.NilError(t, ws.Handshake()) 162 | assert.Equal(t, w.Code, http.StatusSwitchingProtocols, "incorrect status code") 163 | 164 | // second handshake fails 165 | defer func() { 166 | r := recover() 167 | if r == nil { 168 | t.Fatalf("expected to catch panic on double handshake") 169 | } 170 | assert.Equal(t, fmt.Sprint(r), "websocket: handshake already completed", "incorrect panic message") 171 | }() 172 | ws.Handshake() 173 | }) 174 | 175 | t.Run("handshake not completed", func(t *testing.T) { 176 | defer func() { 177 | r := recover() 178 | if r == nil { 179 | t.Fatalf("expected to catch panic on Serve before Handshake") 180 | } 181 | assert.Equal(t, fmt.Sprint(r), "websocket: serve: handshake not completed", "incorrect panic message") 182 | }() 183 | w := httptest.NewRecorder() 184 | websocket.New(w, handshakeReq, websocket.Limits{}).Serve(nil) 185 | }) 186 | 187 | t.Run("http.Hijack not implemented", func(t *testing.T) { 188 | // confirm that httptest.ResponseRecorder does not implmeent 189 | // http.Hjijacker 190 | var rw http.ResponseWriter = httptest.NewRecorder() 191 | _, ok := rw.(http.Hijacker) 192 | assert.Equal(t, ok, false, "expected httptest.ResponseRecorder not to implement http.Hijacker") 193 | 194 | w := httptest.NewRecorder() 195 | ws := websocket.New(w, handshakeReq, websocket.Limits{}) 196 | 197 | assert.NilError(t, ws.Handshake()) 198 | assert.Equal(t, w.Code, http.StatusSwitchingProtocols, "incorrect status code") 199 | 200 | defer func() { 201 | r := recover() 202 | if r == nil { 203 | t.Fatalf("expected to catch panic on when http.Hijack not implemented") 204 | } 205 | assert.Equal(t, fmt.Sprint(r), "websocket: serve: server does not support hijacking", "incorrect panic message") 206 | }() 207 | ws.Serve(nil) 208 | }) 209 | 210 | t.Run("hijack failed", func(t *testing.T) { 211 | w := &brokenHijackResponseWriter{} 212 | ws := websocket.New(w, handshakeReq, websocket.Limits{}) 213 | 214 | assert.NilError(t, ws.Handshake()) 215 | assert.Equal(t, w.Code, http.StatusSwitchingProtocols, "incorrect status code") 216 | 217 | defer func() { 218 | r := recover() 219 | if r == nil { 220 | t.Fatalf("expected to catch panic on Serve before Handshake") 221 | } 222 | assert.Equal(t, fmt.Sprint(r), "websocket: serve: hijack failed: error hijacking connection", "incorrect panic message") 223 | }() 224 | ws.Serve(nil) 225 | }) 226 | } 227 | 228 | func TestConnectionLimits(t *testing.T) { 229 | t.Run("maximum request duration is enforced", func(t *testing.T) { 230 | t.Parallel() 231 | 232 | maxDuration := 500 * time.Millisecond 233 | 234 | srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 235 | ws := websocket.New(w, r, websocket.Limits{ 236 | MaxDuration: maxDuration, 237 | // TODO: test these limits as well 238 | MaxFragmentSize: 128, 239 | MaxMessageSize: 256, 240 | }) 241 | if err := ws.Handshake(); err != nil { 242 | http.Error(w, err.Error(), http.StatusBadRequest) 243 | return 244 | } 245 | ws.Serve(websocket.EchoHandler) 246 | })) 247 | defer srv.Close() 248 | 249 | conn, err := net.Dial("tcp", srv.Listener.Addr().String()) 250 | assert.NilError(t, err) 251 | defer conn.Close() 252 | 253 | reqParts := []string{ 254 | "GET /websocket/echo HTTP/1.1", 255 | "Host: test", 256 | "Connection: upgrade", 257 | "Upgrade: websocket", 258 | "Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==", 259 | "Sec-WebSocket-Version: 13", 260 | } 261 | reqBytes := []byte(strings.Join(reqParts, "\r\n") + "\r\n\r\n") 262 | t.Logf("raw request:\n%q", reqBytes) 263 | 264 | // first, we write the request line and headers, which should cause the 265 | // server to respond with a 101 Switching Protocols response. 266 | { 267 | n, err := conn.Write(reqBytes) 268 | assert.NilError(t, err) 269 | assert.Equal(t, n, len(reqBytes), "incorrect number of bytes written") 270 | 271 | resp, err := http.ReadResponse(bufio.NewReader(conn), nil) 272 | assert.NilError(t, err) 273 | assert.StatusCode(t, resp, http.StatusSwitchingProtocols) 274 | } 275 | 276 | // next, we try to read from the connection, expecting the connection 277 | // to be closed after roughly maxDuration seconds 278 | { 279 | start := time.Now() 280 | _, err := conn.Read(make([]byte, 1)) 281 | elapsed := time.Since(start) 282 | 283 | assert.Error(t, err, io.EOF) 284 | assert.RoughlyEqual(t, elapsed, maxDuration, 25*time.Millisecond) 285 | } 286 | }) 287 | 288 | t.Run("client closing connection", func(t *testing.T) { 289 | t.Parallel() 290 | 291 | // the client will close the connection well before the server closes 292 | // the connection. make sure the server properly handles the client 293 | // closure. 294 | var ( 295 | clientTimeout = 100 * time.Millisecond 296 | serverTimeout = time.Hour // should never be reached 297 | elapsedClientTime time.Duration 298 | elapsedServerTime time.Duration 299 | wg sync.WaitGroup 300 | ) 301 | 302 | wg.Add(1) 303 | srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 304 | defer wg.Done() 305 | start := time.Now() 306 | ws := websocket.New(w, r, websocket.Limits{ 307 | MaxDuration: serverTimeout, 308 | MaxFragmentSize: 128, 309 | MaxMessageSize: 256, 310 | }) 311 | if err := ws.Handshake(); err != nil { 312 | http.Error(w, err.Error(), http.StatusBadRequest) 313 | return 314 | } 315 | ws.Serve(websocket.EchoHandler) 316 | elapsedServerTime = time.Since(start) 317 | })) 318 | defer srv.Close() 319 | 320 | conn, err := net.Dial("tcp", srv.Listener.Addr().String()) 321 | assert.NilError(t, err) 322 | defer conn.Close() 323 | 324 | // should cause the client end of the connection to close well before 325 | // the max request time configured above 326 | conn.SetDeadline(time.Now().Add(clientTimeout)) 327 | 328 | reqParts := []string{ 329 | "GET /websocket/echo HTTP/1.1", 330 | "Host: test", 331 | "Connection: upgrade", 332 | "Upgrade: websocket", 333 | "Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==", 334 | "Sec-WebSocket-Version: 13", 335 | } 336 | reqBytes := []byte(strings.Join(reqParts, "\r\n") + "\r\n\r\n") 337 | t.Logf("raw request:\n%q", reqBytes) 338 | 339 | // first, we write the request line and headers, which should cause the 340 | // server to respond with a 101 Switching Protocols response. 341 | { 342 | n, err := conn.Write(reqBytes) 343 | assert.NilError(t, err) 344 | assert.Equal(t, n, len(reqBytes), "incorrect number of bytes written") 345 | 346 | resp, err := http.ReadResponse(bufio.NewReader(conn), nil) 347 | assert.NilError(t, err) 348 | assert.StatusCode(t, resp, http.StatusSwitchingProtocols) 349 | } 350 | 351 | // next, we try to read from the connection, expecting the connection 352 | // to be closed after roughly clientTimeout seconds. 353 | // 354 | // the server should detect the closed connection and abort the 355 | // handler, also after roughly clientTimeout seconds. 356 | { 357 | start := time.Now() 358 | _, err := conn.Read(make([]byte, 1)) 359 | elapsedClientTime = time.Since(start) 360 | 361 | // close client connection, which should interrupt the server's 362 | // blocking read call on the connection 363 | conn.Close() 364 | 365 | assert.Equal(t, os.IsTimeout(err), true, "expected timeout error") 366 | assert.RoughlyEqual(t, elapsedClientTime, clientTimeout, 10*time.Millisecond) 367 | 368 | // wait for the server to finish 369 | wg.Wait() 370 | assert.RoughlyEqual(t, elapsedServerTime, clientTimeout, 10*time.Millisecond) 371 | } 372 | }) 373 | } 374 | 375 | // brokenHijackResponseWriter implements just enough to satisfy the 376 | // http.ResponseWriter and http.Hijacker interfaces and get through the 377 | // handshake before failing to actually hijack the connection. 378 | type brokenHijackResponseWriter struct { 379 | http.ResponseWriter 380 | Code int 381 | } 382 | 383 | func (w *brokenHijackResponseWriter) WriteHeader(code int) { 384 | w.Code = code 385 | } 386 | 387 | func (w *brokenHijackResponseWriter) Header() http.Header { 388 | return http.Header{} 389 | } 390 | 391 | func (brokenHijackResponseWriter) Hijack() (net.Conn, *bufio.ReadWriter, error) { 392 | return nil, nil, fmt.Errorf("error hijacking connection") 393 | } 394 | 395 | var ( 396 | _ http.ResponseWriter = &brokenHijackResponseWriter{} 397 | _ http.Hijacker = &brokenHijackResponseWriter{} 398 | ) 399 | -------------------------------------------------------------------------------- /internal/testing/assert/assert.go: -------------------------------------------------------------------------------- 1 | // Package assert implements common assertions used in go-httbin's unit tests. 2 | package assert 3 | 4 | import ( 5 | "fmt" 6 | "net/http" 7 | "reflect" 8 | "strings" 9 | "testing" 10 | "time" 11 | 12 | "github.com/mccutchen/go-httpbin/v2/internal/testing/must" 13 | ) 14 | 15 | // Equal asserts that two values are equal. 16 | func Equal[T comparable](t *testing.T, got, want T, msg string, arg ...any) { 17 | t.Helper() 18 | if got != want { 19 | if msg == "" { 20 | msg = "expected values to match" 21 | } 22 | msg = fmt.Sprintf(msg, arg...) 23 | t.Fatalf("%s:\nwant: %#v\n got: %#v", msg, want, got) 24 | } 25 | } 26 | 27 | // DeepEqual asserts that two values are deeply equal. 28 | func DeepEqual[T any](t *testing.T, got, want T, msg string, arg ...any) { 29 | t.Helper() 30 | if !reflect.DeepEqual(got, want) { 31 | if msg == "" { 32 | msg = "expected values to match" 33 | } 34 | msg = fmt.Sprintf(msg, arg...) 35 | t.Fatalf("%s:\nwant: %#v\n got: %#v", msg, want, got) 36 | } 37 | } 38 | 39 | // NilError asserts that an error is nil. 40 | func NilError(t *testing.T, err error) { 41 | t.Helper() 42 | if err != nil { 43 | t.Fatalf("expected nil error, got %s (%T)", err, err) 44 | } 45 | } 46 | 47 | // Error asserts that an error is not nil. 48 | func Error(t *testing.T, got, expected error) { 49 | t.Helper() 50 | if got != expected { 51 | if got != nil && expected != nil { 52 | if got.Error() == expected.Error() { 53 | return 54 | } 55 | } 56 | t.Fatalf("expected error %v, got %v", expected, got) 57 | } 58 | } 59 | 60 | // StatusCode asserts that a response has a specific status code. 61 | func StatusCode(t *testing.T, resp *http.Response, code int) { 62 | t.Helper() 63 | if resp.StatusCode != code { 64 | t.Fatalf("expected status code %d, got %d", code, resp.StatusCode) 65 | } 66 | if resp.StatusCode >= 400 { 67 | // Ensure our error responses are never served as HTML, so that we do 68 | // not need to worry about XSS or other attacks in error responses. 69 | if ct := resp.Header.Get("Content-Type"); !isSafeContentType(ct) { 70 | t.Errorf("HTTP %s error served with dangerous content type: %s", resp.Status, ct) 71 | } 72 | } 73 | } 74 | 75 | func isSafeContentType(ct string) bool { 76 | return strings.HasPrefix(ct, "application/json") || strings.HasPrefix(ct, "text/plain") || strings.HasPrefix(ct, "application/octet-stream") 77 | } 78 | 79 | // Header asserts that a header key has a specific value in a response. 80 | func Header(t *testing.T, resp *http.Response, key, want string) { 81 | t.Helper() 82 | got := resp.Header.Get(key) 83 | if want != got { 84 | t.Fatalf("expected header %s=%#v, got %#v", key, want, got) 85 | } 86 | } 87 | 88 | // ContentType asserts that a response has a specific Content-Type header 89 | // value. 90 | func ContentType(t *testing.T, resp *http.Response, contentType string) { 91 | t.Helper() 92 | Header(t, resp, "Content-Type", contentType) 93 | } 94 | 95 | // Contains asserts that needle is found in the given string. 96 | func Contains(t *testing.T, s string, needle string, description string) { 97 | t.Helper() 98 | if !strings.Contains(s, needle) { 99 | t.Fatalf("expected string %q in %s %q", needle, description, s) 100 | } 101 | } 102 | 103 | // BodyContains asserts that a response body contains a specific substring. 104 | func BodyContains(t *testing.T, resp *http.Response, needle string) { 105 | t.Helper() 106 | body := must.ReadAll(t, resp.Body) 107 | Contains(t, body, needle, "body") 108 | } 109 | 110 | // BodyEquals asserts that a response body is equal to a specific string. 111 | func BodyEquals(t *testing.T, resp *http.Response, want string) { 112 | t.Helper() 113 | got := must.ReadAll(t, resp.Body) 114 | Equal(t, got, want, "incorrect response body") 115 | } 116 | 117 | // BodySize asserts that a response body is a specific size. 118 | func BodySize(t *testing.T, resp *http.Response, want int) { 119 | t.Helper() 120 | got := must.ReadAll(t, resp.Body) 121 | Equal(t, len(got), want, "incorrect response body size") 122 | } 123 | 124 | // DurationRange asserts that a duration is within a specific range. 125 | func DurationRange(t *testing.T, got, minVal, maxVal time.Duration) { 126 | t.Helper() 127 | if got < minVal || got > maxVal { 128 | t.Fatalf("expected duration between %s and %s, got %s", minVal, maxVal, got) 129 | } 130 | } 131 | 132 | type number interface { 133 | ~int64 | ~float64 134 | } 135 | 136 | // RoughlyEqual asserts that a numeric value is within a certain tolerance. 137 | func RoughlyEqual[T number](t *testing.T, got, want T, epsilon T) { 138 | t.Helper() 139 | if got < want-epsilon || got > want+epsilon { 140 | t.Fatalf("expected value between %v and %v, got %v", want-epsilon, want+epsilon, got) 141 | } 142 | } 143 | -------------------------------------------------------------------------------- /internal/testing/must/must.go: -------------------------------------------------------------------------------- 1 | // Package must implements helper functions for testing to eliminate some error 2 | // checking boilerplate. 3 | package must 4 | 5 | import ( 6 | "encoding/json" 7 | "io" 8 | "net/http" 9 | "testing" 10 | "time" 11 | ) 12 | 13 | // DoReq makes an HTTP request and fails the test if there is an error. 14 | func DoReq(t *testing.T, client *http.Client, req *http.Request) *http.Response { 15 | t.Helper() 16 | start := time.Now() 17 | resp, err := client.Do(req) 18 | if err != nil { 19 | t.Fatalf("error making HTTP request: %s %s: %s", req.Method, req.URL, err) 20 | } 21 | t.Logf("HTTP request: %s %s => %s (%s)", req.Method, req.URL, resp.Status, time.Since(start)) 22 | return resp 23 | } 24 | 25 | // ReadAll reads all bytes from an io.Reader and fails the test if there is an 26 | // error. 27 | func ReadAll(t *testing.T, r io.Reader) string { 28 | t.Helper() 29 | body, err := io.ReadAll(r) 30 | if err != nil { 31 | t.Fatalf("error reading: %s", err) 32 | } 33 | if rc, ok := r.(io.ReadCloser); ok { 34 | rc.Close() 35 | } 36 | return string(body) 37 | } 38 | 39 | // Unmarshal unmarshals JSON from an io.Reader into a value and fails the test 40 | // if there is an error. 41 | func Unmarshal[T any](t *testing.T, r io.Reader) T { 42 | t.Helper() 43 | var v T 44 | if err := json.NewDecoder(r).Decode(&v); err != nil { 45 | t.Fatal(err) 46 | } 47 | return v 48 | } 49 | -------------------------------------------------------------------------------- /kustomize/README.md: -------------------------------------------------------------------------------- 1 | This folder is a [kustomize application](https://kubectl.docs.kubernetes.io/references/kustomize/glossary/#application) that stands up a running instance of go-httpbin in a Kubernetes cluster. 2 | 3 | You may wish to utilise this as a [remote application](https://kubectl.docs.kubernetes.io/references/kustomize/kustomization/resource/), e.g. 4 | 5 | ```yaml 6 | apiVersion: kustomize.config.k8s.io/v1beta1 7 | kind: Kustomization 8 | commonLabels: 9 | app.kubernetes.io/name: httpbin 10 | resources: 11 | - github.com/mccutchen/go-httpbin/kustomize 12 | images: 13 | - name: mccutchen/go-httpbin 14 | ``` 15 | 16 | To expose your instance to the internet, you could add an `Ingress` in an overlay: 17 | ```yaml 18 | apiVersion: networking.k8s.io/v1 19 | kind: Ingress 20 | metadata: 21 | name: httpbin 22 | spec: 23 | ingressClassName: myingressname 24 | rules: 25 | - host: my-go-httpbin.com 26 | http: 27 | paths: 28 | - path: / 29 | pathType: Prefix 30 | backend: 31 | service: 32 | name: httpbin 33 | port: 34 | name: http 35 | tls: 36 | - hosts: 37 | - my-go-httpbin.com 38 | secretName: go-httpbin-tls 39 | ``` 40 | -------------------------------------------------------------------------------- /kustomize/kustomization.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: kustomize.config.k8s.io/v1beta1 2 | kind: Kustomization 3 | commonLabels: 4 | app.kubernetes.io/name: httpbin 5 | resources: 6 | - resources.yaml 7 | images: 8 | - name: mccutchen/go-httpbin 9 | -------------------------------------------------------------------------------- /kustomize/resources.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: apps/v1 2 | kind: Deployment 3 | metadata: 4 | name: httpbin 5 | spec: 6 | template: 7 | spec: 8 | containers: 9 | - name: httpbin 10 | image: ghcr.io/mccutchen/go-httpbin 11 | ports: 12 | - name: http 13 | containerPort: 8080 14 | protocol: TCP 15 | livenessProbe: 16 | httpGet: 17 | path: /status/200 18 | port: http 19 | readinessProbe: 20 | httpGet: 21 | path: /status/200 22 | port: http 23 | resources: {} 24 | --- 25 | apiVersion: v1 26 | kind: Service 27 | metadata: 28 | name: httpbin 29 | spec: 30 | ports: 31 | - port: 80 32 | targetPort: http 33 | protocol: TCP 34 | name: http 35 | appProtocol: http 36 | --------------------------------------------------------------------------------