Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 8 additions & 8 deletions .agents/skills/echo-guard/SKILL.md
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
---
name: echo-guard
description: Use when wiring guard-core-go security into a Go Echo service, or when working in github.com/rennf93/echo-guard: build the middleware with guardecho.New(engine, opts...) (package echo, alias it to avoid clashing with github.com/labstack/echo/v4), tune WithMaxBodyBytes (default 262144) and WithLogger, attach route identity with WithRouteID on the request context (c.SetRequest), understand requestShim adaptation of echo.Context to guardcore.Request including the bounded body prefix scan, idempotent Body(), and replay so handlers still receive the full stream, exact verdict translation into c.Response() writes with the chain stopped by not calling next(c) (Echo has no Abort call), fail-closed 500 on engine malfunction, and Redis-backed integration tests via REDIS_HOST and go test -tags integration.
description: Use when wiring guard-core-go security into a Go Echo service, or when working in github.com/rennf93/echo-guard: build the middleware with guardecho.New(engine, opts...) (package echo, alias it to avoid clashing with github.com/labstack/echo/v5), tune WithMaxBodyBytes (default 262144) and WithLogger, attach route identity with WithRouteID on the request context (c.SetRequest), understand requestShim adaptation of echo.Context to guardcore.Request including the bounded body prefix scan, idempotent Body(), and replay so handlers still receive the full stream, exact verdict translation into c.Response() writes with the chain stopped by not calling next(c) (Echo has no Abort call), fail-closed 500 on engine malfunction, and Redis-backed integration tests via REDIS_HOST and go test -tags integration.
---

# echo-guard

Echo middleware adapter for [guard-core-go](https://github.com/Guard-Core/guard-core-go). Translates `echo.Context` into the guardcore request surface, runs the engine, and translates verdicts to exact Echo responses (status, headers, body, then stop the chain). Contains no security logic itself. Module: `github.com/rennf93/echo-guard`, Go `1.25.0`, released at `v1.1.0`.
Echo middleware adapter for [guard-core-go](https://github.com/Guard-Core/guard-core-go). Translates `echo.Context` into the guardcore request surface, runs the engine, and translates verdicts to exact Echo responses (status, headers, body, then stop the chain). Contains no security logic itself. Module: `github.com/rennf93/echo-guard/v2`, Go `1.26.0`, Echo v5 (v2.0.0; Echo v4 users stay on the v1.x line).

## Quick Reference

Expand All @@ -22,16 +22,16 @@ Behavior contract: the engine sees at most `MaxBodyBytes` of the body prefix; pa
## Installation

```sh
go get github.com/rennf93/echo-guard@v1.1.0 github.com/rennf93/guard-core-go/v4@v4.0.5-0.20260926230539-e39ac203568b
go get github.com/rennf93/echo-guard/v2@v2.0.0 github.com/rennf93/guard-core-go/v4@v4.4.1
```

The package name is `echo`, which collides with `github.com/labstack/echo/v4`, so import the adapter with an alias:
The package name is `echo`, which collides with `github.com/labstack/echo/v5`, so import the adapter with an alias:

```go
import (
echolib "github.com/labstack/echo/v4"
echolib "github.com/labstack/echo/v5"
guardcore "github.com/rennf93/guard-core-go/v4/guardcore"
guardecho "github.com/rennf93/echo-guard"
guardecho "github.com/rennf93/echo-guard/v2"
)
```

Expand All @@ -48,7 +48,7 @@ if err != nil { log.Fatal(err) }

router := echolib.New()
router.Use(guard)
router.GET("/", func(c echolib.Context) error { return c.String(200, "ok") })
router.GET("/", func(c *echolib.Context) error { return c.String(200, "ok") })
log.Fatal(router.Start(":8080"))
```

Expand Down Expand Up @@ -99,7 +99,7 @@ func WithRouteID(ctx context.Context, routeID string) context.Context

## Footguns

- Package name collision: `github.com/rennf93/echo-guard` and `github.com/labstack/echo/v4` are both package `echo`; always alias the adapter (for example `guardecho`).
- Package name collision: `github.com/rennf93/echo-guard/v2` and `github.com/labstack/echo/v5` are both package `echo`; always alias the adapter (for example `guardecho`).
- The engine only scans the first `MaxBodyBytes` bytes. A malicious marker beyond the bound is not detected and the request passes; verify bounds when a route accepts large payloads.
- `WithMaxBodyBytes(-1)` and `WithLogger(nil)` are silently ignored, not errors. Guard against typos that drop real configuration.
- Client identity comes from `RemoteAddr`, never `c.RealIP()`. Do not "fix" this in the adapter; proxy trust is core configuration territory.
Expand Down
8 changes: 4 additions & 4 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ name: Release Gate
# can only run once the tag exists.
#
# Re-check: run this workflow by hand (Actions > Release Gate > Run workflow)
# with the tag, for example v1.5.0. It checks out that tag, so a fixed
# with the tag, for example v2.0.0. It checks out that tag, so a fixed
# workflow on the default branch can re-check an existing tag.
on:
push:
Expand All @@ -21,7 +21,7 @@ on:
workflow_dispatch:
inputs:
tag:
description: 'Release tag to check, for example v1.5.0'
description: 'Release tag to check, for example v2.0.0'
required: true

permissions: {}
Expand Down Expand Up @@ -58,7 +58,7 @@ jobs:
tag="$REF_NAME"
fi
if ! printf '%s' "$tag" | grep -Eq '^v[0-9]+\.[0-9]+\.[0-9]+$'; then
echo "::error::tag '$tag' is not of the form v1.2.3"
echo "::error::tag '$tag' is not of the form v2.0.0"
exit 1
fi
echo "tag=$tag" >> "$GITHUB_OUTPUT"
Expand Down Expand Up @@ -119,7 +119,7 @@ jobs:

- name: Verify module tag resolves
env:
MODULE: github.com/rennf93/echo-guard
MODULE: github.com/rennf93/echo-guard/v2
TAG: ${{ needs.verify.outputs.tag }}
run: |
cd "$(mktemp -d)"
Expand Down
31 changes: 16 additions & 15 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,19 +5,20 @@ Guidance for AI agents (including Claude Code) working in this repository.

echo-guard is an Echo middleware adapter for [guard-core-go](https://github.com/Guard-Core/guard-core-go). It translates `echo.Context` into the guardcore request surface, runs the engine, and translates verdicts to exact Echo responses. It contains NO security logic of its own.

- Module: `github.com/rennf93/echo-guard`, Go directive `go 1.25.0`, MIT license.
- Module: `github.com/rennf93/echo-guard/v2`, Go directive `go 1.26.0` (echo v5.4.0 itself needs 1.25.0), MIT license.
- Single Go package `echo` at the repo root. Source files: `middleware.go`, `request.go`. Tests: `middleware_test.go`, `integration_test.go`. There are no subpackage directories.
- Release state: no tags exist yet. The repo ships untagged; depend on it via a commit reference until the first tag is cut. State this honestly; there is no changelog beyond git history.
- Release state: v1.x targets Echo v4 (last release v1.5.0). This branch is the Echo v5 line, module `github.com/rennf93/echo-guard/v2`, released as v2.0.0 (see `CHANGELOG.md`). Echo v4 users stay on v1.x.
- Echo v5 notes: `echo.Context` is a concrete struct, so handlers and middleware take `*echo.Context`; `c.Response()` returns an `http.ResponseWriter` (the `*echo.Response` is reached with `echo.UnwrapResponse`); `c.Error` is gone (the error handler is `c.Echo().HTTPErrorHandler`).
- The public surface is small and deliberate: `New`, `Option`, `WithMaxBodyBytes`, `WithLogger`, `WithRouteID`, and the `DefaultMaxBodyBytes` constant (262144).

## Ecosystem Position

This repo is the ADAPTER layer of the guard-core ecosystem:

- `guard-core-go` is the engine. All detection (suspicious activity, IP bans, rate limits, HTTPS enforcement), verdict construction, and error response factories live there.
- This repo wires Go Echo types to that engine and nothing more. It consumes `github.com/rennf93/guard-core-go/v4 v4.0.5-0.20260926230539-e39ac203568b` as a normal module dependency (see `go.mod`); no `replace` directive is used or needed. For cross-repo work on the core, add a temporary local `replace` in your own checkout and drop it before committing.
- This repo wires Go Echo types to that engine and nothing more. It consumes `github.com/rennf93/guard-core-go/v4 v4.4.1` as a normal module dependency (see `go.mod`); no `replace` directive is used or needed. For cross-repo work on the core, add a temporary local `replace` in your own checkout and drop it before committing.
- Because the middleware is an `echo.MiddlewareFunc`, it composes with `e.Use` and runs anywhere in an Echo handler chain. Echo has no explicit abort call: a verdict is written to `c.Response()` and the chain stops because the middleware returns nil without calling `next(c)`.
- Direct dependencies: `github.com/labstack/echo/v4 v4.15.4` (the framework this adapter exists for; a framework import is allowed here, unlike in the core) and `github.com/rennf93/guard-core-go/v4 v4.0.5-0.20260926230539-e39ac203568b`. Notable indirect floors: the core's transitives (`redis/go-redis/v9 v9.22.0`, `dlclark/regexp2 v1.12.0`, `go.uber.org/atomic`, `golang.org/x/text v0.41.0`) plus echo's own (`golang.org/x/crypto v0.53.0`, `golang.org/x/net v0.56.0`). govulncheck reports zero vulnerabilities reachable from this module's code; the module-level findings in `x/crypto/ssh` and `x/crypto/openpgp` are never called here, so no floor bump was needed (unlike gin-guard's quic-go bump for GO-2026-5676).
- Direct dependencies: `github.com/labstack/echo/v5 v5.4.0` (the framework this adapter exists for; a framework import is allowed here, unlike in the core) and `github.com/rennf93/guard-core-go/v4 v4.4.1`. Notable indirect floors: the core's transitives (`redis/go-redis/v9 v9.22.0`, `dlclark/regexp2 v1.12.0`, `go.uber.org/atomic`, `golang.org/x/text v0.41.0`) plus echo's own (`golang.org/x/crypto v0.53.0`, `golang.org/x/net v0.56.0`). govulncheck reports zero vulnerabilities reachable from this module's code; the module-level findings in `x/crypto/ssh` and `x/crypto/openpgp` are never called here, so no floor bump was needed (unlike gin-guard's quic-go bump for GO-2026-5676).

## Boundary Rules

Expand Down Expand Up @@ -48,9 +49,9 @@ Minimal usage (from README.md):

```go
import (
echolib "github.com/labstack/echo/v4"
echolib "github.com/labstack/echo/v5"
guardcore "github.com/rennf93/guard-core-go/v4/guardcore"
guardecho "github.com/rennf93/echo-guard"
guardecho "github.com/rennf93/echo-guard/v2"
)

cfg := guardcore.DefaultSecurityConfig()
Expand All @@ -63,15 +64,15 @@ if err != nil { log.Fatal(err) }

router := echolib.New()
router.Use(guard)
router.GET("/api", func(c echolib.Context) error { return c.String(200, "ok") })
router.GET("/api", func(c *echolib.Context) error { return c.String(200, "ok") })
log.Fatal(router.Start(":8080"))
```

Note the import alias: the module path ends in `echo-guard` but the package name is `echo`, which collides with `github.com/labstack/echo/v4` (also package `echo`), so an explicit alias such as `guardecho "github.com/rennf93/echo-guard"` is the idiomatic import.
Note the import alias: the module path ends in `echo-guard` but the package name is `echo`, which collides with `github.com/labstack/echo/v5` (also package `echo`), so an explicit alias such as `guardecho "github.com/rennf93/echo-guard/v2"` is the idiomatic import.

## Development Commands

There is no Makefile. Every command below comes from the CI workflows or the README.
A Makefile exists (`install`, `test`, `lint`, `bump-version`, `clean`, Docker based), but the commands below come from the CI workflows (the build files win over this doc).

| Command | Purpose | Verified in |
| --- | --- | --- |
Expand All @@ -85,7 +86,7 @@ There is no Makefile. Every command below comes from the CI workflows or the REA
| `docker compose -f examples/simple_app/docker-compose.yml up --build -d --wait` | Local live smoke of the guarded example app (assertions in `.github/workflows/live-smoke.yml`) | `.github/workflows/live-smoke.yml` |
| `mkdocs build --strict` | Build the MkDocs site (docs.yml does the same before gh-deploy) | `.github/workflows/docs.yml` |

CI runs the test job on a Go matrix of `1.25.x` and `1.26.x` (fail-fast disabled) with `GOTOOLCHAIN: auto`, plus a separate `govulncheck` job on stable Go. The integration step runs against a `redis:7-alpine` service container on port 6379 with `REDIS_HOST=127.0.0.1`.
CI runs the test job on Go `1.26.x` (a one-entry matrix, fail-fast disabled) with `GOTOOLCHAIN: auto`, plus a separate `govulncheck` job on stable Go. The integration step runs against a `redis:7-alpine` service container on port 6379 with `REDIS_HOST=127.0.0.1`.

## Project Structure

Expand All @@ -95,7 +96,7 @@ CI runs the test job on a Go matrix of `1.25.x` and `1.26.x` (fail-fast disabled
├── request.go # requestShim (implements guardcore.Request), WithRouteID, DefaultMaxBodyBytes, replayBody
├── middleware_test.go # unit tests, real echo.Echo, Redis disabled
├── integration_test.go # //go:build integration, Redis-backed, skips when REDIS_HOST is unset
├── go.mod / go.sum # module github.com/rennf93/echo-guard, requires echo v4.15.4 and guard-core-go/v4 v4.0.5-0.20260926230539-e39ac203568b
├── go.mod / go.sum # module github.com/rennf93/echo-guard/v2, requires echo v5.4.0 and guard-core-go/v4 v4.4.1
├── README.md # usage, options, integration test instructions
├── LICENSE # MIT
├── mkdocs.yml # MkDocs site config (material theme; nav: Home/Usage/Configuration)
Expand Down Expand Up @@ -125,16 +126,16 @@ CI runs the test job on a Go matrix of `1.25.x` and `1.26.x` (fail-fast disabled

## Technology Stack

- Go, directive `go 1.25.0`; CI matrix tests 1.25.x and 1.26.x.
- `github.com/rennf93/guard-core-go/v4 v4.0.5-0.20260926230539-e39ac203568b` (direct require in `go.mod`), providing `guardcore.Engine`, `guardcore.Request`, `guardcore.Response`, `guardcore.SecurityConfig`.
- `github.com/labstack/echo/v4 v4.15.4` (direct require in `go.mod`), providing `echo.MiddlewareFunc` and `echo.Context` for the bridging surface.
- Go, directive `go 1.26.0`; CI runs the test job on 1.26.x.
- `github.com/rennf93/guard-core-go/v4 v4.4.1` (direct require in `go.mod`), providing `guardcore.Engine`, `guardcore.Request`, `guardcore.Response`, `guardcore.SecurityConfig`.
- `github.com/labstack/echo/v5 v5.4.0` (direct require in `go.mod`), providing `echo.MiddlewareFunc` and `echo.Context` for the bridging surface.
- Redis 7 for integration tests (CI service container `redis:7-alpine`); runtime Redis usage is a guard-core-go concern, not this adapter's.
- GitHub Actions: CI on push and pull_request, Release Gate on `v*` tag push, weekly Scheduled Lint, CodeQL (go), dockerized Live Smoke of the example apps on push/PR to master, MkDocs docs deploy to GitHub Pages on docs pushes to master, demo container publishing to ghcr.io on releases and examples changes, daily upstream drift tests against guard-core-go@master, issue-link, greetings, labeler, stale, summary, and label sync; Dependabot for gomod and actions. All workflows use minimal permissions and pinned action SHAs.

## Testing Guidelines

- Unit tests live in `middleware_test.go` and run with plain `go test ./...`. The helper `newTestEngine` builds a config with `EnableRedis = false` so no Redis is required.
- Tests drive a real `echo.Echo` (see the `serve` helper): the guard is registered with `router.Use`, a wildcard `router.Any("/*")` probe route records whether it was reached, the response headers it saw, and the method, path, query, and body it received. `newShimContext` builds a bare `echolib.Context` via `e.NewContext(r, httptest.NewRecorder())` for direct `requestShim` tests.
- Tests drive a real `echo.Echo` (see the `serve` helper): the guard is registered with `router.Use`, a wildcard `router.Any("/*")` probe route records whether it was reached, the response headers it saw, and the method, path, query, and body it received. `newShimContext` builds a bare `*echolib.Context` via `e.NewContext(r, httptest.NewRecorder())` for direct `requestShim` tests.
- Integration tests in `integration_test.go` carry the `//go:build integration` build tag. They skip (not fail) when `REDIS_HOST` is unset. They use `cfg.RedisURL = "redis://" + host + ":6379"`, `cfg.RedisPrefix = "guard_core_echo_test:"`, `cfg.RedisFailOpen = false`, and clean up the `banned_ips:*` and `rate_limit:rate:*` key patterns in `t.Cleanup` before closing the engine.
- Invariants the tests enforce; keep them true when changing code:
- A blocked request must never reach the handler; a clean pass must reach it with the original method, path, and query, and no adapter-added headers.
Expand Down
19 changes: 19 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,25 @@ Release Notes

___

v2.0.0 (2026-10-19)
-------------------

Echo v5 support as the `/v2` module
-----------------------------------

### Changed

- **Echo v5 support; module path `github.com/rennf93/echo-guard/v2`; Echo v4 users stay on v1.x.** The adapter now targets `github.com/labstack/echo/v5 v5.4.0`. Go's semantic import versioning requires the `/v2` suffix because the public types change (`echo.MiddlewareFunc`, `echo.HandlerFunc` and `echo.Context` are different types in v5). Import the adapter as `github.com/rennf93/echo-guard/v2` (keep an alias such as `guardecho`, the package name is still `echo`). Echo v4 applications keep using `github.com/rennf93/echo-guard` v1.x, which stays on `echo/v4`. The adapter behavior is identical: exact verdict translation, fail-closed 500, bounded body replay, route identity from the request context, `RemoteAddr` as the only client host.
- **Raised the engine floor to `github.com/rennf93/guard-core-go/v4 v4.4.1`.** v4.4.1 stops printing the spec DEBUG lines (`No countries blocked or whitelisted <ip>`, `Request blocked by <check>`, the private-peer spoof line) on every request; they go through `log/slog` at debug level.
- **Forced signature changes** (every other exported identifier is unchanged):
- `New(engine, opts...)` returns `echo.MiddlewareFunc` of Echo v5 (`func(next HandlerFunc) HandlerFunc` over `func(c *echo.Context) error`).
- `GuardWebSocket(engine, c)` takes `*echo.Context` instead of the v4 `echo.Context` interface.
- `NewStatusHandler(engine)` returns Echo v5's `echo.HandlerFunc` (`func(c *echo.Context) error`).
- `AddStatusRoute(app, engine, path)` takes Echo v5's `*echo.Echo`.
- **Behavior notes for the v5 port.** Echo v5 removed `c.Error` and exposes the response as an `http.ResponseWriter`: a handler error is turned into a response through `c.Echo().HTTPErrorHandler`, and the adapter reaches the `*echo.Response` with `echo.UnwrapResponse` to swap the capture and held writers underneath it, as v4 did through `Response.Writer`. A context whose response writer chain never reaches an `*echo.Response`, or a handler error on a context without an Echo instance, fails closed with the 500 `Security check failed` response.

___

v1.5.0 (2026-10-16)
-------------------

Expand Down
Loading
Loading