diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml index 436254a63..57ae0ffdd 100644 --- a/.github/workflows/checks.yml +++ b/.github/workflows/checks.yml @@ -4,9 +4,11 @@ on: push: branches: - master + - v4 pull_request: branches: - master + - v4 workflow_dispatch: permissions: @@ -14,17 +16,17 @@ permissions: env: # run static analysis only with the latest Go version - LATEST_GO_VERSION: "1.25" + LATEST_GO_VERSION: "1.27" jobs: check: runs-on: ubuntu-latest steps: - name: Checkout Code - uses: actions/checkout@v5 + uses: actions/checkout@v6 - name: Set up Go ${{ matrix.go }} - uses: actions/setup-go@v5 + uses: actions/setup-go@v6 with: go-version: ${{ env.LATEST_GO_VERSION }} check-latest: true @@ -44,5 +46,3 @@ jobs: go version go install golang.org/x/vuln/cmd/govulncheck@latest govulncheck ./... - - diff --git a/.github/workflows/echo.yml b/.github/workflows/echo.yml index c7780fd21..1f26b0012 100644 --- a/.github/workflows/echo.yml +++ b/.github/workflows/echo.yml @@ -4,9 +4,11 @@ on: push: branches: - master + - v4 pull_request: branches: - master + - v4 workflow_dispatch: permissions: @@ -14,7 +16,7 @@ permissions: env: # run coverage and benchmarks only with the latest Go version - LATEST_GO_VERSION: "1.25" + LATEST_GO_VERSION: "1.27" jobs: test: @@ -23,17 +25,17 @@ jobs: os: [ubuntu-latest, macos-latest, windows-latest] # Each major Go release is supported until there are two newer major releases. https://golang.org/doc/devel/release.html#policy # Echo tests with last four major releases (unless there are pressing vulnerabilities) - # As we depend on `golang.org/x/` libraries which only support last 2 Go releases we could have situations when - # we derive from last four major releases promise. - go: ["1.22", "1.23", "1.24", "1.25"] + # As we depend on `golang.org/x/` libraries which only support the last 2 Go releases, we could have situations when + # we derive from the last four major releases promise. + go: ["1.26", "1.27"] name: ${{ matrix.os }} @ Go ${{ matrix.go }} runs-on: ${{ matrix.os }} steps: - name: Checkout Code - uses: actions/checkout@v5 + uses: actions/checkout@v6 - name: Set up Go ${{ matrix.go }} - uses: actions/setup-go@v5 + uses: actions/setup-go@v6 with: go-version: ${{ matrix.go }} @@ -42,29 +44,30 @@ jobs: - name: Upload coverage to Codecov if: success() && matrix.go == env.LATEST_GO_VERSION && matrix.os == 'ubuntu-latest' - uses: codecov/codecov-action@v5 + uses: codecov/codecov-action@v6 with: token: fail_ci_if_error: false benchmark: + if: github.event_name == 'pull_request' needs: test name: Benchmark comparison runs-on: ubuntu-latest steps: - name: Checkout Code (Previous) - uses: actions/checkout@v5 + uses: actions/checkout@v6 with: ref: ${{ github.base_ref }} path: previous - name: Checkout Code (New) - uses: actions/checkout@v5 + uses: actions/checkout@v6 with: path: new - name: Set up Go ${{ matrix.go }} - uses: actions/setup-go@v5 + uses: actions/setup-go@v6 with: go-version: ${{ env.LATEST_GO_VERSION }} @@ -74,12 +77,12 @@ jobs: - name: Run Benchmark (Previous) run: | cd previous - go test -run="-" -bench=".*" -count=8 ./... > benchmark.txt + go test -run="-" -bench=".*" -benchtime=100ms -count=8 ./... > benchmark.txt - name: Run Benchmark (New) run: | cd new - go test -run="-" -bench=".*" -count=8 ./... > benchmark.txt + go test -run="-" -bench=".*" -benchtime=100ms -count=8 ./... > benchmark.txt - name: Run Benchstat run: | diff --git a/CHANGELOG.md b/CHANGELOG.md index b7fd0e14e..6538dbe58 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,173 @@ # Changelog +## v4.16.5 - 2026-11-14 + +**Security** + +* **Proxy middleware:** a client could remove `X-Forwarded-Proto` from the upstream request by naming it in the `Connection` header, on plain HTTP requests and WebSocket upgrades. The proxy now sets `X-Forwarded-Proto` again after Go removes hop-by-hop headers, as it already does for `X-Real-IP`. Fixes [GHSA-h6x5-phvw-c55c](https://github.com/labstack/echo/security/advisories/GHSA-h6x5-phvw-c55c). Reported by zachary-satterly. + +**Behavior change** + +A custom balancer that replaces the request and sets its own `X-Forwarded-Proto` no longer reaches the upstream with that value; the proxy uses the scheme of the original request. A custom `ProxyConfig.Transport` can still set the header. + +No public API, dependency or minimum Go-version changes; Go 1.26 or later remains required. + +## v4.16.4 - 2026-11-12 + +**Security** + +* **Redirect middleware:** `HTTPSRedirect`, `HTTPSWWWRedirect`, `HTTPSNonWWWRedirect`, `WWWRedirect` and `NonWWWRedirect` now return `400 Bad Request` instead of redirecting when the host is empty, starts with `:` or `.`, contains `%`, or is empty after removing `www.`. Before this change, such hosts could produce an open redirect. The middleware no longer redirects `OPTIONS *` requests, maps `ws` and `wss` to `http` and `https` in redirect targets, and matches the `www.` prefix case-insensitively. Fixes [GHSA-3f6j-cw5h-qw9c](https://github.com/labstack/echo/security/advisories/GHSA-3f6j-cw5h-qw9c) ([#3217](https://github.com/labstack/echo/issues/3217)) in [#3224](https://github.com/labstack/echo/pull/3224). + +**Fixes** + +* **Trailing slash middleware:** `AddTrailingSlash` and `RemoveTrailingSlash` keep escaped path segments such as `%2F` in redirect and forward targets, so the request still matches its original route. Fixes [#3206](https://github.com/labstack/echo/issues/3206) in [#3225](https://github.com/labstack/echo/pull/3225). +* **IPv6 zones:** `ExtractIPFromXFFHeader` and `ExtractIPFromRealIPHeader` trust zoned link-local proxy peers such as `[fe80::1%eth0]:8080`, with or without a port. Fixes [#3179](https://github.com/labstack/echo/issues/3179) in [#3226](https://github.com/labstack/echo/pull/3226). +* **Header binding:** `BindHeaders` no longer panics on nil or empty header value slices. Fixes [#3169](https://github.com/labstack/echo/issues/3169) in [#3227](https://github.com/labstack/echo/pull/3227). + +**Behavior changes** + +* `Context.RealIP()` and `ExtractIPDirect()` now return a zoned link-local peer without its zone: `fe80::1`, not `fe80::1%eth0`. Update logs, allowlists or rate-limit keys that store the zoned form. +* `HTTPSWWWRedirect` keeps its v4 behavior: it does not redirect plain HTTP requests for hosts that already start with `www.`. v5 redirects these requests. + +No public API, dependency or minimum Go-version changes; Go 1.26 or later remains required. + +## v4.16.3 - 2026-10-29 + +**Security** + +* **Client IP defaults:** when `Echo#IPExtractor` is unset, `Context.RealIP()` now uses the connection peer and ignores `X-Forwarded-For` and `X-Real-IP`. This prevents clients from selecting their own default rate-limit bucket. Fixes [CVE-2026-118653](https://github.com/labstack/echo/security/advisories/GHSA-246p-cpwv-v3jq). +* **Proxy identity:** Proxy middleware always replaces upstream `X-Real-IP` with the derived client identity, including after `Connection`-nominated header removal and on WebSocket upgrades. The identity is derived once and preserved across retries. Fixes [CVE-2026-118651](https://github.com/labstack/echo/security/advisories/GHSA-99jh-6h7p-pp36). + +**Migration required for applications behind proxies** + +Configure `Echo#IPExtractor` to trust only your deployment's proxies before upgrading. Otherwise, clients behind one proxy share its address for rate limiting, access controls, logging, and proxy forwarding. See the [README migration example](README.md#client-ip-security-migration-since-v4163) for explicit XFF trust ranges; use the same trust options with `ExtractIPFromRealIPHeader` for proxies that set `X-Real-IP`. Configure the outermost proxy to replace client-supplied identity headers. Explicit extractors and custom rate-limit identifiers retain their behavior. + +**Other changes** + +* The direct-peer fallback now accepts bare IPv4 and IPv6 addresses without a port. +* Document v4 bug-fix and security-fix support dates (#3194). + +Implementation: [#3197](https://github.com/labstack/echo/pull/3197). No dependency or minimum Go-version changes; Go 1.26 or later remains required. + +## v4.16.0 - 2026-09-27 + +**Security** + +This release fixes several security issues. Upgrading is recommended. Some fixes change behavior; read "Behavior changes to check before upgrading" below. + +* **Request scheme:** `Context.Scheme()` now uses the `X-Forwarded-Proto`, `X-Forwarded-Protocol`, `X-Forwarded-Ssl` and `X-Url-Scheme` headers only when the request comes directly from a loopback, link-local or private network address or a unix socket. Before this, any client could send `X-Forwarded-Proto: https` over plain HTTP and skip `HTTPSRedirect`. When `X-Forwarded-Proto` is present, only it is used (its last value), and the scheme is returned in lowercase. the new `Echo#SchemeExtractor` field selects the strategy: `ExtractSchemeFromHeaders(...TrustOption)` (default), `ExtractSchemeDirect()` or `LegacySchemeExtractor()`. The Secure middleware now sets HSTS based on `Context.Scheme()`. The Proxy middleware always sets `X-Forwarded-Proto` from `Context.Scheme()` and removes `X-Forwarded-Ssl`, `X-Forwarded-Protocol` and `X-Url-Scheme` before forwarding. [GHSA-2ffq-g2xg-c22p](https://github.com/labstack/echo/security/advisories/GHSA-2ffq-g2xg-c22p) +* **JSONP:** `Context.JSONP` and `Context.JSONPBlob` accept only a callback that is empty, a JavaScript identifier or a dot-separated path of identifiers (ASCII letters, digits, `_` and `$`). Any other callback returns a 400 Bad Request error that wraps the new `ErrInvalidJSONPCallback`, and nothing is written. JSONP responses now carry `X-Content-Type-Options: nosniff`. JSONP lets any website read the response with the user's cookies, so do not use it for data that needs authentication. [GHSA-h9g5-28mm-hx3g](https://github.com/labstack/echo/security/advisories/GHSA-h9g5-28mm-hx3g) +* **MethodOverride:** a POST can no longer be overridden to `GET`, `HEAD`, `OPTIONS`, `TRACE` or `CONNECT`. Before this, with the `MethodFromForm` or `MethodFromQuery` getter and MethodOverride registered with `Use` before the CSRF middleware, `_method=GET` skipped the CSRF check. Register MethodOverride with `Echo#Pre`. [GHSA-r7w9-592q-9vg4](https://github.com/labstack/echo/security/advisories/GHSA-r7w9-592q-9vg4) +* **Redirects:** the trailing slash middlewares and the static directory redirect percent-encode control characters in the redirect path. Before this, `/%09/evil.example/` redirected browsers to `evil.example`. [GHSA-v753-g4cw-jm48](https://github.com/labstack/echo/security/advisories/GHSA-v753-g4cw-jm48) +* **Static files:** with the default settings, the Static middleware resolves files from the same form of the path that the router matched, so `/admin%2Fsecret.txt` or `/%61dmin/secret.txt` can no longer reach a file under a guarded `/admin/*` route. [GHSA-375p-5qhx-8wq4](https://github.com/labstack/echo/security/advisories/GHSA-375p-5qhx-8wq4) The Static middleware and `StaticDirectoryHandler` (used by `Echo.Static`, `Echo.StaticFS`, `Group.Static` and `Group.StaticFS`) no longer serve paths with a `.`, `..` or empty segment, such as `/assets/../admin/secret.txt`, also after path unescaping. [GHSA-3pmx-cf9f-34xr](https://github.com/labstack/echo/security/advisories/GHSA-3pmx-cf9f-34xr) +* **Dependencies:** update `golang.org/x/text` to v0.40.0 ([GO-2026-5970](https://pkg.go.dev/vuln/GO-2026-5970)). + +**Client IP address (no code change in v4)** + +Without `Echo#IPExtractor`, `Context.RealIP()` in v4 trusts the `X-Forwarded-For` and `X-Real-IP` headers from any client, so the rate limiter can be bypassed and the Proxy middleware forwards a spoofed `X-Real-IP` ([GHSA-246p-cpwv-v3jq](https://github.com/labstack/echo/security/advisories/GHSA-246p-cpwv-v3jq), [GHSA-99jh-6h7p-pp36](https://github.com/labstack/echo/security/advisories/GHSA-99jh-6h7p-pp36)). Changing this default in v4 would put all clients behind a proxy into one rate-limit bucket, so v4 keeps it. Set an extractor that matches your deployment: +```go +e.IPExtractor = echo.ExtractIPDirect() // no proxy in front of the app +e.IPExtractor = echo.ExtractIPFromXFFHeader() // behind proxies in private networks that set X-Forwarded-For +// behind a proxy with public addresses (e.g. a CDN), also trust its ranges: +// e.IPExtractor = echo.ExtractIPFromXFFHeader(echo.TrustIPRange(cdnRange)) +``` +v5 uses the direct peer address by default since v5.1.0. + +**Behavior changes to check before upgrading** + +* **Proxies or load balancers with public IP addresses.** If a proxy connects to your app from a public (or `100.64.0.0/10`) address, its `X-Forwarded-Proto` is now ignored: `HTTPSRedirect` redirects in a loop and the Secure middleware stops sending HSTS. This affects, for example, Cloudflare, CloudFront and Azure Front Door connecting to a public origin, the GCP external HTTP(S) load balancer including GKE Ingress (`35.191.0.0/16`, `130.211.0.0/22`), and networks that use `100.64.0.0/10` (such as Alibaba Cloud SLB or EKS custom networking). Trust the proxy's address ranges: + ```go + _, gclb1, _ := net.ParseCIDR("35.191.0.0/16") + _, gclb2, _ := net.ParseCIDR("130.211.0.0/22") + e.SchemeExtractor = echo.ExtractSchemeFromHeaders(echo.TrustIPRange(gclb1), echo.TrustIPRange(gclb2)) + ``` + Proxies on the same host, in a private network (AWS ALB, in-cluster ingress controllers such as ingress-nginx or Traefik, most PaaS routers) or on a unix socket keep working without changes. `echo.LegacySchemeExtractor()` restores the old behavior but is not safe unless every request passes through a proxy that sets these headers. Serverless adapters or middleware that set `RemoteAddr` to the client's address also make `X-Forwarded-Proto` ignored (or, if they take it from a header, spoofable). +* **Trusted proxies must set `X-Forwarded-Proto`.** A proxy on a trusted address that passes the client's `X-Forwarded-Proto` through (for example nginx without `proxy_set_header X-Forwarded-Proto $scheme;`) still lets the client choose the scheme. An invalid `X-Forwarded-Proto` value now results in `http` instead of falling back to the other scheme headers. +* **Your own tests.** `httptest.NewRequest` sets `RemoteAddr` to `192.0.2.1:1234`, which is not trusted, so tests that set `X-Forwarded-Proto` now see `http`. Set `req.RemoteAddr = "10.0.0.1:1234"` or use `e.SchemeExtractor = echo.LegacySchemeExtractor()` in such tests. +* **Proxy middleware headers.** `X-Forwarded-Ssl`, `X-Forwarded-Protocol` and `X-Url-Scheme` are no longer forwarded to the upstream; `X-Forwarded-Proto` carries the scheme. +* **MethodOverride.** Overriding a POST to `GET` (for example with `X-HTTP-Method-Override: GET` to send a long query in a POST body) is no longer done; such requests keep the POST method. +* **Static files.** Paths with a double slash or dot segment (for example `/assets//app.js`) now return 404; in HTML5 mode the index is still served. The Static middleware no longer finds file names that the client sends with non-default escaping (for example `%2C`, `%40` or lowercase hex like `%c3%a9`) unless `StaticConfig.EnablePathUnescaping` is set; `Echo.Static` has behaved this way since v4.15.4. With `StaticConfig.EnablePathUnescaping` or `Echo#EnablePathUnescapingStaticFiles`, encoded dots (`%2e%2e`) no longer traverse directories, but encoded slashes are still decoded, so do not combine these options with route-based access control. +* **JSONP.** `Context.JSONP` returns an error for callbacks that are not JavaScript identifiers. + +**Documentation** + +* Static middleware: when registered with `Echo#Use` it runs before route and group middleware, so route guards do not protect the files it serves. + + +## v4.15.4 - 2026-06-15 + +**Security** + +Fixes [GHSA-vfp3-v2gw-7wfq](https://github.com/labstack/echo/security/advisories/GHSA-vfp3-v2gw-7wfq) + +Make serving static file releated methods and middleware not unescape path by default - so how the way Router interprets paths and Static methods/middleware is consistent. + +Given following situation: +```go +// 0. +// given folder structure: +// private.txt +// public/ +// public/index.html +// public/text.txt +// public/admin/private.txt + +// 1. share `public/` folder contents from the server root. This folder actually contains subfolder `admin` which +// contents we want to forbid from downloading +e.Static("/", "public") + +// 2. naively assume that everything under /admin folder is now forbidden +e.GET("/admin/*", func(c *Context) error { + return ErrForbidden +}) +``` + +Then requests to `/admin%2fprivate.txt` would not be matched to `GET /admin/*` route (routing does not look unescaped path) and static file serving will use unescaped path to serve the file. + +Note: this way of "guarding" subfolders will never work for for paths like `/assets/../admin%2fprivate.txt` which will `path.Clean("/assets/../admin%2fprivate.txt")` to `/admin/private.txt` and are servable if static file serving is configured to unescape paths. + +If you want to guard routes - use middlewares on `Static*` methods and before `Static` middleware. + +**Breaking change / migration:** If you serve files whose names contain URL-encoded characters (e.g., `/hello%20world.txt` → `hello world.txt`), you must now opt in: + +```go + e := echo.New() + e.EnablePathUnescapingStaticFiles = true // <-- enable old behavior + e.Static("/", "public") +``` +for static middleware +```go + e.Use(middleware.StaticWithConfig(middleware.StaticConfig{ + EnablePathUnescaping: true, // <-- enable old behavior + })) +``` + + +## v4.15.3 - 2026-06-14 + +**Security** + +* fix(static): reject encoded path separators that bypass route-level middleware by @vishr in https://github.com/labstack/echo/pull/3011 + +Fixes [GHSA-vfp3-v2gw-7wfq](https://github.com/labstack/echo/security/advisories/GHSA-vfp3-v2gw-7wfq): an encoded path separator (`%2F` or `%5C`) in a static file URL could bypass route-level middleware (e.g. authentication on a sibling route) and disclose static files. Both `StaticDirectoryHandler` (used by `Static`/`StaticFS`) and the `Static` middleware are affected. Backport of the v5 fix (#3009). Thanks to @a-tt-om and @oran-gugu for reporting. + + +## v4.15.2 - 2026-05-01 + +**Security** + +* `Context.Scheme()` should validate values taken from header by @aldas in https://github.com/labstack/echo/pull/2962 + +Thanks to @shblue21 for reporting this [issue](https://github.com/labstack/echo/issues/2952). + + +## v4.15.1 - 2026-02-22 + +**Enhancements** + +* CSRF: support older token-based CSRF protection handler that want to render token into template by @aldas in https://github.com/labstack/echo/pull/2905 + + ## v4.15.0 - 2026-01-01 diff --git a/README.md b/README.md index 5e52d1d4e..2a1602509 100644 --- a/README.md +++ b/README.md @@ -1,60 +1,112 @@ -[![Sourcegraph](https://sourcegraph.com/github.com/labstack/echo/-/badge.svg?style=flat-square)](https://sourcegraph.com/github.com/labstack/echo?badge) -[![GoDoc](http://img.shields.io/badge/go-documentation-blue.svg?style=flat-square)](https://pkg.go.dev/github.com/labstack/echo/v4) -[![Go Report Card](https://goreportcard.com/badge/github.com/labstack/echo?style=flat-square)](https://goreportcard.com/report/github.com/labstack/echo) -[![GitHub Workflow Status (with event)](https://img.shields.io/github/actions/workflow/status/labstack/echo/echo.yml?style=flat-square)](https://github.com/labstack/echo/actions) -[![Codecov](https://img.shields.io/codecov/c/github/labstack/echo.svg?style=flat-square)](https://codecov.io/gh/labstack/echo) -[![Forum](https://img.shields.io/badge/community-forum-00afd1.svg?style=flat-square)](https://github.com/labstack/echo/discussions) -[![Twitter](https://img.shields.io/badge/twitter-@labstack-55acee.svg?style=flat-square)](https://twitter.com/labstack) -[![License](http://img.shields.io/badge/license-mit-blue.svg?style=flat-square)](https://raw.githubusercontent.com/labstack/echo/master/LICENSE) +

+ + + + Echo + + +

-## Echo +

High performance, extensible, minimalist Go web framework.
Echo v4 maintenance line

-High performance, extensible, minimalist Go web framework. +

+ Go Reference + CI + Codecov + Discussions + X + License +

-* [Official website](https://echo.labstack.com) -* [Quick start](https://echo.labstack.com/docs/quick-start) -* [Middlewares](https://echo.labstack.com/docs/category/middleware) +

+ v4 API reference · + Upgrade to v5 · + Website (v5) · + Discussions · + Commercial support +

-Help and questions: [Github Discussions](https://github.com/labstack/echo/discussions) +## About +This branch holds Echo v4, the maintenance line. Echo v5 is the current release; start new projects on [v5](https://github.com/labstack/echo). -### Feature Overview +### Support -- Optimized HTTP router which smartly prioritize routes -- Build robust and scalable RESTful APIs -- Group APIs -- Extensible middleware framework -- Define middleware at root, group or route level -- Data binding for JSON, XML and form payload -- Handy functions to send variety of HTTP responses +Echo v4 receives bug fixes until 2026-12-31 and free security fixes until 2027-12-31, on its latest release only. It gets no new features. See [SECURITY.md](https://github.com/labstack/echo/blob/master/SECURITY.md) for the full policy. + +Echo v4 requires Go 1.26 or later (since v4.16.1), to use patched `golang.org/x/` dependencies. + +[Echo Support](https://echo.labstack.com/pro#support) is a paid annual plan from LabStack for v4 and v5 applications. It includes a named contact, email support with a two-business-day response target, and an annual maintenance-status letter. + +## Upgrade notes + +### Client IP security migration since v4.16.3 + +> **Important:** Behind a proxy, set `Echo#IPExtractor` before you upgrade to v4.16.3. Otherwise all clients behind the proxy share one IP address for rate limiting, access controls and logging. + +Echo v4.16.3 changes client IP defaults. Without `Echo#IPExtractor`, `Context.RealIP()` uses the connection peer and ignores `X-Forwarded-For` and `X-Real-IP`. Proxy middleware always replaces upstream `X-Real-IP` with this result. This fixes [CVE-2026-118651](https://github.com/labstack/echo/security/advisories/GHSA-99jh-6h7p-pp36) and [CVE-2026-118653](https://github.com/labstack/echo/security/advisories/GHSA-246p-cpwv-v3jq). Applications that already set `Echo#IPExtractor` keep their behavior. A custom rate-limit identifier that does not call `RealIP()` is also unaffected. + +Behind a proxy, configure `Echo#IPExtractor` before starting the server. Otherwise, clients behind the same proxy share its IP address for rate limiting, access controls and logging. Use `echo.ExtractIPDirect()` when there is no proxy. For proxies that set `X-Forwarded-For`, turn off the broad default trust ranges and trust only your proxy addresses: + +```go +// After e := echo.New(); imports "log" and "net". +_, proxyRange, err := net.ParseCIDR("10.0.0.10/32") // replace with your proxy's address or CIDR +if err != nil { + log.Fatal(err) +} +e.IPExtractor = echo.ExtractIPFromXFFHeader( + echo.TrustLoopback(false), + echo.TrustLinkLocal(false), + echo.TrustPrivateNet(false), + echo.TrustIPRange(proxyRange), +) +``` + +If your proxy uses `X-Real-IP`, use `echo.ExtractIPFromRealIPHeader` with the same trust options. Configure the outermost proxy to replace client-supplied identity headers. + +### Security defaults since v4.16.1 + +See the [v4.16.1 release notes](https://github.com/labstack/echo/releases/tag/v4.16.1) for the advisories. + +`BodyDump` captures at most 5 MiB of the request and 5 MiB of the response by default. The application and client still receive the complete bodies. Set `BodyDumpConfig.MaxRequestBytes` and `MaxResponseBytes` to change the capture limits; `-1` removes a limit. + +`Decompress` limits decompressed request bodies to 100 MiB by default. Reading beyond the limit returns HTTP 413, including through JSON and XML binding. Set `DecompressConfig.MaxDecompressedSize` to change the limit; `-1` removes it. Apply any smaller application body limit after decompression. + +The proxy middleware validates WebSocket upgrade responses, closes both WebSocket connections when either side disconnects, and removes incoming `Forwarded` headers. Backends should trust only the forwarding headers the proxy sets and enforce their own authorization. + +### Request scheme since v4.16.0 + +`Context.Scheme()` uses `X-Forwarded-Proto` and related headers only when the request comes directly from a loopback, link-local or private network address, or a unix socket. If a proxy with a public address terminates TLS and forwards plain HTTP to Echo, `HTTPSRedirect` keeps redirecting until you set `Echo#SchemeExtractor` to trust that proxy. See the [v4.16.0 changelog](https://github.com/labstack/echo/blob/v4/CHANGELOG.md#v4160---2026-09-27) for this and the other fixes in that release. + +## Features + +- Router with static, parameter and wildcard routes; static routes match first +- RESTful APIs with route groups +- Middleware at the root, group or route level +- Data binding for JSON, XML, form, query and path parameters +- Helpers for common HTTP responses - Centralized HTTP error handling - Template rendering with any template engine -- Define your format for the logger -- Highly customizable -- Automatic TLS via Let’s Encrypt -- HTTP/2 support +- Customizable logging +- Automatic TLS through Let's Encrypt +- HTTP/2, including h2c ## Sponsors -
- - encore icon - Encore – the platform for building Go-based cloud backends - -
-
+| | Sponsor | +| --- | --- | +| Blacksmith logo | [Blacksmith](https://blacksmith.sh): faster, drop-in GitHub Actions runners | +| Encore logo | [Encore](https://encore.dev): the platform for building Go-based cloud backends | -Click [here](https://github.com/sponsors/labstack) for more information on sponsorship. +Echo is independent and community funded. If your company depends on Echo, please consider [sponsoring it](https://github.com/sponsors/labstack). -## [Guide](https://echo.labstack.com/guide) +## Getting started ### Installation ```sh -// go get github.com/labstack/echo/{version} go get github.com/labstack/echo/v4 ``` -Latest version of Echo supports last four Go major [releases](https://go.dev/doc/devel/release) and might work with older versions. ### Example @@ -62,86 +114,90 @@ Latest version of Echo supports last four Go major [releases](https://go.dev/doc package main import ( - "github.com/labstack/echo/v4" - "github.com/labstack/echo/v4/middleware" - "log/slog" - "net/http" + "errors" + "log/slog" + "net/http" + + "github.com/labstack/echo/v4" + "github.com/labstack/echo/v4/middleware" ) func main() { - // Echo instance - e := echo.New() + e := echo.New() - // Middleware - e.Use(middleware.RequestLogger()) // use the default RequestLogger middleware with slog logger - e.Use(middleware.Recover()) // recover panics as errors for proper error handling + e.Use(middleware.RequestLogger()) // log requests with slog + e.Use(middleware.Recover()) // turn panics into errors - // Routes - e.GET("/", hello) + e.GET("/", hello) - // Start server - if err := e.Start(":8080"); err != nil && !errors.Is(err, http.ErrServerClosed) { - slog.Error("failed to start server", "error", err) - } + if err := e.Start(":8080"); err != nil && !errors.Is(err, http.ErrServerClosed) { + slog.Error("failed to start server", "error", err) + } } -// Handler func hello(c echo.Context) error { - return c.String(http.StatusOK, "Hello, World!") + return c.String(http.StatusOK, "Hello, World!") } ``` -# Official middleware repositories +Run it and send a request: + +```console +$ go run . +$ curl http://localhost:8080/ +Hello, World! +``` + +## Official middleware -Following list of middleware is maintained by Echo team. +The Echo team maintains these repositories. -| Repository | Description | -|------------------------------------------------------------------------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| [github.com/labstack/echo-jwt](https://github.com/labstack/echo-jwt) | [JWT](https://github.com/golang-jwt/jwt) middleware | -| [github.com/labstack/echo-contrib](https://github.com/labstack/echo-contrib) | [casbin](https://github.com/casbin/casbin), [gorilla/sessions](https://github.com/gorilla/sessions), [jaegertracing](https://github.com/uber/jaeger-client-go), [prometheus](https://github.com/prometheus/client_golang/), [pprof](https://pkg.go.dev/net/http/pprof), [zipkin](https://github.com/openzipkin/zipkin-go) middlewares | +| Repository | Description | +| --- | --- | +| [github.com/labstack/echo-jwt](https://github.com/labstack/echo-jwt) | [JWT](https://github.com/golang-jwt/jwt) middleware | +| [github.com/labstack/echo-contrib](https://github.com/labstack/echo-contrib) | [casbin](https://github.com/casbin/casbin), [gorilla/sessions](https://github.com/gorilla/sessions), [jaegertracing](https://github.com/uber/jaeger-client-go), [prometheus](https://github.com/prometheus/client_golang/), [pprof](https://pkg.go.dev/net/http/pprof) and [zipkin](https://github.com/openzipkin/zipkin-go) middleware | +| [github.com/labstack/echo-otel](https://github.com/labstack/echo-otel/tree/v4) | [OpenTelemetry](https://opentelemetry.io/) tracing and metrics middleware (`github.com/labstack/echo-otel/v4`) | -# Third-party middleware repositories +## Third-party middleware -Be careful when adding 3rd party middleware. Echo teams does not have time or manpower to guarantee safety and quality -of middlewares in this list. +
+Third-party middleware list -| Repository | Description | -|------------------------------------------------------------------------------------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| [deepmap/oapi-codegen](https://github.com/deepmap/oapi-codegen) | Automatically generate RESTful API documentation with [OpenAPI](https://swagger.io/specification/) Client and Server Code Generator | -| [github.com/swaggo/echo-swagger](https://github.com/swaggo/echo-swagger) | Automatically generate RESTful API documentation with [Swagger](https://swagger.io/) 2.0. | -| [github.com/ziflex/lecho](https://github.com/ziflex/lecho) | [Zerolog](https://github.com/rs/zerolog) logging library wrapper for Echo logger interface. | -| [github.com/brpaz/echozap](https://github.com/brpaz/echozap) | Uber´s [Zap](https://github.com/uber-go/zap) logging library wrapper for Echo logger interface. | -| [github.com/samber/slog-echo](https://github.com/samber/slog-echo) | Go [slog](https://pkg.go.dev/golang.org/x/exp/slog) logging library wrapper for Echo logger interface. | -| [github.com/darkweak/souin/plugins/echo](https://github.com/darkweak/souin/tree/master/plugins/echo) | HTTP cache system based on [Souin](https://github.com/darkweak/souin) to automatically get your endpoints cached. It supports some distributed and non-distributed storage systems depending your needs. | -| [github.com/mikestefanello/pagoda](https://github.com/mikestefanello/pagoda) | Rapid, easy full-stack web development starter kit built with Echo. | -| [github.com/go-woo/protoc-gen-echo](https://github.com/go-woo/protoc-gen-echo) | ProtoBuf generate Echo server side code | +The Echo team does not review or maintain these projects. Check their quality and security, and that each one supports v4, before you use them. -Please send a PR to add your own library here. +| Repository | Description | +| --- | --- | +| [oapi-codegen/oapi-codegen](https://github.com/oapi-codegen/oapi-codegen) | Generates server and client code from [OpenAPI](https://swagger.io/specification/) specifications | +| [github.com/swaggo/echo-swagger](https://github.com/swaggo/echo-swagger) | Serves [Swagger](https://swagger.io/) 2.0 API documentation | +| [github.com/ziflex/lecho](https://github.com/ziflex/lecho) | [Zerolog](https://github.com/rs/zerolog) adapter for the Echo logger interface | +| [github.com/brpaz/echozap](https://github.com/brpaz/echozap) | Uber's [Zap](https://github.com/uber-go/zap) adapter for the Echo logger interface | +| [github.com/samber/slog-echo](https://github.com/samber/slog-echo) | [slog](https://pkg.go.dev/log/slog) request logging middleware | +| [github.com/darkweak/souin/plugins/echo](https://github.com/darkweak/souin/tree/master/plugins/echo) | HTTP cache based on [Souin](https://github.com/darkweak/souin), with distributed and local storage backends | +| [github.com/mikestefanello/pagoda](https://github.com/mikestefanello/pagoda) | Full-stack web starter kit built with Echo | +| [github.com/go-woo/protoc-gen-echo](https://github.com/go-woo/protoc-gen-echo) | Generates Echo server code from Protocol Buffers | + +
+ +To add your library, send a pull request. ## Contribute -**Use issues for everything** +v4 takes bug and security fixes only (see [Support](#support)). Send new features to v5 on the [master branch](https://github.com/labstack/echo). + +- Open an issue before a large change. Send small fixes directly as a pull request. +- Include a test, documentation and, where it helps, an example. -- For a small change, just send a PR. -- For bigger changes open an issue for discussion before sending a PR. -- PR should have: - - Test case - - Documentation - - Example (If it makes sense) -- You can also contribute by: - - Reporting issues - - Suggesting new features or enhancements - - Improve/fix documentation +Report security issues privately as [SECURITY.md](https://github.com/labstack/echo/blob/master/SECURITY.md) describes, not in public issues. ## Credits -- [Vishal Rana](https://github.com/vishr) (Author) +- [Vishal Rana](https://github.com/vishr) (Author and Maintainer) - [Nitin Rana](https://github.com/nr17) (Consultant) -- [Roland Lammel](https://github.com/lammel) (Maintainer) -- [Martti T.](https://github.com/aldas) (Maintainer) -- [Pablo Andres Fuente](https://github.com/pafuent) (Maintainer) +- [Martti T.](https://github.com/aldas) (Maintainer Emeritus) +- [Roland Lammel](https://github.com/lammel) (Maintainer Emeritus) +- [Pablo Andres Fuente](https://github.com/pafuent) (Maintainer Emeritus) - [Contributors](https://github.com/labstack/echo/graphs/contributors) ## License -[MIT](https://github.com/labstack/echo/blob/master/LICENSE) +[MIT](https://github.com/labstack/echo/blob/v4/LICENSE) diff --git a/bind.go b/bind.go index 1d4fe6f0a..983ef50ee 100644 --- a/bind.go +++ b/bind.go @@ -22,6 +22,13 @@ type Binder interface { } // DefaultBinder is the default implementation of the Binder interface. +// For path, query, header, and form binding, time.Time and *time.Time fields (not slices) +// may use a format tag: "date-time" uses the standard RFC3339 decoding, "date-time-local" +// accepts "2006-01-02T15:04:05" with optional fractional seconds, "date" accepts +// "2006-01-02", and other values specify Go time layouts. "date", "date-time-local", and +// layouts without a timezone give UTC times. JSON and XML decoding do not use this tag. +// HTML datetime-local inputs omit seconds by default, so bind them with a Go layout +// such as "2006-01-02T15:04" instead of "date-time-local". type DefaultBinder struct{} // BindUnmarshaler is the interface used to wrap the UnmarshalParam method. @@ -42,10 +49,11 @@ type bindMultipleUnmarshaler interface { // BindPathParams binds path params to bindable object // // Time format support: time.Time fields can use `format` tags to specify custom parsing layouts. -// Example: `param:"created" format:"2006-01-02T15:04"` for datetime-local format +// Example: `form:"created" format:"2006-01-02T15:04"` for HTML datetime-local input // Example: `param:"date" format:"2006-01-02"` for date format // Uses Go's standard time format reference time: Mon Jan 2 15:04:05 MST 2006 -// Works with form data, query parameters, and path parameters (not JSON body) +// OpenAPI format names "date-time", "date-time-local" and "date" are also supported (see DefaultBinder) +// Works with form data, query parameters, headers, and path parameters (not JSON body) // Falls back to default time.Time parsing if no format tag is specified func (b *DefaultBinder) BindPathParams(c Context, i interface{}) error { names := c.ParamNames() @@ -95,6 +103,10 @@ func (b *DefaultBinder) BindBody(c Context, i interface{}) (err error) { } case MIMEApplicationXML, MIMETextXML: if err = xml.NewDecoder(req.Body).Decode(i); err != nil { + var httpError *HTTPError + if errors.As(err, &httpError) { + return httpError + } if ute, ok := err.(*xml.UnsupportedTypeError); ok { return NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Unsupported type error: type=%v, error=%v", ute.Type, ute.Error())).SetInternal(err) } else if se, ok := err.(*xml.SyntaxError); ok { @@ -105,6 +117,10 @@ func (b *DefaultBinder) BindBody(c Context, i interface{}) (err error) { case MIMEApplicationForm: params, err := c.FormParams() if err != nil { + var httpError *HTTPError + if errors.As(err, &httpError) { + return httpError + } return NewHTTPError(http.StatusBadRequest, err.Error()).SetInternal(err) } if err = b.bindData(i, params, "form", nil); err != nil { @@ -113,6 +129,10 @@ func (b *DefaultBinder) BindBody(c Context, i interface{}) (err error) { case MIMEMultipartForm: params, err := c.MultipartForm() if err != nil { + var httpError *HTTPError + if errors.As(err, &httpError) { + return httpError + } return NewHTTPError(http.StatusBadRequest, err.Error()).SetInternal(err) } if err = b.bindData(i, params.Value, "form", params.File); err != nil { @@ -178,6 +198,11 @@ func (b *DefaultBinder) bindData(destination interface{}, data map[string][]stri val.Set(reflect.MakeMap(typ)) } for k, v := range data { + // A scalar destination needs a value. Slice destinations can retain + // empty slices, which is their existing binding behavior. + if len(v) == 0 && !isElemSliceOfStrings { + continue + } if isElemString { val.SetMapIndex(reflect.ValueOf(k), reflect.ValueOf(v[0])) } else if isElemInterface { @@ -258,6 +283,17 @@ func (b *DefaultBinder) bindData(destination interface{}, data map[string][]stri if !exists { continue } + if len(inputValue) == 0 { + // Preserve custom multi-value unmarshalling, but otherwise treat + // entries without values as absent (without allocating pointers). + value := structField + if structFieldKind != reflect.Pointer { + value = structField.Addr() + } + if _, multiple := value.Interface().(bindMultipleUnmarshaler); !multiple { + continue + } + } // NOTE: algorithm here is not particularly sophisticated. It probably does not work with absurd types like `**[]*int` // but it is smart enough to handle niche cases like `*int`,`*[]string`,`[]*int` . @@ -375,10 +411,19 @@ func unmarshalInputToField(valueKind reflect.Kind, val string, field reflect.Val fieldIValue := field.Addr().Interface() - // Handle time.Time with custom format tag - if formatTag != "" { + // date-time uses the same TextUnmarshaler as an untagged time.Time. + if formatTag != "" && formatTag != "date-time" { if _, isTime := fieldIValue.(*time.Time); isTime { - t, err := time.Parse(formatTag, val) + // OpenAPI dates and local date-times have no timezone. time.Parse assigns UTC, + // as it does for custom layouts without timezone information. + layout := formatTag + switch formatTag { + case "date": + layout = "2006-01-02" + case "date-time-local": + layout = "2006-01-02T15:04:05" + } + t, err := time.Parse(layout, val) if err != nil { return true, err } diff --git a/bind_test.go b/bind_test.go index 3e387ba19..a6560de32 100644 --- a/bind_test.go +++ b/bind_test.go @@ -1577,7 +1577,7 @@ func TestTimeFormatBinding(t *testing.T) { DateTimeLocal time.Time `form:"datetime_local" format:"2006-01-02T15:04"` Date time.Time `query:"date" format:"2006-01-02"` CustomFormat time.Time `form:"custom" format:"01/02/2006 15:04:05"` - DefaultTime time.Time `form:"default_time"` // No format tag - should use default parsing + DefaultTime time.Time `form:"default_time"` // No format tag - should use default parsing PtrTime *time.Time `query:"ptr_time" format:"2006-01-02"` } @@ -1623,7 +1623,7 @@ func TestTimeFormatBinding(t *testing.T) { { name: "nok, wrong format should fail", contentType: MIMEApplicationForm, - data: "datetime_local=2023-12-25", // Missing time part + data: "datetime_local=2023-12-25", // Missing time part expectError: true, }, } @@ -1684,3 +1684,254 @@ func TestTimeFormatBinding(t *testing.T) { }) } } + +func TestOpenAPIFormatTimeBinding(t *testing.T) { + type TestStruct struct { + DateTime time.Time `param:"date_time" query:"date_time" header:"date_time" form:"date_time" format:"date-time"` + DateTimePtr *time.Time `param:"date_time_ptr" query:"date_time_ptr" header:"date_time_ptr" form:"date_time_ptr" format:"date-time"` + DateTimeLocal time.Time `param:"date_time_local" query:"date_time_local" header:"date_time_local" form:"date_time_local" format:"date-time-local"` + DateTimeLocalPtr *time.Time `param:"date_time_local_ptr" query:"date_time_local_ptr" header:"date_time_local_ptr" form:"date_time_local_ptr" format:"date-time-local"` + Date time.Time `param:"full_date" query:"full_date" header:"full_date" form:"full_date" format:"date"` + DatePtr *time.Time `param:"full_date_ptr" query:"full_date_ptr" header:"full_date_ptr" form:"full_date_ptr" format:"date"` + } + + utc := time.Date(2023, 12, 25, 14, 30, 45, 0, time.UTC) + testCases := []struct { + name, field, value string + want time.Time + wantError bool + }{ + {name: "ok, date-time UTC", field: "date_time", value: "2023-12-25T14:30:45Z", want: utc}, + {name: "ok, date-time offset and fraction", field: "date_time", value: "2023-12-25T14:30:45.123456789+02:00", want: time.Date(2023, 12, 25, 14, 30, 45, 123456789, time.FixedZone("", 2*60*60))}, + {name: "nok, date-time requires timezone", field: "date_time", value: "2023-12-25T14:30:45", wantError: true}, + {name: "nok, date-time invalid date", field: "date_time", value: "2023-02-30T14:30:45Z", wantError: true}, + {name: "ok, local date-time", field: "date_time_local", value: "2023-12-25T14:30:45", want: utc}, + {name: "ok, local date-time fraction", field: "date_time_local", value: "2023-12-25T14:30:45.123456789", want: utc.Add(123456789 * time.Nanosecond)}, + {name: "nok, local date-time rejects UTC suffix", field: "date_time_local", value: "2023-12-25T14:30:45Z", wantError: true}, + {name: "nok, local date-time rejects offset", field: "date_time_local", value: "2023-12-25T14:30:45+02:00", wantError: true}, + {name: "nok, local date-time requires seconds", field: "date_time_local", value: "2023-12-25T14:30", wantError: true}, + {name: "nok, local date-time invalid date", field: "date_time_local", value: "2023-02-30T14:30:45", wantError: true}, + {name: "ok, date", field: "full_date", value: "2023-12-25", want: time.Date(2023, 12, 25, 0, 0, 0, 0, time.UTC)}, + {name: "nok, date rejects time", field: "full_date", value: "2023-12-25T14:30:45Z", wantError: true}, + {name: "nok, date invalid date", field: "full_date", value: "2023-02-30", wantError: true}, + } + for _, source := range []string{"param", "query", "header", "form", "multipart"} { + for _, tc := range testCases { + for _, pointer := range []bool{false, true} { + t.Run(fmt.Sprintf("%s/%s/pointer=%t", source, tc.name, pointer), func(t *testing.T) { + field := tc.field + if pointer { + field += "_ptr" + } + req := httptest.NewRequest(http.MethodGet, "/", nil) + switch source { + case "query": + req.URL.RawQuery = url.Values{field: {tc.value}}.Encode() + case "header": + req.Header.Set(field, tc.value) + case "form": + req = httptest.NewRequest(http.MethodPost, "/", strings.NewReader(url.Values{field: {tc.value}}.Encode())) + req.Header.Set(HeaderContentType, MIMEApplicationForm) + case "multipart": + var body bytes.Buffer + writer := multipart.NewWriter(&body) + if !assert.NoError(t, writer.WriteField(field, tc.value)) || !assert.NoError(t, writer.Close()) { + return + } + req = httptest.NewRequest(http.MethodPost, "/", &body) + req.Header.Set(HeaderContentType, writer.FormDataContentType()) + } + c := New().NewContext(req, httptest.NewRecorder()) + c.SetParamNames(field) + c.SetParamValues(tc.value) + b := new(DefaultBinder) + var result TestStruct + var err error + switch source { + case "param": + err = b.BindPathParams(c, &result) + case "query": + err = b.BindQueryParams(c, &result) + case "header": + err = b.BindHeaders(c, &result) + default: + err = b.BindBody(c, &result) + } + if tc.wantError { + var he *HTTPError + if assert.ErrorAs(t, err, &he) { + assert.Equal(t, http.StatusBadRequest, he.Code) + } + return + } + if !assert.NoError(t, err) { + return + } + actual, actualPtr := result.DateTime, result.DateTimePtr + switch tc.field { + case "date_time_local": + actual, actualPtr = result.DateTimeLocal, result.DateTimeLocalPtr + case "full_date": + actual, actualPtr = result.Date, result.DatePtr + } + if pointer { + if !assert.NotNil(t, actualPtr) { + return + } + actual = *actualPtr + } + assert.True(t, tc.want.Equal(actual), "expected %v, got %v", tc.want, actual) + _, wantOffset := tc.want.Zone() + _, actualOffset := actual.Zone() + assert.Equal(t, wantOffset, actualOffset) + if tc.field != "date_time" { + assert.Same(t, time.UTC, actual.Location()) + } + }) + } + } + } +} + +func TestOpenAPIFormatTimeBindingJSON(t *testing.T) { + for _, tc := range []struct { + name, value string + wantError bool + }{ + {name: "ok, RFC3339 decoding ignores format tag", value: "2023-12-25T14:30:45Z"}, + {name: "nok, local format tag does not enable timezone-free JSON", value: "2023-12-25T14:30:45", wantError: true}, + } { + t.Run(tc.name, func(t *testing.T) { + var result struct { + Time *time.Time `json:"time" format:"date-time-local"` + } + req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(fmt.Sprintf(`{"time":%q}`, tc.value))) + req.Header.Set(HeaderContentType, MIMEApplicationJSON) + err := New().NewContext(req, httptest.NewRecorder()).Bind(&result) + if tc.wantError { + var he *HTTPError + if assert.ErrorAs(t, err, &he) { + assert.Equal(t, http.StatusBadRequest, he.Code) + } + return + } + if assert.NoError(t, err) && assert.NotNil(t, result.Time) { + assert.True(t, time.Date(2023, 12, 25, 14, 30, 45, 0, time.UTC).Equal(*result.Time)) + } + }) + } +} + +func TestOpenAPIFormatTimeBindingCustomTypes(t *testing.T) { + var result struct { + Time Timestamp `query:"time" format:"date-time-local"` + Text string `query:"text" format:"date-time-local"` + } + value := "2023-12-25T14:30:45Z" + req := httptest.NewRequest(http.MethodGet, "/?"+url.Values{"time": {value}, "text": {value}}.Encode(), nil) + err := New().NewContext(req, httptest.NewRecorder()).Bind(&result) + if assert.NoError(t, err) { + assert.True(t, time.Date(2023, 12, 25, 14, 30, 45, 0, time.UTC).Equal(time.Time(result.Time))) + assert.Equal(t, value, result.Text) + } +} + +func TestOpenAPIFormatTimeBindingEdgeCases(t *testing.T) { + type TestStruct struct { + DateTime time.Time `query:"date_time" format:"date-time"` + DateTimeLocal time.Time `query:"date_time_local" format:"date-time-local"` + Date time.Time `query:"full_date" format:"date"` + Slice []time.Time `query:"slice" format:"date-time-local"` + } + testCases := []struct { + name, query string + wantError bool + }{ + {name: "nok, literal date-time name is not a value", query: "date_time=date-time", wantError: true}, + {name: "nok, literal date-time-local name is not a value", query: "date_time_local=date-time-local", wantError: true}, + {name: "nok, literal date name is not a value", query: "full_date=date", wantError: true}, + {name: "ok, slices ignore the format tag", query: "slice=2023-12-25T14%3A30%3A45Z"}, + {name: "nok, slices ignore the format tag", query: "slice=2023-12-25T14%3A30%3A45", wantError: true}, + } + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/?"+tc.query, nil) + c := New().NewContext(req, httptest.NewRecorder()) + var result TestStruct + err := new(DefaultBinder).BindQueryParams(c, &result) + if tc.wantError { + assert.Error(t, err) + return + } + if assert.NoError(t, err) && assert.Len(t, result.Slice, 1) { + assert.True(t, time.Date(2023, 12, 25, 14, 30, 45, 0, time.UTC).Equal(result.Slice[0])) + } + }) + } +} + +func TestBindHeadersEmptyValues(t *testing.T) { + for _, values := range [][]string{nil, {}, {""}, {"value"}} { + t.Run(fmt.Sprintf("%#v", values), func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/", nil) + req.Header["X-Value"] = values + c := New().NewContext(req, httptest.NewRecorder()) + want := "original" + if len(values) > 0 { + want = values[0] + } + var dst = struct { + Value string `header:"X-Value"` + }{Value: "original"} + assert.NotPanics(t, func() { assert.NoError(t, new(DefaultBinder).BindHeaders(c, &dst)) }) + assert.Equal(t, want, dst.Value) + m := map[string]string{"X-Value": "original"} + assert.NotPanics(t, func() { assert.NoError(t, new(DefaultBinder).BindHeaders(c, &m)) }) + assert.Equal(t, want, m["X-Value"]) + mi := map[string]any{"X-Value": "original"} + assert.NotPanics(t, func() { assert.NoError(t, new(DefaultBinder).BindHeaders(c, &mi)) }) + assert.Equal(t, want, mi["X-Value"]) + ms := map[string][]string{} + assert.NoError(t, new(DefaultBinder).BindHeaders(c, &ms)) + assert.Equal(t, values, ms["X-Value"]) + if len(values) == 0 { + var pointer struct { + Value *string `header:"X-Value"` + } + assert.NoError(t, new(DefaultBinder).BindHeaders(c, &pointer)) + assert.Nil(t, pointer.Value) + } + }) + } +} + +type headerValuesUnmarshaler struct { + called bool + values []string +} + +func (v *headerValuesUnmarshaler) UnmarshalParams(values []string) error { + v.called, v.values = true, values + return nil +} + +func TestBindHeadersEmptyValuesCustomUnmarshaler(t *testing.T) { + for _, values := range [][]string{nil, {}} { + req := httptest.NewRequest(http.MethodGet, "/", nil) + req.Header["X-Value"] = values + var dst struct { + Value headerValuesUnmarshaler `header:"X-Value"` + } + assert.NoError(t, new(DefaultBinder).BindHeaders(New().NewContext(req, httptest.NewRecorder()), &dst)) + assert.True(t, dst.Value.called) + assert.Equal(t, values, dst.Value.values) + var pointer struct { + Value *headerValuesUnmarshaler `header:"X-Value"` + } + assert.NoError(t, new(DefaultBinder).BindHeaders(New().NewContext(req, httptest.NewRecorder()), &pointer)) + if assert.NotNil(t, pointer.Value) { + assert.True(t, pointer.Value.called) + assert.Equal(t, values, pointer.Value.values) + } + } +} diff --git a/client_ip_security_test.go b/client_ip_security_test.go new file mode 100644 index 000000000..e11eb0916 --- /dev/null +++ b/client_ip_security_test.go @@ -0,0 +1,52 @@ +// SPDX-License-Identifier: MIT +// SPDX-FileCopyrightText: © 2015 LabStack LLC and Echo contributors + +package echo + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestContextRealIPIgnoresUntrustedHeaders(t *testing.T) { + for _, remote := range []struct { + address string + want string + }{ + {"198.51.100.10:1234", "198.51.100.10"}, + {"[2001:db8::10]:1234", "2001:db8::10"}, + {"198.51.100.10", "198.51.100.10"}, + {"2001:db8::10", "2001:db8::10"}, + {"invalid", ""}, + } { + t.Run(remote.address, func(t *testing.T) { + for _, headers := range []http.Header{ + {}, + {HeaderXForwardedFor: {"203.0.113.1"}}, + {HeaderXForwardedFor: {"203.0.113.1, 203.0.113.2", "203.0.113.3"}}, + {HeaderXRealIP: {"203.0.113.1"}}, + {HeaderXRealIP: {"[2001:db8::1]"}}, + {HeaderXForwardedFor: {"invalid"}, HeaderXRealIP: {"203.0.113.1"}}, + } { + req := httptest.NewRequest(http.MethodGet, "/", nil) + req.RemoteAddr = remote.address + req.Header = headers + assert.Equal(t, remote.want, New().NewContext(req, httptest.NewRecorder()).RealIP()) + assert.Equal(t, remote.want, (&context{request: req}).RealIP()) + } + }) + } +} + +func TestContextRealIPPreservesCustomExtractor(t *testing.T) { + e := New() + e.IPExtractor = func(r *http.Request) string { + return r.Header.Get("Custom-Client-IP") + } + req := httptest.NewRequest(http.MethodGet, "/", nil) + req.Header.Set("Custom-Client-IP", "203.0.113.10") + assert.Equal(t, "203.0.113.10", e.NewContext(req, httptest.NewRecorder()).RealIP()) +} diff --git a/context.go b/context.go index 67e83181c..07b24e0ca 100644 --- a/context.go +++ b/context.go @@ -9,7 +9,6 @@ import ( "fmt" "io" "mime/multipart" - "net" "net/http" "net/url" "strings" @@ -40,9 +39,10 @@ type Context interface { // Scheme returns the HTTP protocol scheme, `http` or `https`. Scheme() string - // RealIP returns the client's network address based on `X-Forwarded-For` - // or `X-Real-IP` request header. - // The behavior can be configured using `Echo#IPExtractor`. + // RealIP returns the client IP from Echo#IPExtractor, or the connection peer + // when no extractor is configured. Forwarding headers are ignored by default. + // Applications behind proxies must configure an extractor that trusts only + // their own proxies to recover individual client addresses. RealIP() string // Path returns the registered path for the handler. @@ -134,10 +134,17 @@ type Context interface { // JSONP sends a JSONP response with status code. It uses `callback` to construct // the JSONP payload. + // + // The callback must be empty, a JavaScript identifier or a dot-separated path of identifiers using only ASCII + // letters, digits, `_` and `$`. Otherwise nothing is written and a 400 Bad Request error wrapping + // ErrInvalidJSONPCallback is returned. The response has the `X-Content-Type-Options: nosniff` header. + // + // Any website can load a JSONP response with a