├── .dockerignore ├── .github └── workflows │ ├── binaries.yml │ ├── docker.yml │ ├── documentation.yml │ ├── helm.yml │ ├── notify.yml │ └── trivy.yml ├── .gitignore ├── Dockerfile ├── LICENSE ├── Makefile ├── README.md ├── cmd └── api-firewall │ ├── internal │ └── handlers │ │ ├── api │ │ ├── app.go │ │ ├── handler.go │ │ ├── health.go │ │ ├── routes.go │ │ ├── run.go │ │ └── updater.go │ │ ├── graphql │ │ ├── httpHandler.go │ │ ├── routes.go │ │ ├── run.go │ │ └── wsHandler.go │ │ └── proxy │ │ ├── health.go │ │ ├── openapi.go │ │ ├── routes.go │ │ ├── run.go │ │ └── updater.go │ ├── main.go │ └── tests │ ├── main_api_mode_bench_test.go │ ├── main_api_mode_test.go │ ├── main_dns_test.go │ ├── main_endpoints_test.go │ ├── main_graphql_bench_test.go │ ├── main_graphql_test.go │ ├── main_json_test.go │ ├── main_modsec_test.go │ ├── main_test.go │ ├── updater_test.go │ ├── updater_v2_test.go │ ├── wallarm_api2_empty.db │ ├── wallarm_api2_update.db │ ├── wallarm_api_after_update.db │ ├── wallarm_api_before_update.db │ ├── wallarm_api_empty.db │ ├── wallarm_api_invalid_file.db │ └── wallarm_api_invalid_schema.db ├── demo ├── docker-compose │ ├── .gitignore │ ├── Makefile │ ├── OWASP_CoreRuleSet │ │ ├── Makefile │ │ ├── README.md │ │ ├── coraza.conf │ │ ├── docker-compose.yml │ │ └── httpbin.json │ ├── README.md │ ├── damn-vulnerable-graphql-app-demo │ │ ├── docker-compose.yml │ │ └── schema.graphql │ ├── docker-compose-api-mode.yml │ ├── docker-compose-graphql-mode.yml │ ├── docker-compose.yml │ └── volumes │ │ ├── api-firewall │ │ ├── allowed.iplist.db │ │ ├── httpbin-with-constraints.json │ │ ├── httpbin.json │ │ ├── schema.graphql │ │ └── tokens.denylist.db │ │ └── wallarm_api.db ├── interface │ └── api-mode │ │ ├── internal │ │ └── updater │ │ │ └── updater.go │ │ ├── main.go │ │ └── wallarm_apifw_test.db └── kubernetes │ ├── .env │ ├── Makefile │ ├── README.md │ ├── docker-compose.yml │ ├── docker │ ├── Dockerfile │ ├── manifests │ │ └── init.yml │ └── scripts │ │ └── routines.py │ └── volumes │ ├── .gitignore │ ├── chart │ ├── cluster.yaml │ ├── dns │ └── Corefile │ ├── docker │ └── daemon.json │ ├── helm │ └── api-firewall.yaml │ └── init-backend │ ├── 01-helm.yaml │ └── 10-httpbin.yaml ├── docker-entrypoint.sh ├── docs.Dockerfile ├── docs ├── configuration-guides │ ├── allowlist.md │ ├── denylist-leaked-tokens.md │ ├── dns-cache-update.md │ ├── endpoint-related-response.md │ ├── ssl-tls.md │ ├── system-settings.md │ └── validate-tokens.md ├── demos │ ├── docker-compose.md │ ├── kubernetes-cluster.md │ └── owasp-coreruleset.md ├── images │ ├── favicon.png │ └── wallarm-logo.svg ├── include │ └── apifw-yaml-example.md ├── index.md ├── installation-guides │ ├── api-mode.md │ ├── docker-container.md │ └── graphql │ │ ├── docker-container.md │ │ ├── limit-compliance.md │ │ ├── playground.md │ │ └── websocket-origin-check.md ├── migrating │ └── modseс-to-apif.md ├── release-notes.md └── requirements.txt ├── go.mod ├── go.sum ├── helm └── api-firewall │ ├── .gitignore │ ├── .helmignore │ ├── Chart.yaml │ ├── README.ja.md │ ├── README.md │ ├── templates │ ├── _helpers.tpl │ ├── configmap.yaml │ ├── deployment.yaml │ ├── hpa.yaml │ ├── ingress.yaml │ ├── pdb.yaml │ ├── service.yaml │ ├── serviceaccount.yaml │ └── target.yaml │ └── values.yaml ├── images ├── BHA2022.svg ├── Firewall opensource - vertical.gif ├── firewall-as-proxy.png └── graphql-playground.png ├── internal ├── config │ ├── api.go │ ├── backend.go │ ├── config.go │ ├── dns.go │ ├── endpoints.go │ ├── endpoints_test.go │ ├── graphql.go │ ├── logging.go │ ├── modsec.go │ ├── proxy.go │ ├── server.go │ └── status.go ├── mid │ ├── allowiplist.go │ ├── denylist.go │ ├── errors.go │ ├── logger.go │ ├── mimetype.go │ ├── modsec.go │ ├── panics.go │ ├── proxy.go │ └── shadowAPI.go ├── platform │ ├── allowiplist │ │ └── allowiplist.go │ ├── complexity │ │ ├── complexity.go │ │ └── complexity_test.go │ ├── denylist │ │ └── denylist.go │ ├── loader │ │ ├── loader.go │ │ └── router.go │ ├── oauth2 │ │ ├── introspection.go │ │ ├── jwt.go │ │ └── oauth2.go │ ├── proxy │ │ ├── chainpool.go │ │ ├── chainpool_mock.go │ │ ├── dnscache.go │ │ ├── dnscache_mock.go │ │ ├── proxy.go │ │ ├── ws.go │ │ ├── wsClient.go │ │ ├── wsClient_mock.go │ │ └── ws_mock.go │ ├── router │ │ ├── LICENSE │ │ ├── chi.go │ │ ├── context.go │ │ ├── context_test.go │ │ ├── handler.go │ │ ├── mux.go │ │ ├── mux_test.go │ │ ├── tree.go │ │ └── tree_test.go │ ├── storage │ │ ├── dbv1.go │ │ ├── dbv2.go │ │ ├── file.go │ │ ├── storage.go │ │ ├── storage_loading_test.go │ │ ├── storage_mock.go │ │ ├── updater │ │ │ ├── updater.go │ │ │ └── updater_mock.go │ │ └── url.go │ ├── validator │ │ ├── api_mode_errors.go │ │ ├── api_mode_errors_test.go │ │ ├── api_mode_validate_request.go │ │ ├── graphql.go │ │ ├── internal.go │ │ ├── internal_test.go │ │ ├── issue1045_test.go │ │ ├── issue201_test.go │ │ ├── issue267_test.go │ │ ├── issue436_test.go │ │ ├── issue624_test.go │ │ ├── issue625_test.go │ │ ├── issue639_test.go │ │ ├── issue641_test.go │ │ ├── issue707_test.go │ │ ├── issue722_test.go │ │ ├── issue733_test.go │ │ ├── issue789_test.go │ │ ├── issue884_test.go │ │ ├── issue991_test.go │ │ ├── req_resp_decoder.go │ │ ├── req_resp_decoder_test.go │ │ ├── req_resp_encoder.go │ │ ├── req_resp_encoder_test.go │ │ ├── unknown_parameters_request.go │ │ ├── unknown_parameters_request_test.go │ │ ├── validate_request.go │ │ ├── validate_request_test.go │ │ ├── validate_response.go │ │ └── validate_response_test.go │ └── web │ │ ├── adaptor.go │ │ ├── errors.go │ │ ├── middleware.go │ │ ├── response.go │ │ ├── trace.go │ │ └── web.go └── version │ └── version.go ├── mkdocs.yml ├── pkg └── APIMode │ ├── apifw.go │ ├── apifw_test.go │ ├── handler.go │ ├── helpers.go │ ├── validator │ ├── errors.go │ ├── response.go │ ├── response_test.go │ └── validator.go │ ├── wallarm_apifw_empty.db │ ├── wallarm_apifw_invalid_db_schema.db │ ├── wallarm_apifw_invalid_spec.db │ ├── wallarm_apifw_spec_validation_failed.db │ └── wallarm_apifw_test.db ├── resources ├── __init__.py ├── coraza.conf-recommended ├── dev │ ├── httpbin.json │ ├── k6-test │ │ └── script.js │ └── wallarm_api.db └── test │ ├── allowed.iplist.db │ ├── database │ ├── wallarm_api.db │ └── wallarm_api_v2.db │ ├── docker-compose-api-mode.yml │ ├── gql │ └── schema.graphql │ ├── jwt │ └── pub.pem │ ├── modsec │ ├── coraza.conf │ └── rules_test │ │ └── test.conf │ ├── specification │ ├── openapi_for_tests.json │ └── script.js │ └── tokens │ └── test.db └── stylesheets-docs ├── .icons └── material │ └── chevron-right.svg ├── extra.css └── extra.js /.dockerignore: -------------------------------------------------------------------------------- 1 | resources/dev/wallarm_api.db 2 | resources/test/**/* 3 | -------------------------------------------------------------------------------- /.github/workflows/docker.yml: -------------------------------------------------------------------------------- 1 | name: Docker 2 | on: 3 | push: 4 | tags: 5 | - 'v[0-9]+.[0-9]+.[0-9]+*' 6 | 7 | jobs: 8 | docker: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - uses: actions/checkout@v2 12 | - 13 | name: Set up QEMU 14 | uses: docker/setup-qemu-action@v1 15 | - 16 | name: Set up Docker Buildx 17 | uses: docker/setup-buildx-action@v1 18 | - 19 | name: Login to DockerHub 20 | uses: docker/login-action@v1 21 | with: 22 | username: ${{ secrets.DOCKERHUB_USERNAME }} 23 | password: ${{ secrets.DOCKERHUB_TOKEN }} 24 | - 25 | name: Extract tag name 26 | run: echo "X_TAG=${GITHUB_REF#refs/*/v}" >> $GITHUB_ENV 27 | - 28 | name: Build and push 29 | uses: docker/build-push-action@v2 30 | with: 31 | context: . 32 | file: ./Dockerfile 33 | platforms: linux/386,linux/amd64,linux/arm/v6,linux/arm/v7,linux/arm64/v8 34 | push: true 35 | build-args: |- 36 | APIFIREWALL_VERSION=${{ env.X_TAG }} 37 | tags: | 38 | wallarm/api-firewall:latest 39 | wallarm/api-firewall:v${{ env.X_TAG }} 40 | -------------------------------------------------------------------------------- /.github/workflows/documentation.yml: -------------------------------------------------------------------------------- 1 | name: 'Publish Documentation' 2 | on: 3 | push: 4 | branches: 5 | - main 6 | paths: 7 | - 'docs/**' 8 | 9 | permissions: 10 | contents: write 11 | 12 | jobs: 13 | deploy_documentation: 14 | runs-on: ubuntu-latest 15 | steps: 16 | 17 | - uses: actions/checkout@v3 18 | - uses: actions/setup-python@v4 19 | with: 20 | python-version: 3.x 21 | - run: echo "cache_id=$(date --utc '+%V')" >> $GITHUB_ENV 22 | 23 | - uses: actions/cache@v3 24 | with: 25 | key: mkdocs-material-${{ env.cache_id }} 26 | path: .cache 27 | restore-keys: | 28 | mkdocs-material- 29 | - name: Install dependencies 30 | run: pip install --no-cache-dir -r docs/requirements.txt 31 | - run: mkdocs gh-deploy --force 32 | -------------------------------------------------------------------------------- /.github/workflows/helm.yml: -------------------------------------------------------------------------------- 1 | name: Helm 2 | on: 3 | push: 4 | tags: 5 | - 'v[0-9]+.[0-9]+.[0-9]+*' 6 | 7 | jobs: 8 | helm: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - uses: actions/checkout@v2 12 | - 13 | name: Extract tag name 14 | run: echo "X_TAG=${GITHUB_REF#refs/*/v}" >> $GITHUB_ENV 15 | - 16 | name: Publish Helm charts 17 | uses: stefanprodan/helm-gh-pages@master 18 | with: 19 | token: ${{ secrets.HELM_PUBLISH_TOKEN }} 20 | charts_dir: ./helm 21 | charts_url: https://charts.wallarm.com 22 | linting: off 23 | repository: helm-charts 24 | branch: main 25 | target_dir: api-firewall 26 | index_dir: . 27 | app_version: "${{ env.X_TAG }}" 28 | chart_version: "${{ env.X_TAG }}" 29 | -------------------------------------------------------------------------------- /.github/workflows/notify.yml: -------------------------------------------------------------------------------- 1 | # This is a basic workflow to help you get started with Actions 2 | 3 | name: 'Notify (DOCS)' 4 | 5 | on: 6 | push: 7 | branches: 8 | - main 9 | paths: 10 | - demo/docker-compose/README.md 11 | - demo/kubernetes/README.md 12 | - README.md 13 | 14 | workflow_dispatch: 15 | 16 | jobs: 17 | notify: 18 | name: 'Notify docs about api-firewall demo docs changes' 19 | runs-on: ubuntu-latest 20 | 21 | defaults: 22 | run: 23 | shell: bash 24 | 25 | steps: 26 | - name: GitHub API Call to notify product-docs-en 27 | env: 28 | FIREWALL_DOCS_TOKEN: ${{ secrets.FIREWALL_DOCS_TOKEN }} 29 | PARENT_REPO: wallarm/product-docs-en 30 | PARENT_BRANCH: master 31 | WORKFLOW_ID: 11686992 32 | run: |- 33 | curl \ 34 | -fL --retry 3 \ 35 | -X POST \ 36 | -H "Accept: application/vnd.github.v3+json" \ 37 | -H "Authorization: token ${{ env.FIREWALL_DOCS_TOKEN }}" \ 38 | https://api.github.com/repos/${{ env.PARENT_REPO }}/actions/workflows/${{ env.WORKFLOW_ID }}/dispatches \ 39 | -d '{"ref":"${{ env.PARENT_BRANCH }}"}' 40 | dockerHubDescription: 41 | runs-on: ubuntu-latest 42 | steps: 43 | - uses: actions/checkout@v2 44 | - name: Docker Hub Description 45 | uses: peter-evans/dockerhub-description@v2 46 | with: 47 | username: ${{ secrets.DOCKERHUB_USERNAME }} 48 | password: ${{ secrets.DOCKERHUB_TOKEN }} 49 | repository: wallarm/api-firewall 50 | short-description: ${{ github.event.repository.description }} 51 | -------------------------------------------------------------------------------- /.github/workflows/trivy.yml: -------------------------------------------------------------------------------- 1 | # This workflow uses actions that are not certified by GitHub. 2 | # They are provided by a third-party and are governed by 3 | # separate terms of service, privacy policy, and support 4 | # documentation. 5 | 6 | name: trivy 7 | 8 | on: 9 | pull_request: 10 | # The branches below must be a subset of the branches above 11 | branches: [ "main" ] 12 | 13 | permissions: 14 | contents: read 15 | 16 | jobs: 17 | build: 18 | permissions: 19 | contents: read # for actions/checkout to fetch code 20 | security-events: write # for github/codeql-action/upload-sarif to upload SARIF results 21 | actions: read # only required for a private repository by github/codeql-action/upload-sarif to get the Action run status 22 | name: Build 23 | runs-on: "ubuntu-22.04" 24 | steps: 25 | - name: Checkout code 26 | uses: actions/checkout@v4 27 | 28 | - name: Build an image from Dockerfile 29 | run: | 30 | docker build -t wallarm/api-firewall:${{ github.sha }} . 31 | 32 | - name: Run Trivy vulnerability scanner 33 | uses: aquasecurity/trivy-action@0.28.0 34 | with: 35 | image-ref: 'wallarm/api-firewall:${{ github.sha }}' 36 | format: 'sarif' 37 | output: 'trivy-results.sarif' 38 | 39 | - name: Upload Trivy scan results to GitHub Security tab 40 | uses: github/codeql-action/upload-sarif@v3 41 | with: 42 | sarif_file: 'trivy-results.sarif' 43 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Binaries for programs and plugins 2 | *.exe 3 | *.exe~ 4 | *.dll 5 | *.so 6 | *.dylib 7 | 8 | # Test binary, built with `go test -c` 9 | *.test 10 | 11 | # Output of the go coverage tool, specifically when used with LiteIDE 12 | *.out 13 | 14 | # Dependency directories (remove the comment below to include it) 15 | vendor/ 16 | .DS_Store 17 | .idea/ 18 | /dev/ 19 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM golang:1.23-alpine3.21 AS build 2 | 3 | ARG APIFIREWALL_NAMESPACE 4 | ARG APIFIREWALL_VERSION 5 | ENV APIFIREWALL_NAMESPACE=${APIFIREWALL_NAMESPACE} 6 | ENV APIFIREWALL_VERSION=${APIFIREWALL_VERSION} 7 | 8 | RUN apk add --no-cache \ 9 | gcc \ 10 | git \ 11 | make \ 12 | musl-dev 13 | 14 | WORKDIR /build 15 | COPY . . 16 | 17 | RUN go mod download -x && \ 18 | go build \ 19 | -ldflags="-X ${APIFIREWALL_NAMESPACE}/internal/version.Version=${APIFIREWALL_VERSION} -s -w" \ 20 | -buildvcs=false \ 21 | -o ./api-firewall \ 22 | ./cmd/api-firewall 23 | 24 | # Smoke test 25 | RUN ./api-firewall -v 26 | 27 | FROM alpine:3.21 AS composer 28 | 29 | WORKDIR /output 30 | 31 | COPY --from=build /build/api-firewall ./usr/local/bin/ 32 | COPY docker-entrypoint.sh ./usr/local/bin/docker-entrypoint.sh 33 | 34 | RUN chmod 755 ./usr/local/bin/* && \ 35 | chown root:root ./usr/local/bin/* 36 | 37 | FROM alpine:3.21 38 | 39 | RUN adduser -u 1000 -H -h /opt -D -s /bin/sh api-firewall 40 | 41 | COPY --from=composer /output / 42 | 43 | USER api-firewall 44 | ENTRYPOINT ["docker-entrypoint.sh"] 45 | CMD ["api-firewall"] 46 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | VERSION := 0.9.1 2 | NAMESPACE := github.com/wallarm/api-firewall 3 | 4 | .DEFAULT_GOAL := build 5 | 6 | build: 7 | docker build --no-cache --build-arg APIFIREWALL_NAMESPACE=$(NAMESPACE) --build-arg APIFIREWALL_VERSION=$(VERSION) --force-rm -t api-firewall . 8 | 9 | lint: 10 | golangci-lint -v run ./... 11 | 12 | tidy: 13 | go mod tidy 14 | go mod vendor 15 | 16 | test: 17 | go test ./... -count=1 -race -cover -run '^Test[^W]' 18 | go test ./cmd/api-firewall/tests/main_dns_test.go 19 | 20 | bench: 21 | GOMAXPROCS=1 go test -v -bench=. -benchtime=1000x -count 5 -benchmem -run BenchmarkWSGraphQL ./cmd/api-firewall/tests 22 | GOMAXPROCS=4 go test -v -bench=. -benchtime=1000x -count 5 -benchmem -run BenchmarkWSGraphQL ./cmd/api-firewall/tests 23 | 24 | genmocks: 25 | mockgen -source ./internal/platform/proxy/chainpool.go -destination ./internal/platform/proxy/chainpool_mock.go -package proxy 26 | mockgen -source ./internal/platform/proxy/dnscache.go -destination ./internal/platform/proxy/dnscache_mock.go -package proxy 27 | mockgen -source ./internal/platform/storage/storage.go -destination ./internal/platform/storage/storage_mock.go -package storage 28 | mockgen -source ./internal/platform/storage/updater/updater.go -destination ./internal/platform/storage/updater/updater_mock.go -package updater 29 | mockgen -source ./internal/platform/proxy/ws.go -destination ./internal/platform/proxy/ws_mock.go -package proxy 30 | mockgen -source ./internal/platform/proxy/wsClient.go -destination ./internal/platform/proxy/wsClient_mock.go -package proxy 31 | 32 | update: 33 | go get -u ./... 34 | 35 | fmt: 36 | gofmt -w ./ 37 | 38 | vulncheck: 39 | govulncheck ./... 40 | 41 | stop_k6_tests: 42 | @docker compose -f resources/test/docker-compose-api-mode.yml down 43 | 44 | run_k6_tests: stop_k6_tests 45 | @docker compose -f resources/test/docker-compose-api-mode.yml up --build --detach --force-recreate 46 | docker run --rm -i --network host grafana/k6 run --vus 100 --iterations 1200 -v - 0 { 74 | // add schema IDs to the validation error messages 75 | for _, r := range validationErrors { 76 | r.SchemaID = &s.SchemaID 77 | r.SchemaVersion = s.OpenAPIRouter.SchemaVersion 78 | } 79 | 80 | s.Log.Debug(). 81 | Interface("error", validationErrors). 82 | Interface("request_id", ctx.UserValue(web.RequestID)). 83 | Bytes("host", ctx.Request.Header.Host()). 84 | Bytes("path", ctx.Path()). 85 | Bytes("method", ctx.Request.Header.Method()). 86 | Msg("request validation error") 87 | 88 | ctx.SetUserValue(keyValidationErrors, validationErrors) 89 | ctx.SetUserValue(keyStatusCode, fasthttp.StatusForbidden) 90 | return nil 91 | } 92 | 93 | // request successfully validated 94 | ctx.SetUserValue(keyStatusCode, fasthttp.StatusOK) 95 | return nil 96 | } 97 | -------------------------------------------------------------------------------- /cmd/api-firewall/internal/handlers/api/health.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "os" 5 | 6 | "github.com/valyala/fasthttp" 7 | "github.com/wallarm/api-firewall/internal/platform/storage" 8 | "github.com/wallarm/api-firewall/internal/platform/web" 9 | "github.com/wallarm/api-firewall/internal/version" 10 | ) 11 | 12 | type Health struct { 13 | OpenAPIDB storage.DBOpenAPILoader 14 | } 15 | 16 | // Readiness checks if the Fasthttp connection pool is ready to handle new requests. 17 | func (h *Health) Readiness(ctx *fasthttp.RequestCtx) error { 18 | 19 | status := "ok" 20 | statusCode := fasthttp.StatusOK 21 | 22 | if !h.OpenAPIDB.IsReady() { 23 | status = "not ready" 24 | statusCode = fasthttp.StatusInternalServerError 25 | } 26 | 27 | data := struct { 28 | Status string `json:"status"` 29 | }{ 30 | Status: status, 31 | } 32 | 33 | return web.Respond(ctx, data, statusCode) 34 | } 35 | 36 | // Liveness returns simple status info if the service is alive. If the 37 | // app is deployed to a Kubernetes cluster, it will also return pod, node, and 38 | // namespace details via the Downward API. The Kubernetes environment variables 39 | // need to be set within your Pod/Deployment manifest. 40 | func (h *Health) Liveness(ctx *fasthttp.RequestCtx) error { 41 | host, err := os.Hostname() 42 | if err != nil { 43 | host = "unavailable" 44 | } 45 | 46 | data := struct { 47 | Status string `json:"status,omitempty"` 48 | Build string `json:"build,omitempty"` 49 | Host string `json:"host,omitempty"` 50 | Pod string `json:"pod,omitempty"` 51 | PodIP string `json:"podIP,omitempty"` 52 | Node string `json:"node,omitempty"` 53 | Namespace string `json:"namespace,omitempty"` 54 | }{ 55 | Status: "up", 56 | Build: version.Version, 57 | Host: host, 58 | Pod: os.Getenv("KUBERNETES_PODNAME"), 59 | PodIP: os.Getenv("KUBERNETES_NAMESPACE_POD_IP"), 60 | Node: os.Getenv("KUBERNETES_NODENAME"), 61 | Namespace: os.Getenv("KUBERNETES_NAMESPACE"), 62 | } 63 | 64 | statusCode := fasthttp.StatusOK 65 | return web.Respond(ctx, data, statusCode) 66 | } 67 | -------------------------------------------------------------------------------- /cmd/api-firewall/internal/handlers/proxy/health.go: -------------------------------------------------------------------------------- 1 | package proxy 2 | 3 | import ( 4 | "os" 5 | 6 | "github.com/rs/zerolog" 7 | "github.com/valyala/fasthttp" 8 | "github.com/wallarm/api-firewall/internal/platform/proxy" 9 | "github.com/wallarm/api-firewall/internal/platform/web" 10 | "github.com/wallarm/api-firewall/internal/version" 11 | ) 12 | 13 | type Health struct { 14 | Logger zerolog.Logger 15 | Pool proxy.Pool 16 | } 17 | 18 | // Readiness checks if the Fasthttp connection pool is ready to handle new requests. 19 | func (h *Health) Readiness(ctx *fasthttp.RequestCtx) error { 20 | 21 | status := "ok" 22 | statusCode := fasthttp.StatusOK 23 | 24 | reverseProxy, ip, err := h.Pool.Get() 25 | if err != nil { 26 | status = "not ready" 27 | statusCode = fasthttp.StatusInternalServerError 28 | } 29 | 30 | if reverseProxy != nil { 31 | if err := h.Pool.Put(ip, reverseProxy); err != nil { 32 | status = "not ready" 33 | statusCode = fasthttp.StatusInternalServerError 34 | } 35 | } 36 | 37 | data := struct { 38 | Status string `json:"status"` 39 | }{ 40 | Status: status, 41 | } 42 | 43 | return web.Respond(ctx, data, statusCode) 44 | } 45 | 46 | // Liveness returns simple status info if the service is alive. If the 47 | // app is deployed to a Kubernetes cluster, it will also return pod, node, and 48 | // namespace details via the Downward API. The Kubernetes environment variables 49 | // need to be set within your Pod/Deployment manifest. 50 | func (h *Health) Liveness(ctx *fasthttp.RequestCtx) error { 51 | host, err := os.Hostname() 52 | if err != nil { 53 | host = "unavailable" 54 | } 55 | 56 | data := struct { 57 | Status string `json:"status,omitempty"` 58 | Build string `json:"build,omitempty"` 59 | Host string `json:"host,omitempty"` 60 | Pod string `json:"pod,omitempty"` 61 | PodIP string `json:"podIP,omitempty"` 62 | Node string `json:"node,omitempty"` 63 | Namespace string `json:"namespace,omitempty"` 64 | }{ 65 | Status: "up", 66 | Build: version.Version, 67 | Host: host, 68 | Pod: os.Getenv("KUBERNETES_PODNAME"), 69 | PodIP: os.Getenv("KUBERNETES_NAMESPACE_POD_IP"), 70 | Node: os.Getenv("KUBERNETES_NODENAME"), 71 | Namespace: os.Getenv("KUBERNETES_NAMESPACE"), 72 | } 73 | 74 | statusCode := fasthttp.StatusOK 75 | return web.Respond(ctx, data, statusCode) 76 | } 77 | -------------------------------------------------------------------------------- /cmd/api-firewall/tests/main_api_mode_bench_test.go: -------------------------------------------------------------------------------- 1 | package tests 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "os" 7 | "os/signal" 8 | "sync" 9 | "syscall" 10 | "testing" 11 | 12 | "github.com/rs/zerolog" 13 | "github.com/valyala/fasthttp" 14 | 15 | handlersAPI "github.com/wallarm/api-firewall/cmd/api-firewall/internal/handlers/api" 16 | "github.com/wallarm/api-firewall/internal/platform/storage" 17 | "github.com/wallarm/api-firewall/internal/platform/web" 18 | ) 19 | 20 | const dbVersion = 1 21 | 22 | func BenchmarkAPIModeBasic(b *testing.B) { 23 | 24 | logger := zerolog.New(os.Stdout).With().Timestamp().Logger() 25 | logger = logger.Level(zerolog.ErrorLevel) 26 | 27 | var lock sync.RWMutex 28 | 29 | // load spec from the database 30 | specStorage, err := storage.NewOpenAPIDB("../../../resources/test/database/wallarm_api.db", dbVersion) 31 | if err != nil { 32 | b.Fatalf("trying to load API Spec value from SQLLite Database : %v\n", err.Error()) 33 | } 34 | 35 | shutdown := make(chan os.Signal, 1) 36 | signal.Notify(shutdown, os.Interrupt, syscall.SIGTERM) 37 | 38 | handler := handlersAPI.Handlers(&lock, &cfg, shutdown, logger, specStorage, nil, nil) 39 | 40 | p, err := json.Marshal(map[string]any{ 41 | "firstname": "test", 42 | "lastname": "test", 43 | "job": "test", 44 | "email": "test@wallarm.com", 45 | "url": "http://wallarm.com", 46 | }) 47 | 48 | if err != nil { 49 | b.Fatal(err) 50 | } 51 | 52 | // basic test 53 | b.Run("api_basic", func(b *testing.B) { 54 | for i := 0; i < b.N; i++ { 55 | req := fasthttp.AcquireRequest() 56 | req.SetRequestURI("/test/signup") 57 | req.Header.SetMethod("POST") 58 | req.SetBodyStream(bytes.NewReader(p), -1) 59 | req.Header.SetContentType("application/json") 60 | req.Header.Add(web.XWallarmSchemaIDHeader, "2") 61 | 62 | reqCtx := fasthttp.RequestCtx{ 63 | Request: *req, 64 | } 65 | handler(&reqCtx) 66 | if reqCtx.Response.StatusCode() != 200 { 67 | b.Errorf("Incorrect response status code. Expected: 200 and got %d", 68 | reqCtx.Response.StatusCode()) 69 | } 70 | } 71 | }) 72 | 73 | } 74 | -------------------------------------------------------------------------------- /cmd/api-firewall/tests/main_dns_test.go: -------------------------------------------------------------------------------- 1 | package tests 2 | 3 | import ( 4 | "context" 5 | "net" 6 | "os" 7 | "testing" 8 | "time" 9 | 10 | "github.com/foxcpp/go-mockdns" 11 | "github.com/rs/zerolog" 12 | 13 | "github.com/wallarm/api-firewall/internal/config" 14 | "github.com/wallarm/api-firewall/internal/platform/proxy" 15 | ) 16 | 17 | func TestWithoutRCCDNSCacheBasic(t *testing.T) { 18 | 19 | logger := zerolog.New(os.Stdout).With().Timestamp().Logger() 20 | logger = logger.Level(zerolog.ErrorLevel) 21 | 22 | var cfg = config.ProxyMode{ 23 | RequestValidation: "BLOCK", 24 | ResponseValidation: "BLOCK", 25 | CustomBlockStatusCode: 403, 26 | AddValidationStatusHeader: false, 27 | ShadowAPI: config.ShadowAPI{ 28 | ExcludeList: []int{404, 401}, 29 | }, 30 | DNS: config.DNS{ 31 | Cache: true, 32 | FetchTimeout: 1000 * time.Millisecond, 33 | LookupTimeout: 400 * time.Millisecond, 34 | }, 35 | } 36 | 37 | srv, _ := mockdns.NewServer(map[string]mockdns.Zone{ 38 | "example.org.": { 39 | A: []string{"1.2.3.4", "5.6.7.8"}, 40 | }, 41 | }, false) 42 | defer srv.Close() 43 | 44 | srUpdatedOrder, _ := mockdns.NewServer(map[string]mockdns.Zone{ 45 | "example.org.": { 46 | A: []string{"5.6.7.8", "1.2.3.4"}, 47 | }, 48 | }, false) 49 | defer srUpdatedOrder.Close() 50 | 51 | r := &net.Resolver{} 52 | srv.PatchNet(r) 53 | 54 | dnsResolverOptions := proxy.DNSCacheOptions{ 55 | UseCache: true, 56 | Logger: logger, 57 | FetchTimeout: cfg.DNS.FetchTimeout, 58 | LookupTimeout: cfg.DNS.LookupTimeout, 59 | } 60 | 61 | dnsCache, err := proxy.NewDNSResolver(r, &dnsResolverOptions) 62 | if err != nil { 63 | t.Fatal(err) 64 | } 65 | defer dnsCache.Stop() 66 | 67 | addr, err := dnsCache.LookupIPAddr(context.Background(), "example.org") 68 | if err != nil { 69 | t.Error(err) 70 | } 71 | 72 | if addr[0].String() != "1.2.3.4" { 73 | t.Errorf("Incorrect response from local DNS server. Expected: 1.2.3.4 and got %s", 74 | addr[0].String()) 75 | } 76 | 77 | srUpdatedOrder.PatchNet(r) 78 | 79 | time.Sleep(600 * time.Millisecond) 80 | 81 | addr, err = dnsCache.LookupIPAddr(context.Background(), "example.org") 82 | if err != nil { 83 | t.Error(err) 84 | } 85 | 86 | if addr[0].String() != "1.2.3.4" { 87 | t.Errorf("Incorrect response from local DNS server. Expected: 1.2.3.4 and got %s", 88 | addr[0].String()) 89 | } 90 | 91 | time.Sleep(800 * time.Millisecond) 92 | 93 | addr, err = dnsCache.LookupIPAddr(context.Background(), "example.org") 94 | if err != nil { 95 | t.Error(err) 96 | } 97 | 98 | if addr[0].String() != "5.6.7.8" { 99 | t.Errorf("Incorrect response from local DNS server. Expected: 5.6.7.8 and got %s", 100 | addr[0].String()) 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /cmd/api-firewall/tests/wallarm_api2_empty.db: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wallarm/api-firewall/c0ce5660a109c4d921d07c9fd40d610962b2a7ee/cmd/api-firewall/tests/wallarm_api2_empty.db -------------------------------------------------------------------------------- /cmd/api-firewall/tests/wallarm_api2_update.db: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wallarm/api-firewall/c0ce5660a109c4d921d07c9fd40d610962b2a7ee/cmd/api-firewall/tests/wallarm_api2_update.db -------------------------------------------------------------------------------- /cmd/api-firewall/tests/wallarm_api_after_update.db: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wallarm/api-firewall/c0ce5660a109c4d921d07c9fd40d610962b2a7ee/cmd/api-firewall/tests/wallarm_api_after_update.db -------------------------------------------------------------------------------- /cmd/api-firewall/tests/wallarm_api_before_update.db: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wallarm/api-firewall/c0ce5660a109c4d921d07c9fd40d610962b2a7ee/cmd/api-firewall/tests/wallarm_api_before_update.db -------------------------------------------------------------------------------- /cmd/api-firewall/tests/wallarm_api_empty.db: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wallarm/api-firewall/c0ce5660a109c4d921d07c9fd40d610962b2a7ee/cmd/api-firewall/tests/wallarm_api_empty.db -------------------------------------------------------------------------------- /cmd/api-firewall/tests/wallarm_api_invalid_file.db: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wallarm/api-firewall/c0ce5660a109c4d921d07c9fd40d610962b2a7ee/cmd/api-firewall/tests/wallarm_api_invalid_file.db -------------------------------------------------------------------------------- /cmd/api-firewall/tests/wallarm_api_invalid_schema.db: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wallarm/api-firewall/c0ce5660a109c4d921d07c9fd40d610962b2a7ee/cmd/api-firewall/tests/wallarm_api_invalid_schema.db -------------------------------------------------------------------------------- /demo/docker-compose/.gitignore: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wallarm/api-firewall/c0ce5660a109c4d921d07c9fd40d610962b2a7ee/demo/docker-compose/.gitignore -------------------------------------------------------------------------------- /demo/docker-compose/Makefile: -------------------------------------------------------------------------------- 1 | # https://makefiletutorial.com/ 2 | SHELL=/bin/bash 3 | 4 | up: 5 | @docker-compose up -d --force-recreate 6 | start: up 7 | run: up 8 | 9 | down: 10 | @docker-compose down 11 | stop: down 12 | 13 | log: logs 14 | logs: follow 15 | follow: 16 | @docker-compose logs -f 17 | 18 | .PHONY: up start run down stop follow 19 | -------------------------------------------------------------------------------- /demo/docker-compose/OWASP_CoreRuleSet/Makefile: -------------------------------------------------------------------------------- 1 | 2 | CRS_URL := https://github.com/coreruleset/coreruleset/archive/refs/tags/v4.2.0.tar.gz 3 | CRS_FILE := v4.1.0.tar.gz 4 | CRS_DIR := ./crs 5 | 6 | prepare: 7 | @test -f $(CRS_FILE) || wget $(CRS_URL) -O $(CRS_FILE) 8 | @if [ ! -d "$(CRS_DIR)" ]; then \ 9 | mkdir $(CRS_DIR); \ 10 | tar -xzvf $(CRS_FILE) --strip-components 1 -C $(CRS_DIR); \ 11 | fi 12 | 13 | clean: 14 | rm -rf $(CRS_DIR) 15 | rm $(CRS_FILE) 16 | rm: clean 17 | 18 | up: prepare 19 | docker-compose up -d --force-recreate 20 | docker logs -f api-firewall 21 | start: up 22 | run: up 23 | 24 | down: 25 | docker-compose down 26 | stop: down 27 | 28 | .PHONY: prepare clean up start run down stop rm -------------------------------------------------------------------------------- /demo/docker-compose/OWASP_CoreRuleSet/docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "3.8" 2 | services: 3 | api-firewall: 4 | container_name: api-firewall 5 | image: wallarm/api-firewall:v0.9.1 6 | restart: on-failure 7 | environment: 8 | APIFW_URL: "http://0.0.0.0:8080" 9 | APIFW_API_SPECS: "/opt/resources/httpbin.json" 10 | APIFW_SERVER_URL: "http://backend:80" 11 | APIFW_SERVER_MAX_CONNS_PER_HOST: "512" 12 | APIFW_SERVER_READ_TIMEOUT: "5s" 13 | APIFW_SERVER_WRITE_TIMEOUT: "5s" 14 | APIFW_SERVER_DIAL_TIMEOUT: "200ms" 15 | APIFW_REQUEST_VALIDATION: "BLOCK" 16 | APIFW_RESPONSE_VALIDATION: "BLOCK" 17 | APIFW_SHADOW_API_UNKNOWN_PARAMETERS_DETECTION: "false" 18 | APIFW_MODSEC_CONF_FILES: "/opt/resources/coraza.conf;/opt/resources/crs/crs-setup.conf.example" 19 | APIFW_MODSEC_RULES_DIR: "/opt/resources/crs/rules/" 20 | volumes: 21 | - ./crs:/opt/resources/crs:ro 22 | - ./coraza.conf:/opt/resources/coraza.conf:ro 23 | - ./httpbin.json:/opt/resources/httpbin.json:ro 24 | ports: 25 | - "8080:8080" 26 | stop_grace_period: 1s 27 | backend: 28 | container_name: api-firewall-backend 29 | image: kennethreitz/httpbin 30 | restart: on-failure 31 | ports: 32 | - 8090:80 33 | expose: 34 | - 80 35 | stop_grace_period: 1s 36 | -------------------------------------------------------------------------------- /demo/docker-compose/damn-vulnerable-graphql-app-demo/docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3.8' 2 | services: 3 | graphql-api: 4 | container_name: graphql-api 5 | image: dolevf/dvga:latest 6 | restart: on-failure 7 | environment: 8 | WEB_HOST: 0.0.0.0 9 | ports: 10 | - "5013:5013" 11 | api-firewall: 12 | container_name: api-firewall 13 | image: api-firewall:latest 14 | restart: on-failure 15 | tty: true 16 | environment: 17 | # Mode and schema 18 | APIFW_MODE: "graphql" 19 | APIFW_GRAPHQL_SCHEMA: "/opt/resources/schema.graphql" 20 | 21 | APIFW_GRAPHQL_PLAYGROUND: "false" 22 | 23 | # API Security features 24 | APIFW_GRAPHQL_INTROSPECTION: false 25 | APIFW_GRAPHQL_MAX_QUERY_DEPTH: 5 26 | APIFW_GRAPHQL_BATCH_QUERY_LIMIT: 1 27 | APIFW_GRAPHQL_MAX_ALIASES_NUM: 1 28 | APIFW_GRAPHQL_DISABLE_FIELD_DUPLICATION: true 29 | APIFW_GRAPHQL_MAX_QUERY_COMPLEXITY: 0 30 | APIFW_GRAPHQL_NODE_COUNT_LIMIT: 0 31 | 32 | # Enforcement mode 33 | # Possible values: DISABLE LOG_ONLY BLOCK 34 | APIFW_GRAPHQL_REQUEST_VALIDATION: "block" 35 | 36 | # Log level, listentnig host, port and other parameters 37 | APIFW_SERVER_URL: "http://graphql-api:5013/graphql" 38 | APIFW_URL: "http://0.0.0.0:8080/graphql" 39 | APIFW_HEALTH_HOST: "0.0.0.0:9667" 40 | APIFW_LOG_LEVEL: "debug" 41 | volumes: 42 | - ./schema.graphql:/opt/resources/schema.graphql:ro 43 | ports: 44 | - "8080:8080" 45 | - "9667:9667" 46 | stop_grace_period: 1s 47 | 48 | 49 | -------------------------------------------------------------------------------- /demo/docker-compose/damn-vulnerable-graphql-app-demo/schema.graphql: -------------------------------------------------------------------------------- 1 | schema { 2 | query: Query 3 | mutation: Mutations 4 | subscription: Subscription 5 | } 6 | 7 | """Displays the network associated with an IP Address (CIDR or Net).""" 8 | directive @show_network(style: String!) on FIELD | FRAGMENT_SPREAD | INLINE_FRAGMENT 9 | 10 | type AuditObject { 11 | id: ID! 12 | gqloperation: String 13 | gqlquery: String 14 | timestamp: DateTime 15 | } 16 | 17 | type CreatePaste { 18 | paste: PasteObject 19 | } 20 | 21 | type CreateUser { 22 | user: UserObject 23 | } 24 | 25 | """ 26 | The `DateTime` scalar type represents a DateTime 27 | value as specified by 28 | [iso8601](https://en.wikipedia.org/wiki/ISO_8601). 29 | """ 30 | scalar DateTime 31 | 32 | type DeletePaste { 33 | result: Boolean 34 | } 35 | 36 | type EditPaste { 37 | paste: PasteObject 38 | } 39 | 40 | type ImportPaste { 41 | result: String 42 | } 43 | 44 | type Login { 45 | accessToken: String 46 | refreshToken: String 47 | } 48 | 49 | type Mutations { 50 | createPaste(burn: Boolean = false, content: String, public: Boolean = true, title: String): CreatePaste 51 | editPaste(content: String, id: Int, title: String): EditPaste 52 | deletePaste(id: Int): DeletePaste 53 | uploadPaste(content: String!, filename: String!): UploadPaste 54 | importPaste(host: String!, path: String!, port: Int, scheme: String!): ImportPaste 55 | createUser(userData: UserInput!): CreateUser 56 | login(password: String, username: String): Login 57 | } 58 | 59 | type OwnerObject { 60 | id: ID! 61 | name: String 62 | paste: [PasteObject] 63 | pastes: [PasteObject] 64 | } 65 | 66 | type PasteObject { 67 | id: ID! 68 | title: String 69 | content: String 70 | public: Boolean 71 | userAgent: String 72 | ipAddr: String 73 | ownerId: Int 74 | burn: Boolean 75 | owner: OwnerObject 76 | } 77 | 78 | type Query { 79 | pastes(public: Boolean, limit: Int, filter: String): [PasteObject] 80 | paste(id: Int, title: String): PasteObject 81 | systemUpdate: String 82 | systemDiagnostics(username: String, password: String, cmd: String): String 83 | systemDebug(arg: String): String 84 | systemHealth: String 85 | users(id: Int): [UserObject] 86 | readAndBurn(id: Int): PasteObject 87 | search(keyword: String): [SearchResult] 88 | audits: [AuditObject] 89 | deleteAllPastes: Boolean 90 | me(token: String): UserObject 91 | } 92 | 93 | union SearchResult = PasteObject | UserObject 94 | 95 | type Subscription { 96 | paste(id: Int, title: String): PasteObject 97 | } 98 | 99 | type UploadPaste { 100 | content: String 101 | filename: String 102 | result: String 103 | } 104 | 105 | input UserInput { 106 | username: String! 107 | email: String! 108 | password: String! 109 | } 110 | 111 | type UserObject { 112 | id: ID! 113 | username(capitalize: Boolean): String 114 | password: String! 115 | } 116 | 117 | -------------------------------------------------------------------------------- /demo/docker-compose/docker-compose-api-mode.yml: -------------------------------------------------------------------------------- 1 | version: '3.8' 2 | services: 3 | api-firewall: 4 | container_name: api-firewall 5 | image: wallarm/api-firewall:v0.9.1 6 | restart: on-failure 7 | environment: 8 | APIFW_MODE: "api" 9 | APIFW_SPECIFICATION_UPDATE_PERIOD: "1m" 10 | APIFW_API_MODE_UNKNOWN_PARAMETERS_DETECTION: "true" 11 | APIFW_PASS_OPTIONS: "false" 12 | APIFW_URL: "http://0.0.0.0:8080" 13 | APIFW_HEALTH_HOST: "0.0.0.0:9667" 14 | APIFW_READ_TIMEOUT: "5s" 15 | APIFW_WRITE_TIMEOUT: "5s" 16 | APIFW_LOG_LEVEL: "info" 17 | volumes: 18 | - ./volumes/wallarm_api.db:/var/lib/wallarm-api/1/wallarm_api.db:ro 19 | ports: 20 | - "8080:8080" 21 | - "9667:9667" 22 | stop_grace_period: 1s -------------------------------------------------------------------------------- /demo/docker-compose/docker-compose-graphql-mode.yml: -------------------------------------------------------------------------------- 1 | version: '3.8' 2 | services: 3 | api-firewall: 4 | container_name: api-firewall 5 | image: wallarm/api-firewall:v0.9.1 6 | restart: on-failure 7 | environment: 8 | APIFW_MODE: "graphql" 9 | APIFW_GRAPHQL_INTROSPECTION: "false" 10 | APIFW_GRAPHQL_MAX_QUERY_DEPTH: "3" 11 | APIFW_GRAPHQL_MAX_ALIASES_NUM: "1" 12 | APIFW_GRAPHQL_BATCH_QUERY_LIMIT: "1" 13 | APIFW_GRAPHQL_NODE_COUNT_LIMIT: "0" 14 | APIFW_GRAPHQL_MAX_QUERY_COMPLEXITY: "0" 15 | APIFW_GRAPHQL_PLAYGROUND: "false" 16 | APIFW_GRAPHQL_REQUEST_VALIDATION: "BLOCK" 17 | # https://github.com/graphql/swapi-graphql 18 | APIFW_GRAPHQL_SCHEMA: "/opt/resources/schema.graphql" 19 | APIFW_SERVER_URL: "https://swapi-graphql.netlify.app/.netlify/functions/index" 20 | APIFW_URL: "http://0.0.0.0:8080" 21 | APIFW_HEALTH_HOST: "0.0.0.0:9667" 22 | APIFW_READ_TIMEOUT: "5s" 23 | APIFW_WRITE_TIMEOUT: "5s" 24 | APIFW_LOG_LEVEL: "info" 25 | volumes: 26 | - ./volumes/api-firewall/schema.graphql:/opt/resources/schema.graphql:ro 27 | ports: 28 | - "8080:8080" 29 | - "9667:9667" 30 | stop_grace_period: 1s -------------------------------------------------------------------------------- /demo/docker-compose/docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "3.8" 2 | services: 3 | api-firewall: 4 | container_name: api-firewall 5 | image: wallarm/api-firewall:v0.9.1 6 | restart: on-failure 7 | environment: 8 | APIFW_URL: "http://0.0.0.0:8080" 9 | APIFW_API_SPECS: "/opt/resources/httpbin.json" 10 | # APIFW_API_SPECS: "/opt/resources/httpbin-with-constraints.json" 11 | APIFW_SERVER_URL: "http://backend:80" 12 | APIFW_SERVER_MAX_CONNS_PER_HOST: "512" 13 | APIFW_SERVER_READ_TIMEOUT: "5s" 14 | APIFW_SERVER_WRITE_TIMEOUT: "5s" 15 | APIFW_SERVER_DIAL_TIMEOUT: "200ms" 16 | APIFW_REQUEST_VALIDATION: "BLOCK" 17 | APIFW_RESPONSE_VALIDATION: "BLOCK" 18 | # Denylist: Token 19 | APIFW_DENYLIST_TOKENS_FILE: "/opt/resources/tokens.denylist.db" 20 | APIFW_DENYLIST_TOKENS_COOKIE_NAME: "test" 21 | APIFW_DENYLIST_TOKENS_HEADER_NAME: "" 22 | APIFW_DENYLIST_TOKENS_TRIM_BEARER_PREFIX: "true" 23 | 24 | # APIFW_ALLOW_IP_FILE: '/opt/resources/allowed.iplist.db' 25 | # APIFW_ALLOW_IP_HEADER_NAME: 'X-Forwarded-For' 26 | 27 | volumes: 28 | - ./volumes/api-firewall:/opt/resources:ro 29 | ports: 30 | - "8080:8080" 31 | stop_grace_period: 1s 32 | backend: 33 | container_name: api-firewall-backend 34 | image: kennethreitz/httpbin 35 | restart: on-failure 36 | ports: 37 | - 8090:80 38 | expose: 39 | - 80 40 | stop_grace_period: 1s 41 | -------------------------------------------------------------------------------- /demo/docker-compose/volumes/api-firewall/allowed.iplist.db: -------------------------------------------------------------------------------- 1 | 127.0.0.1 2 | -------------------------------------------------------------------------------- /demo/docker-compose/volumes/api-firewall/httpbin-with-constraints.json: -------------------------------------------------------------------------------- 1 | { 2 | "openapi": "3.0.1", 3 | "info": { 4 | "title": "httpbin.org", 5 | "description": "A simple HTTP Request & Response Service.

Run locally: $ docker run -p 80:80 kennethreitz/httpbin", 6 | "contact": { 7 | "url": "https://kennethreitz.org", 8 | "email": "me@kennethreitz.org" 9 | }, 10 | "version": "0.9.2" 11 | }, 12 | "servers": [ 13 | { 14 | "url": "https://httpbin.org/" 15 | } 16 | ], 17 | "tags": [ 18 | { 19 | "name": "HTTP Methods", 20 | "description": "Testing different HTTP verbs" 21 | }, 22 | { 23 | "name": "Auth", 24 | "description": "Auth methods" 25 | }, 26 | { 27 | "name": "Status codes", 28 | "description": "Generates responses with given status code" 29 | }, 30 | { 31 | "name": "Request inspection", 32 | "description": "Inspect the request data" 33 | }, 34 | { 35 | "name": "Response inspection", 36 | "description": "Inspect the response data like caching and headers" 37 | }, 38 | { 39 | "name": "Response formats", 40 | "description": "Returns responses in different data formats" 41 | }, 42 | { 43 | "name": "Dynamic data", 44 | "description": "Generates random and dynamic data" 45 | }, 46 | { 47 | "name": "Cookies", 48 | "description": "Creates, reads and deletes Cookies" 49 | }, 50 | { 51 | "name": "Images", 52 | "description": "Returns different image formats" 53 | }, 54 | { 55 | "name": "Redirects", 56 | "description": "Returns different redirect responses" 57 | }, 58 | { 59 | "name": "Anything", 60 | "description": "Returns anything that is passed to request" 61 | } 62 | ], 63 | "paths": { 64 | "/get": { 65 | "get": { 66 | "tags": [ 67 | "HTTP Methods" 68 | ], 69 | "summary": "The request's query parameters.", 70 | "responses": { 71 | "200": { 72 | "description": "The request's query parameters.", 73 | "content": {} 74 | } 75 | }, 76 | "parameters": [ 77 | { 78 | "in": "query", 79 | "name": "int", 80 | "schema": { 81 | "type": "integer", 82 | "minimum": 10, 83 | "maximum": 100 84 | }, 85 | "required": true 86 | }, 87 | { 88 | "in": "query", 89 | "name": "str", 90 | "schema": { 91 | "type": "string", 92 | "pattern": "^.{1,10}-\\d{1,10}$" 93 | } 94 | } 95 | ] 96 | } 97 | } 98 | }, 99 | "components": {} 100 | } -------------------------------------------------------------------------------- /demo/docker-compose/volumes/api-firewall/tokens.denylist.db: -------------------------------------------------------------------------------- 1 | eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJzb21lIjoicGF5bG9hZDk5OTk5ODIifQ.CUq8iJ_LUzQMfDTvArpz6jUyK0Qyn7jZ9WCqE0xKTCA 2 | eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJzb21lIjoicGF5bG9hZDk5OTk5ODMifQ.BinZ4AcJp_SQz-iFfgKOKPz_jWjEgiVTb9cS8PP4BI0 3 | eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJzb21lIjoicGF5bG9hZDk5OTk5ODQifQ.j5Iea7KGm7GqjMGBuEZc2akTIoByUaQc5SSX7w_qjY8 4 | eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJzb21lIjoicGF5bG9hZDk5OTk5ODUifQ.S9P-DEiWg7dlI81rLjnJWCA6h9Q4ewTizxrsxOPGmNA 5 | eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJzb21lIjoicGF5bG9hZDk5OTk5ODYifQ.HdINfOmk59NdNYBnMjrqUdD4gEikAUafKjAhBI1_Ue8 6 | eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJzb21lIjoicGF5bG9hZDk5OTk5ODcifQ.MDPMmuAquxi55sGTajKQjcFzoaNzFZJFMkDg3fIyhx0 7 | eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJzb21lIjoicGF5bG9hZDk5OTk5ODgifQ.-HfLUDIIHawNbZJkAbml_Um8vlQw7UMeiYmzdRbbwHs 8 | eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJzb21lIjoicGF5bG9hZDk5OTk5ODkifQ.zyFgVDFYCKyp10GKbC8HCUpeT0rRajqG192gb-s7L8U 9 | eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJzb21lIjoicGF5bG9hZDk5OTk5OTAifQ.6b3J4xCO6U88k0ZmLPUeDpopsg6krl6Q7UyuLbcH-l8 10 | eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJzb21lIjoicGF5bG9hZDk5OTk5OTEifQ.NdeEQYz4vE56uJrniAHCDO2NeMJmlrUH5F_a5bQJOo4 -------------------------------------------------------------------------------- /demo/docker-compose/volumes/wallarm_api.db: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wallarm/api-firewall/c0ce5660a109c4d921d07c9fd40d610962b2a7ee/demo/docker-compose/volumes/wallarm_api.db -------------------------------------------------------------------------------- /demo/interface/api-mode/internal/updater/updater.go: -------------------------------------------------------------------------------- 1 | package updater 2 | 3 | import ( 4 | "time" 5 | 6 | "github.com/rs/zerolog" 7 | 8 | "github.com/wallarm/api-firewall/pkg/APIMode" 9 | ) 10 | 11 | const ( 12 | logPrefix = "Regular OpenAPI specification updater" 13 | ) 14 | 15 | type DatabaseUpdater interface { 16 | Start() error 17 | Shutdown() error 18 | } 19 | 20 | type Specification struct { 21 | logger zerolog.Logger 22 | stop chan struct{} 23 | updateTime time.Duration 24 | apifw APIMode.APIFirewall 25 | } 26 | 27 | // NewUpdater function defines configuration updater controller 28 | func NewUpdater(logger zerolog.Logger, apifw APIMode.APIFirewall, updateTime time.Duration) DatabaseUpdater { 29 | return &Specification{ 30 | logger: logger, 31 | stop: make(chan struct{}), 32 | updateTime: updateTime, 33 | apifw: apifw, 34 | } 35 | } 36 | 37 | // Run function performs update of the specification 38 | func (s *Specification) Run() { 39 | 40 | updateTicker := time.NewTicker(s.updateTime) 41 | for { 42 | select { 43 | case <-updateTicker.C: 44 | 45 | currentSIDs, isUpdated, err := s.apifw.UpdateSpecsStorage() 46 | if err != nil { 47 | s.logger.Error().Msgf("%s: error while OpenAPI specifications update: %v", logPrefix, err) 48 | } 49 | 50 | if isUpdated { 51 | s.logger.Debug().Msgf("%s: OpenAPI specifications were updated: current schema IDs list %v", logPrefix, currentSIDs) 52 | } 53 | 54 | case <-s.stop: 55 | updateTicker.Stop() 56 | return 57 | } 58 | } 59 | } 60 | 61 | // Start function starts update process every ConfigurationUpdatePeriod 62 | func (s *Specification) Start() error { 63 | go s.Run() 64 | 65 | <-s.stop 66 | return nil 67 | } 68 | 69 | // Shutdown function stops update process 70 | func (s *Specification) Shutdown() error { 71 | defer s.logger.Info().Msgf("%s: stopped", logPrefix) 72 | 73 | // close worker and finish Start function 74 | for i := 0; i < 2; i++ { 75 | s.stop <- struct{}{} 76 | } 77 | 78 | return nil 79 | } 80 | -------------------------------------------------------------------------------- /demo/interface/api-mode/wallarm_apifw_test.db: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wallarm/api-firewall/c0ce5660a109c4d921d07c9fd40d610962b2a7ee/demo/interface/api-mode/wallarm_apifw_test.db -------------------------------------------------------------------------------- /demo/kubernetes/.env: -------------------------------------------------------------------------------- 1 | KIND_IMAGE=local/kind:local 2 | 3 | ### Docker-in-Docker version for building image 4 | ### See me here: https://hub.docker.com/_/docker?tab=tags&ordering=last_updated 5 | DOCKER_VERSION=19.03 6 | 7 | ### Kubernetes-in-Docker-version 8 | ### See me here: 9 | ### https://github.com/kubernetes-sigs/kind/tags 10 | ### https://hub.docker.com/r/kindest/node/tags?ordering=last_updated 11 | # 12 | KIND_VERSION=0.11.0 13 | KIND_NODE_VERSION=1.19.11 14 | 15 | ### Kubernetes version 16 | ### See me here: https://github.com/kubernetes/kubernetes/tags 17 | # 18 | KUBERNETES_VERSION=1.19.11 19 | 20 | # Other shared entries 21 | LOCAL_DNS=10.254.254.254 22 | -------------------------------------------------------------------------------- /demo/kubernetes/Makefile: -------------------------------------------------------------------------------- 1 | # https://makefiletutorial.com/ 2 | SHELL=/bin/bash 3 | 4 | include .env 5 | 6 | build: 7 | @docker-compose build --parallel --compress 8 | 9 | rebuild: 10 | @docker-compose build --parallel --compress --no-cache 11 | 12 | start: 13 | @docker-compose up --no-start 14 | @docker-compose start kubernetes 15 | @docker-compose start dns 16 | @sleep 2 # wait docker for ready 17 | @docker-compose exec -w /root kubernetes bash -c 'cat $${CLUSTER_MANIFEST_TEMPLATE} | envsubst > $${KIND_CLUSTER_MANIFEST}' 18 | @docker-compose exec -w /root kubernetes routines.py create 19 | @docker-compose start 20 | @make backend $(MAKEFLAGS) 21 | @make chart $(MAKEFLAGS) 22 | 23 | stop: 24 | @docker-compose down 25 | 26 | exec bash shell: 27 | @docker-compose exec -w /root kubernetes bash 28 | 29 | backend: 30 | @docker-compose exec -w /root kubernetes bash -c "set +x; cd ./init-backend; ls -1 | egrep '.(yml|yaml)$$' | sort | xargs -L1 kubectl create -f || true" 31 | 32 | chart: 33 | @docker-compose exec -w /root kubernetes bash -c "set +x; helm install api-firewall ./chart/. -f ./helm/api-firewall.yaml" 34 | -------------------------------------------------------------------------------- /demo/kubernetes/docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3.6' 2 | 3 | x-env-kubernetes-version: &ref_env_kubernetes_version ${KUBERNETES_VERSION} 4 | x-env-kind-version: &ref_env_kind_version ${KIND_VERSION} 5 | x-env-docker-version: &ref_env_docker_version ${DOCKER_VERSION} 6 | x-env-kind-image: &ref_env_kind_image ${KIND_IMAGE} 7 | x-env-local-dns: &ref_env_local_dns ${LOCAL_DNS} 8 | 9 | services: 10 | dns: # makes env compatible with macos/windows, not linux only 11 | container_name: api-firewall-kubernetes-dns 12 | image: coredns/coredns:1.8.3 13 | command: 14 | - -conf 15 | - /etc/Coredns 16 | expose: 17 | - "53/udp" 18 | networks: 19 | default: 20 | ipv4_address: *ref_env_local_dns 21 | volumes: 22 | - "./volumes/dns/Corefile:/etc/Coredns:ro" 23 | restart: on-failure 24 | stop_grace_period: 1s 25 | 26 | kubernetes: 27 | container_name: api-firewall-kubernetes-kind 28 | image: *ref_env_kind_image 29 | build: 30 | context: ./docker 31 | dockerfile: Dockerfile 32 | labels: 33 | tld.local: local 34 | network: host 35 | args: 36 | DOCKER_VERSION: *ref_env_docker_version 37 | KUBERNETES_VERSION: *ref_env_kubernetes_version 38 | KIND_VERSION: *ref_env_kind_version 39 | privileged: true 40 | environment: 41 | KIND_DNS_SERVER_IP: *ref_env_local_dns 42 | CLUSTER_MANIFEST_TEMPLATE: /root/cluster.yaml.tmpl 43 | KIND_CLUSTER_MANIFEST: /root/cluster.yaml 44 | env_file: 45 | - ".env" 46 | entrypoint: 47 | - sh 48 | - -c 49 | command: 50 | - | 51 | echo nameserver ${LOCAL_DNS} > /etc/resolv.conf 52 | exec dockerd-entrypoint.sh 53 | ports: 54 | - "6443:6443" # kubernetes api 55 | - "8008:30080" # kubernetes dashboard 56 | - "8080:31080" # backend via firewall 57 | - "8090:31090" # backend 58 | dns: 59 | - *ref_env_local_dns 60 | extra_hosts: 61 | - "kubernetes:127.0.0.1" 62 | volumes: 63 | - ./volumes/cluster.yaml:/root/cluster.yaml.tmpl:ro # kubernetes cluster configuration 64 | - ./volumes/init-backend:/root/init-backend:ro # kubernetes initial configuration (manifests) 65 | - ./volumes/helm:/root/helm:ro # helm values for api-firewall 66 | - ../../helm/api-firewall:/root/chart:ro # helm chart for api-firewall 67 | - ./volumes/docker/daemon.json:/etc/docker/daemon.json:ro # docker daemon config 68 | stop_grace_period: 5s 69 | depends_on: 70 | - dns 71 | 72 | networks: 73 | default: 74 | name: api-firewall-kubernetes 75 | ipam: 76 | config: 77 | - subnet: 10.254.254.0/24 78 | -------------------------------------------------------------------------------- /demo/kubernetes/docker/Dockerfile: -------------------------------------------------------------------------------- 1 | ARG DOCKER_VERSION 2 | ARG KIND_VERSION 3 | ARG KUBERNETES_VERSION 4 | 5 | FROM docker:${DOCKER_VERSION}-dind as builder 6 | 7 | ARG KIND_VERSION 8 | 9 | WORKDIR /output 10 | COPY ./manifests ./manifests 11 | COPY ./scripts ./usr/local/bin 12 | 13 | RUN chmod +x ./usr/local/bin/* 14 | 15 | RUN apk add --no-cache \ 16 | go \ 17 | git \ 18 | git-lfs && \ 19 | export GO111MODULE="on" && \ 20 | go get sigs.k8s.io/kind@v${KIND_VERSION} && \ 21 | mkdir -p ./usr/local/bin && \ 22 | cp /root/go/bin/kind ./usr/local/bin/kind 23 | 24 | FROM docker:${DOCKER_VERSION}-dind 25 | 26 | ARG KUBERNETES_VERSION 27 | ARG HELM_VERSION_2=2.17.0 28 | ARG HELM_VERSION_3=3.6.0 29 | 30 | USER root 31 | 32 | RUN apk add --no-cache \ 33 | bash \ 34 | bash-completion \ 35 | bind-tools \ 36 | curl \ 37 | findutils \ 38 | gettext \ 39 | jq \ 40 | nano \ 41 | py3-pip \ 42 | python3 \ 43 | shadow \ 44 | supervisor && \ 45 | mkdir -p /etc/bash_completion.d && \ 46 | curl https://storage.googleapis.com/kubernetes-release/release/v${KUBERNETES_VERSION}/bin/linux/amd64/kubectl \ 47 | -Lo /usr/local/bin/kubectl && \ 48 | chmod +x /usr/local/bin/kubectl && \ 49 | kubectl completion bash \ 50 | >> /etc/bash_completion.d/kubectl.bash && \ 51 | curl helm.tar.gz https://get.helm.sh/helm-v${HELM_VERSION_3}-linux-amd64.tar.gz \ 52 | | tar xz --strip-components=1 linux-amd64/helm && \ 53 | mv helm /usr/local/bin/helm3 && \ 54 | chmod +x /usr/local/bin/helm3 && \ 55 | curl helm.tar.gz https://get.helm.sh/helm-v${HELM_VERSION_2}-linux-amd64.tar.gz \ 56 | | tar xz --strip-components=1 linux-amd64/helm && \ 57 | mv helm /usr/local/bin/helm2 && \ 58 | chmod +x /usr/local/bin/helm2 && \ 59 | ln -sf /usr/local/bin/helm3 /usr/local/bin/helm && \ 60 | helm completion bash \ 61 | >> /etc/bash_completion.d/helm.bash && \ 62 | pip install --no-cache --no-cache-dir \ 63 | yq 64 | 65 | COPY --from=builder /output / 66 | -------------------------------------------------------------------------------- /demo/kubernetes/volumes/.gitignore: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wallarm/api-firewall/c0ce5660a109c4d921d07c9fd40d610962b2a7ee/demo/kubernetes/volumes/.gitignore -------------------------------------------------------------------------------- /demo/kubernetes/volumes/chart: -------------------------------------------------------------------------------- 1 | ../../../helm/api-firewall -------------------------------------------------------------------------------- /demo/kubernetes/volumes/cluster.yaml: -------------------------------------------------------------------------------- 1 | kind: Cluster 2 | apiVersion: kind.x-k8s.io/v1alpha4 3 | networking: 4 | apiServerAddress: "0.0.0.0" 5 | apiServerPort: 6443 6 | podSubnet: "10.244.0.0/16" 7 | serviceSubnet: "10.96.0.0/12" 8 | nodes: 9 | - role: control-plane 10 | image: kindest/node:v${KIND_NODE_VERSION} 11 | extraMounts: 12 | - hostPath: /mnt/kubernetes 13 | containerPath: /mnt/kubernetes 14 | extraPortMappings: 15 | - containerPort: 30080 16 | hostPort: 30080 17 | listenAddress: "0.0.0.0" 18 | protocol: TCP 19 | - containerPort: 31080 20 | hostPort: 31080 21 | listenAddress: "0.0.0.0" 22 | protocol: TCP 23 | - containerPort: 31090 24 | hostPort: 31090 25 | listenAddress: "0.0.0.0" 26 | protocol: TCP 27 | # kubeadmConfigPatches: 28 | # - | 29 | # kind: ClusterConfiguration 30 | # apiServer: 31 | # extraArgs: 32 | # enable-admission-plugins: PodSecurityPolicy 33 | # - role: worker 34 | # image: kindest/node:v${KIND_NODE_VERSION} 35 | # extraMounts: 36 | # - hostPath: /mnt/kubernetes 37 | # containerPath: /mnt/kubernetes 38 | # kubeadmConfigPatches: 39 | # # - | 40 | # # kind: ClusterConfiguration 41 | # # apiServer: 42 | # # extraArgs: 43 | # # enable-admission-plugins: PodSecurityPolicy 44 | # - role: worker 45 | # image: kindest/node:v${KIND_NODE_VERSION} 46 | # extraMounts: 47 | # - hostPath: /mnt/kubernetes 48 | # containerPath: /mnt/kubernetes 49 | # kubeadmConfigPatches: 50 | # # - | 51 | # # kind: ClusterConfiguration 52 | # # apiServer: 53 | # # extraArgs: 54 | # # enable-admission-plugins: PodSecurityPolicy 55 | # - role: worker 56 | # image: kindest/node:v${KIND_NODE_VERSION} 57 | # extraMounts: 58 | # - hostPath: /mnt/kubernetes 59 | # containerPath: /mnt/kubernetes 60 | # kubeadmConfigPatches: 61 | # # - | 62 | # # kind: ClusterConfiguration 63 | # # apiServer: 64 | # # extraArgs: 65 | # # enable-admission-plugins: PodSecurityPolicy 66 | -------------------------------------------------------------------------------- /demo/kubernetes/volumes/dns/Corefile: -------------------------------------------------------------------------------- 1 | dc { 2 | errors 3 | ready 4 | rewrite name regex (.*)\.dc {1}. 5 | forward . 127.0.0.11 6 | cache 30 7 | loop 8 | reload 9 | loadbalance 10 | } 11 | 12 | . { 13 | errors 14 | ready 15 | forward . 1.1.1.1 8.8.8.8 16 | cache 30 17 | loop 18 | reload 19 | loadbalance 20 | } 21 | -------------------------------------------------------------------------------- /demo/kubernetes/volumes/docker/daemon.json: -------------------------------------------------------------------------------- 1 | { 2 | "live-restore": false 3 | } -------------------------------------------------------------------------------- /demo/kubernetes/volumes/init-backend/01-helm.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: apps/v1 2 | kind: Deployment 3 | metadata: 4 | name: tiller-deploy 5 | namespace: kube-system 6 | labels: 7 | app: helm 8 | name: tiller 9 | spec: 10 | replicas: 1 11 | selector: 12 | matchLabels: 13 | app: helm 14 | name: tiller 15 | strategy: 16 | type: RollingUpdate 17 | template: 18 | metadata: 19 | labels: 20 | app: helm 21 | name: tiller 22 | spec: 23 | affinity: 24 | podAntiAffinity: 25 | preferredDuringSchedulingIgnoredDuringExecution: 26 | - podAffinityTerm: 27 | labelSelector: 28 | matchExpressions: 29 | - key: app 30 | operator: In 31 | values: 32 | - helm 33 | - key: name 34 | operator: In 35 | values: 36 | - tiller 37 | topologyKey: kubernetes.io/hostname 38 | weight: 100 39 | containers: 40 | - name: tiller 41 | image: ghcr.io/helm/tiller:v2.17.0 42 | imagePullPolicy: IfNotPresent 43 | env: 44 | - name: TILLER_NAMESPACE 45 | value: kube-system 46 | - name: TILLER_HISTORY_MAX 47 | value: "5" 48 | ports: 49 | - containerPort: 44134 50 | name: tiller 51 | protocol: TCP 52 | - containerPort: 44135 53 | name: http 54 | protocol: TCP 55 | readinessProbe: 56 | failureThreshold: 3 57 | httpGet: 58 | path: /readiness 59 | port: 44135 60 | scheme: HTTP 61 | initialDelaySeconds: 1 62 | periodSeconds: 10 63 | successThreshold: 1 64 | timeoutSeconds: 1 65 | livenessProbe: 66 | failureThreshold: 3 67 | httpGet: 68 | path: /liveness 69 | port: 44135 70 | scheme: HTTP 71 | initialDelaySeconds: 1 72 | periodSeconds: 10 73 | successThreshold: 1 74 | timeoutSeconds: 1 75 | resources: 76 | requests: 77 | cpu: 50m 78 | memory: 50Mi 79 | serviceAccount: tiller 80 | serviceAccountName: tiller 81 | terminationGracePeriodSeconds: 5 82 | 83 | --- 84 | apiVersion: v1 85 | kind: Service 86 | metadata: 87 | name: tiller-deploy 88 | namespace: kube-system 89 | labels: 90 | app: helm 91 | name: tiller 92 | spec: 93 | type: ClusterIP 94 | selector: 95 | app: helm 96 | name: tiller 97 | ports: 98 | - name: tiller 99 | port: 44134 100 | protocol: TCP 101 | targetPort: tiller 102 | 103 | --- 104 | apiVersion: v1 105 | kind: ServiceAccount 106 | metadata: 107 | name: tiller 108 | namespace: kube-system 109 | -------------------------------------------------------------------------------- /demo/kubernetes/volumes/init-backend/10-httpbin.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: apps/v1 2 | kind: Deployment 3 | metadata: 4 | name: backend 5 | labels: 6 | app: httpbin 7 | role: backend 8 | spec: 9 | replicas: 4 10 | strategy: 11 | type: RollingUpdate 12 | selector: 13 | matchLabels: 14 | app: httpbin 15 | role: backend 16 | template: 17 | metadata: 18 | labels: 19 | app: httpbin 20 | role: backend 21 | spec: 22 | affinity: 23 | podAntiAffinity: 24 | preferredDuringSchedulingIgnoredDuringExecution: 25 | - podAffinityTerm: 26 | labelSelector: 27 | matchExpressions: 28 | - key: app 29 | operator: In 30 | values: 31 | - httpbin 32 | - key: role 33 | operator: In 34 | values: 35 | - backend 36 | topologyKey: kubernetes.io/hostname 37 | weight: 100 38 | terminationGracePeriodSeconds: 1 39 | containers: 40 | - name: httpbin 41 | image: kennethreitz/httpbin 42 | imagePullPolicy: IfNotPresent 43 | ports: 44 | - name: http 45 | containerPort: 80 46 | readinessProbe: 47 | failureThreshold: 1 48 | httpGet: 49 | path: /get 50 | port: http 51 | initialDelaySeconds: 5 52 | periodSeconds: 5 53 | successThreshold: 1 54 | timeoutSeconds: 1 55 | livenessProbe: 56 | failureThreshold: 6 57 | httpGet: 58 | path: /get 59 | port: http 60 | initialDelaySeconds: 30 61 | periodSeconds: 5 62 | successThreshold: 1 63 | timeoutSeconds: 1 64 | resources: 65 | requests: 66 | cpu: 50m 67 | memory: 50Mi 68 | limits: 69 | cpu: 350m 70 | memory: 250Mi 71 | 72 | --- 73 | apiVersion: v1 74 | kind: Service 75 | metadata: 76 | name: backend 77 | labels: 78 | app: httpbin 79 | role: backend 80 | spec: 81 | type: NodePort 82 | selector: 83 | app: httpbin 84 | role: backend 85 | ports: 86 | - port: 80 87 | targetPort: http 88 | nodePort: 31090 89 | -------------------------------------------------------------------------------- /docker-entrypoint.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | set -e 3 | 4 | # this if will check if the first argument is a flag 5 | # but only works if all arguments require a hyphenated flag 6 | # -v; -SL; -f arg; etc will work, but not arg1 arg2 7 | if [ "$#" -eq 0 ] || [ "${1#-}" != "$1" ]; then 8 | set -- api-firewall "$@" 9 | fi 10 | 11 | if [ "$1" = 'api-firewall' ]; then 12 | shift # "api-firewall" 13 | set -- api-firewall "$@" 14 | fi 15 | 16 | exec "$@" 17 | -------------------------------------------------------------------------------- /docs.Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.8.0 2 | 3 | WORKDIR /tmp 4 | 5 | COPY docs/requirements.txt ./ 6 | RUN pip install --no-cache-dir -r requirements.txt 7 | 8 | # Set working directory 9 | WORKDIR /docs 10 | VOLUME /docs 11 | RUN rm -rf docs 12 | 13 | EXPOSE 8000 14 | ENTRYPOINT ["mkdocs"] 15 | CMD ["serve", "--dev-addr=0.0.0.0:8000", "--config-file=mkdocs.yml"] 16 | 17 | # docker run --rm -it -p 8000:8000/tcp -v ${PWD}:/docs docs-apifirewall:latest -------------------------------------------------------------------------------- /docs/configuration-guides/allowlist.md: -------------------------------------------------------------------------------- 1 | # Allowlisting IPs 2 | 3 | The Wallarm API Firewall enables secure access to your backend by allowing requests exclusively from predefined IP addresses. This document provides a step-by-step guide on how to implement IP allowlisting, applicable for the REST API in both the [`PROXY`](../installation-guides/docker-container.md) and [`API`](../installation-guides/api-mode.md) modes or for [GraphQL API](../installation-guides/graphql/docker-container.md). 4 | 5 | This feature ensures that only requests from allowlisted IP addresses are validated against the OpenAPI specification 3.0. Requests from non-allowlisted IPs are outright rejected, returning a 403 error code, regardless of their compliance with the OpenAPI specification. 6 | 7 | To allowlist IP addresses: 8 | 9 | 1. Prepare a file listing the IP addresses you wish to allowlist. The file format is flexible (e.g., `.txt` or `.db`), with each IP address on a separate line. For instance: 10 | 11 | ``` 12 | 1.1.1.1 13 | 2001:0db8:11a3:09d7:1f34:8a2e:07a0:7655 14 | 10.1.2.0/24 15 | ``` 16 | 17 | The requests from 1.1.1.1, 2001:0db8:11a3:09d7:1f34:8a2e:07a0:7655 and 10.1.2.1-10.1.2.254 IPs will be allowed. 18 | 19 | !!! info "Allowlist validation and supported data formats" 20 | The API Firewall validates the content of the allowlist file during list handling. 21 | 22 | It supports both IPv4 and IPv6 addresses, as well as subnets. 23 | 1. Mount the allowlist file to the API Firewall Docker container using the `-v` Docker option. 24 | 1. Run the API Firewall container with the `APIFW_ALLOW_IP_FILE` environment variable indicating the path to the mounted allowlist file inside the container. 25 | 1. (Optional) Pass to the container the `APIFW_ALLOW_IP_HEADER_NAME` environment variable with the name of the request header that carries the origin IP address, if necessary. By default, `connection.remoteAddress` is used (the variable value is empty). 26 | 27 | Example `docker run` command: 28 | 29 | ``` 30 | docker run --rm -it --network api-firewall-network --network-alias api-firewall \ 31 | -v : -e APIFW_API_SPECS= \ 32 | -v ./ip-allowlist.txt:/opt/ip-allowlist.txt \ 33 | -e APIFW_URL= -e APIFW_SERVER_URL= \ 34 | -e APIFW_REQUEST_VALIDATION= -e APIFW_RESPONSE_VALIDATION= \ 35 | -e APIFW_ALLOW_IP_FILE=/opt/ip-allowlist.txt -e APIFW_ALLOW_IP_HEADER_NAME="X-Real-IP" \ 36 | -p 8088:8088 wallarm/api-firewall:v0.9.1 37 | ``` 38 | 39 | | Environment variable | Description | 40 | | -------------------- | ----------- | 41 | | `APIFW_ALLOW_IP_FILE` | Specifies the container path to the mounted file with allowlisted IP addresses (e.g., `/opt/ip-allowlist.txt`). | 42 | | `APIFW_ALLOW_IP_HEADER_NAME` | Defines the request header name that contains the origin IP address. The defauls value is `""` that points to using `connection.remoteAddress`. | 43 | -------------------------------------------------------------------------------- /docs/configuration-guides/denylist-leaked-tokens.md: -------------------------------------------------------------------------------- 1 | # Blocking Requests with Compromised Tokens 2 | 3 | The Wallarm API Firewall provides a feature to prevent the use of leaked authentication tokens. This guide outlines how to enable this feature using the API Firewall Docker container for either [REST API](../installation-guides/docker-container.md) or [GraphQL API](../installation-guides/graphql/docker-container.md). 4 | 5 | This capability relies on your supplied data regarding compromised tokens. To activate it, mount a .txt file containing these tokens to the firewall Docker container, then set the corresponding environment variable. For an in-depth look into this feature, read our [blog post](https://lab.wallarm.com/oss-api-firewall-unveils-new-feature-blacklist-for-compromised-api-tokens-and-cookies/). 6 | 7 | For REST API, should any of the flagged tokens surface in a request, the API Firewall will respond using the status code specified in the [`APIFW_CUSTOM_BLOCK_STATUS_CODE`](../installation-guides/docker-container.md#apifw-custom-block-status-code) environment variable. For GraphQL API, any request containing a flagged token will be blocked, even if it aligns with the mounted schema. 8 | 9 | To enable the denylist feature: 10 | 11 | 1. Draft a .txt file with the compromised tokens. Each token should be on a new line. Here is an example: 12 | 13 | ```txt 14 | eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJzb21lIjoicGF5bG9hZDk5OTk5ODIifQ.CUq8iJ_LUzQMfDTvArpz6jUyK0Qyn7jZ9WCqE0xKTCA 15 | eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJzb21lIjoicGF5bG9hZDk5OTk5ODMifQ.BinZ4AcJp_SQz-iFfgKOKPz_jWjEgiVTb9cS8PP4BI0 16 | eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJzb21lIjoicGF5bG9hZDk5OTk5ODQifQ.j5Iea7KGm7GqjMGBuEZc2akTIoByUaQc5SSX7w_qjY8 17 | ``` 18 | 1. Mount the denylist file to the firewall Docker container. For example, in your `docker-compose.yaml`, make the following modification: 19 | 20 | ```diff 21 | ... 22 | volumes: 23 | - : 24 | + - : 25 | ... 26 | ``` 27 | 1. Input the following environment variables when initiating the Docker container: 28 | 29 | | Environment variable | Description | 30 | | -------------------- | ----------- | 31 | | `APIFW_DENYLIST_TOKENS_FILE` | Path in the container to the mounted denylist file. Example: `/auth-data/tokens-denylist.txt`. | 32 | | `APIFW_DENYLIST_TOKENS_COOKIE_NAME` | Name of the Cookie that carries the authentication token. | 33 | | `APIFW_DENYLIST_TOKENS_HEADER_NAME` | Name of the Header transmitting the authentication token. If both the `APIFW_DENYLIST_TOKENS_COOKIE_NAME` and `APIFW_DENYLIST_TOKENS_HEADER_NAME` are specified, the API Firewall checks both in sequence. | 34 | | `APIFW_DENYLIST_TOKENS_TRIM_BEARER_PREFIX` | Indicates if the `Bearer` prefix should be removed from the authentication header during comparison with the denylist. If tokens in the denylist do not have this prefix, but the authentication header does, the tokens might not be matched correctly. Accepts `true` or `false` (default). | 35 | -------------------------------------------------------------------------------- /docs/configuration-guides/dns-cache-update.md: -------------------------------------------------------------------------------- 1 | # DNS Cache Update 2 | 3 | The DNS cache update feature allows you to make asynchronous DNS requests and cache results for a configured period of time. This feature could be useful when DNS load balancing is used. 4 | 5 | !!! info "Feature availability" 6 | This feature and corresponding variables are supported only in the [`PROXY`](../installation-guides/docker-container.md) API Firewall mode. 7 | 8 | To configure the DNS cache update, use the following environment variables: 9 | 10 | | Environment variable | Type | Description | 11 | | -------------------- | ----------- | ----------- | 12 | | `APIFW_DNS_CACHE` | `bool` | Turns on using async DNS resolving and caching feature.
The default value is `false`. | 13 | | `APIFW_DNS_FETCH_TIMEOUT` | `time.Duration` | TTL of the cache.
The default value is `1 minute`. | 14 | | `APIFW_DNS_LOOKUP_TIMEOUT` | `time.Duration` | Lookup timeout.
The default value is `1 second`. | 15 | | `APIFW_DNS_NAMESERVER_HOST` | `string` | Host of the custom nameserver.
By default the value is `“”`. In this case the configured in the system DNS server will be used. | 16 | | `APIFW_DNS_NAMESERVER_PORT` | `string` | Port of the custom nameserver.
The default value is `53`. | 17 | | `APIFW_DNS_NAMESERVER_PROTO` | `string` | Protocol to use.
Possible values are case `tcp`, `tcp4`, `tcp6`, `udp`, `udp4`, `udp6` - `4` and `6` are IPv4 and IPv6.

The default value is `udp`. | 18 | 19 | When the asynchronous DNS resolving and caching feature is turned on, a dedicated goroutine is started and the DNS cache is updated every fetch timeout period. If a custom nameserver is configured then it will be used by the APIFW for all requests and DNS caching system. If a host contains multiple IPs for one entry then the first entry will be used. Also, the IPv4 has higher priority than the IPv6 IPs. 20 | -------------------------------------------------------------------------------- /docs/configuration-guides/endpoint-related-response.md: -------------------------------------------------------------------------------- 1 | # Endpoint-Related Response Actions 2 | 3 | You can configure [validation modes](../installation-guides/docker-container.md#apifw-req-val) (`RequestValidation`, `ResponseValidation`) for each endpoint separately. If not set for the endpoint specifically, global value is used. 4 | 5 | !!! info "Example of `apifw.yaml`" 6 | ```yaml 7 | mode: "PROXY" 8 | RequestValidation: "BLOCK" 9 | ResponseValidation: "BLOCK" 10 | ... 11 | Endpoints: 12 | - Path: "/test/endpoint1" 13 | RequestValidation: "LOG_ONLY" 14 | ResponseValidation: "LOG_ONLY" 15 | - Path: "/test/endpoint1/{internal_id}" 16 | Method: "get" 17 | RequestValidation: "LOG_ONLY" 18 | ResponseValidation: "DISABLE" 19 | ``` 20 | 21 | The `Method` value is optional. If the `Method` is not set then the validation modes will be applied to all methods of the endpoint. 22 | 23 | Example of the same configuration via environment variables: 24 | 25 | ``` 26 | APIFW_ENDPOINTS=/test/endpoint1|LOG_ONLY|LOG_ONLY,GET:/test/endpoint1/{internal_id}|LOG_ONLY|DISABLE 27 | ``` 28 | 29 | The format of the `APIFW_ENDPOINTS` environment variable: 30 | 31 | ``` 32 | [METHOD:]PATH|REQUEST_VALIDATION|RESPONSE_VALIDATION 33 | ``` -------------------------------------------------------------------------------- /docs/configuration-guides/ssl-tls.md: -------------------------------------------------------------------------------- 1 | # SSL/TLS Configuration 2 | 3 | This guide explains how to set environment variables for configuring SSL/TLS connections between the API Firewall and the protected application, as well as for the API Firewall server itself. Provide these variables when launching the API Firewall Docker container for [REST API](../installation-guides/docker-container.md) or [GraphQL API](../installation-guides/graphql/docker-container.md). 4 | 5 | ## Secure SSL/TLS connection between API Firewall and the application 6 | 7 | To establish a secure connection between the API Firewall and the protected application's server that utilizes custom CA certificates, utilize the following environment variables: 8 | 9 | 1. Mount the custom CA certificate to the API Firewall container. For example, in your `docker-compose.yaml`, make the following modification: 10 | 11 | ```diff 12 | ... 13 | volumes: 14 | - : 15 | + - : 16 | ... 17 | ``` 18 | 1. Provide the mounted file path using the following environment variables: 19 | 20 | | Environment variable | Description | 21 | | -------------------- | ----------- | 22 | | `APIFW_SERVER_ROOT_CA`
(only if the `APIFW_SERVER_INSECURE_CONNECTION` value is `false`) | Path inside the Docker container to the protected application server's CA certificate. | 23 | 24 | ## Insecure connection between API Firewall and the application 25 | 26 | To set up an insecure connection (i.e., bypassing SSL/TLS verification) between the API Firewall and the protected application's server, use this environment variable: 27 | 28 | | Environment variable | Description | 29 | | -------------------- | ----------- | 30 | | `APIFW_SERVER_INSECURE_CONNECTION` | Determines whether the SSL/TLS certificate validation of the protected application server should be disabled. The server address is denoted in the `APIFW_SERVER_URL` variable. By default (`false`), the system attempts a secure connection using either the default CA certificate or the one specified in `APIFW_SERVER_ROOT_CA`. | 31 | 32 | ## SSL/TLS for the API Firewall server 33 | 34 | To ensure the server running the API Firewall accepts HTTPS connections, follow the steps below: 35 | 36 | 1. Mount the certificate and private key directory to the API Firewall container. For example, in your `docker-compose.yaml`, make the following modification: 37 | 38 | ```diff 39 | ... 40 | volumes: 41 | - : 42 | + - : 43 | ... 44 | ``` 45 | 1. Provide mounted file paths using the following environment variables: 46 | 47 | | Environment variable | Description | 48 | | -------------------- | ----------- | 49 | | `APIFW_TLS_CERTS_PATH` | Path in the container to the directory where the certificate and private key for the API Firewall are mounted. | 50 | | `APIFW_TLS_CERT_FILE` | Filename of the SSL/TLS certificate for the API Firewall, located within the `APIFW_TLS_CERTS_PATH` directory. | 51 | | `APIFW_TLS_CERT_KEY` | Filename of the SSL/TLS private key for the API Firewall, found in the `APIFW_TLS_CERTS_PATH` directory. | 52 | -------------------------------------------------------------------------------- /docs/demos/docker-compose.md: -------------------------------------------------------------------------------- 1 | ../../demo/docker-compose/README.md -------------------------------------------------------------------------------- /docs/demos/kubernetes-cluster.md: -------------------------------------------------------------------------------- 1 | ../../demo/kubernetes/README.md -------------------------------------------------------------------------------- /docs/demos/owasp-coreruleset.md: -------------------------------------------------------------------------------- 1 | ../../demo/docker-compose/OWASP_CoreRuleSet/README.md -------------------------------------------------------------------------------- /docs/images/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wallarm/api-firewall/c0ce5660a109c4d921d07c9fd40d610962b2a7ee/docs/images/favicon.png -------------------------------------------------------------------------------- /docs/images/wallarm-logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /docs/include/apifw-yaml-example.md: -------------------------------------------------------------------------------- 1 | ```yaml 2 | mode: "PROXY" 3 | RequestValidation: "BLOCK" 4 | ResponseValidation: "BLOCK" 5 | CustomBlockStatusCode: 403 6 | AddValidationStatusHeader: false 7 | APISpecs: "openapi.yaml" 8 | APISpecsCustomHeader: 9 | Name: "" 10 | Value: "" 11 | PassOptionsRequests: true 12 | SpecificationUpdatePeriod: "0" 13 | Server: 14 | APIHost: "http://0.0.0.0:8282" 15 | HealthAPIHost: "0.0.0.0:9999" 16 | ReadTimeout: "5s" 17 | WriteTimeout: "5s" 18 | ReadBufferSize: 8192 19 | WriteBufferSize: 8192 20 | MaxRequestBodySize: 4194304 21 | DisableKeepalive: false 22 | MaxConnsPerIP: 0 23 | MaxRequestsPerConn: 0 24 | DNS: 25 | Nameserver: 26 | Host: "" 27 | Port: "53" 28 | Proto: "udp" 29 | Cache: false 30 | FetchTimeout: "1m" 31 | LookupTimeout: "1s" 32 | Denylist: 33 | Tokens: 34 | CookieName: "" 35 | HeaderName: "" 36 | TrimBearerPrefix: true 37 | File: "" 38 | AllowIP: 39 | File: "" 40 | HeaderName: "" 41 | ShadowAPI: 42 | ExcludeList: 43 | - 404 44 | - 200 45 | UnknownParametersDetection: false 46 | TLS: 47 | CertsPath: "certs" 48 | CertFile: "localhost.crt" 49 | CertKey: "localhost.key" 50 | ModSecurity: 51 | ConfFiles: [] 52 | RulesDir: "" 53 | Endpoints: [] 54 | Backend: 55 | Oauth: 56 | ValidationType: "JWT" 57 | JWT: 58 | SignatureAlgorithm: "RS256" 59 | PubCertFile: "" 60 | SecretKey: "" 61 | Introspection: 62 | ClientAuthBearerToken: "" 63 | Endpoint: "" 64 | EndpointParams: "" 65 | TokenParamName: "" 66 | ContentType: "" 67 | EndpointMethod: "GET" 68 | RefreshInterval: "10m" 69 | ProtectedAPI: 70 | URL: "http://localhost:3000/v1/" 71 | RequestHostHeader: "" 72 | ClientPoolCapacity: 1000 73 | InsecureConnection: false 74 | RootCA: "" 75 | MaxConnsPerHost: 512 76 | ReadTimeout: "5s" 77 | WriteTimeout: "5s" 78 | DialTimeout: "200ms" 79 | ReadBufferSize: 8192 80 | WriteBufferSize: 8192 81 | MaxResponseBodySize: 0 82 | DeleteAcceptEncoding: false 83 | ``` -------------------------------------------------------------------------------- /docs/index.md: -------------------------------------------------------------------------------- 1 | ../README.md -------------------------------------------------------------------------------- /docs/installation-guides/graphql/playground.md: -------------------------------------------------------------------------------- 1 | # GraphQL Playground in API Firewall 2 | 3 | Wallarm API Firewall equips developers with the [GraphQL Playground](https://github.com/graphql/graphql-playground). This guide explains how to run the playground. 4 | 5 | GraphQL Playground is an in-browser Integrated Development Environment (IDE) specifically for GraphQL. It is designed as a visual platform where developers can effortlessly write, examine, and delve into the myriad possibilities of GraphQL queries, mutations, and subscriptions. 6 | 7 | The playground automatically fetches the schema from the URL set in `APIFW_SERVER_URL`. This action is an introspection query that discloses the GraphQL schema. Therefore, it is required to ensure the `APIFW_GRAPHQL_INTROSPECTION` variable is set to `true`. Doing so permits this process, averting potential errors in the API Firewall logs. 8 | 9 | To activate the Playground within the API Firewall, you need to use the following environment variables: 10 | 11 | | Environment variable | Description | 12 | | -------------------- | ----------- | 13 | | `APIFW_GRAPHQL_INTROSPECTION` | Allows introspection queries, which disclose the layout of your GraphQL schema. Ensure this variable is set to `true`. | 14 | | `APIFW_GRAPHQL_PLAYGROUND` | Toggles the playground feature. By default, it is set to `false`. To enable, change to `true`. | 15 | | `APIFW_GRAPHQL_PLAYGROUND_PATH` | Designates the path where the playground will be accessible. By default, it is the root path `/`. | 16 | 17 | Once set up, you can access the playground interface from the designated path in your browser: 18 | 19 | ![Playground](https://github.com/wallarm/api-firewall/blob/main/images/graphql-playground.png?raw=true) 20 | -------------------------------------------------------------------------------- /docs/installation-guides/graphql/websocket-origin-check.md: -------------------------------------------------------------------------------- 1 | # WebSocket Origin Validation 2 | 3 | When a browser initiates a WebSocket connection, it automatically includes an `Origin` header that denotes the domain from which the request originates. With Wallarm API Firewall, you can ensure that the value of the `Origin` header matches your predefined list during the upgrade phase of the WebSocket connection. This article outlines the steps to enable `Origin` validation for [GraphQL queries](docker-container.md). 4 | 5 | By default, the WebSocket Origin validation feature is disabled. To activate it, configure the following environment variables: 6 | 7 | | Environment variable | Description | 8 | | -------------------- | ----------- | 9 | | `APIFW_GRAPHQL_WS_CHECK_ORIGIN` | Enables the validation of the `Origin` header during the WebSocket upgrade phase. Default: `false`. | 10 | | `APIFW_GRAPHQL_WS_ORIGIN` (required if `APIFW_GRAPHQL_WS_CHECK_ORIGIN` is `true`) | The list of allowed origins for WebSocket connections. Origins are separated by `;`. | 11 | 12 | The `APIFW_GRAPHQL_WS_CHECK_ORIGIN` operates independently of [`APIFW_GRAPHQL_REQUEST_VALIDATION`](docker-container.md#apifw-graphql-request-validation). WebSocket requests with incorrect `Origin` headers will be blocked regardless of the request validation mode. 13 | -------------------------------------------------------------------------------- /docs/migrating/modseс-to-apif.md: -------------------------------------------------------------------------------- 1 | # Migrating to API Firewall from ModSecurity 2 | 3 | This guide walks through migrating from [ModSecurity](https://github.com/owasp-modsecurity/ModSecurity) to Wallarm's API Firewall by explaining how to import the ModSecurity rules to API Firewall and set API Firewall to perform protection in accordance with these rules. 4 | 5 | ## Problem and solution 6 | 7 | In August 2021, Trustwave [announced](https://www.trustwave.com/en-us/resources/security-resources/software-updates/end-of-sale-and-trustwave-support-for-modsecurity-web-application-firewall/) the end-of-sale for ModSecurity support, and the subsequent end-of-life date for their support of ModSecurity of July 2024. Trustwave has been providing regular updates to the standard rules for ModSecurity, supporting what was effectively an open source community tool with commercial quality detection rules. Reaching the end-of-life date and support ending may quickly put any organizations using ModSecurity rules at risk by quickly becoming out-of-date with their attack detection. 8 | 9 | Wallarm supports easy transitioning from ModSecurity to Wallarm's API Firewall: ModSecurity rules can be effortlessly connected to API Firewall and continued to be used without additional configuration. 10 | 11 | ## ModSecurity rules support 12 | 13 | API Firewall's ModSecurity Rules Support module allows parsing and applying ModSecurity rules (secLang) to the traffic. The module is implemented using the [Coraza](https://github.com/corazawaf/coraza) project. 14 | 15 | The module works for REST API both in the [API](../installation-guides/api-mode.md) and [PROXY](../installation-guides/docker-container.md) modes. In the API mode, only requests are checked. 16 | 17 | Supported response actions: 18 | 19 | * `drop`, `deny` - respond to the client by error message with APIFW_CUSTOM_BLOCK_STATUS_CODE code or status value (if configured in the rule). 20 | * `redirect` - responds by status code and target which were specified in the rule. 21 | 22 | GraphQL API is currently not supported. 23 | 24 | ## Running API Firewall on ModSecurity rules 25 | 26 | [Check the demo on running API Firewall with OWASP CoreRuleSet v4.x.x](../demos/owasp-coreruleset.md) 27 | -------------------------------------------------------------------------------- /docs/requirements.txt: -------------------------------------------------------------------------------- 1 | mkdocs==1.5.3 2 | mkdocs-material==9.4.6 3 | pymdown-extensions==10.3.1 4 | pygments==2.16.1 5 | mkdocs-minify-plugin==0.7.1 6 | prependnewline==1.2 7 | mkdocs-meta-descriptions-plugin==2.3.0 8 | markdown==3.5 9 | importlib_metadata==4.13.0 -------------------------------------------------------------------------------- /helm/api-firewall/.gitignore: -------------------------------------------------------------------------------- 1 | values.*.yaml 2 | values.*.yml 3 | -------------------------------------------------------------------------------- /helm/api-firewall/.helmignore: -------------------------------------------------------------------------------- 1 | # Patterns to ignore when building packages. 2 | # This supports shell glob matching, relative path matching, and 3 | # negation (prefixed with !). Only one pattern per line. 4 | .DS_Store 5 | 6 | # Common VCS dirs 7 | .git/ 8 | .gitignore 9 | .bzr/ 10 | .bzrignore 11 | .hg/ 12 | .hgignore 13 | .svn/ 14 | 15 | # Common backup files 16 | *.swp 17 | *.bak 18 | *.tmp 19 | *~ 20 | 21 | # Various IDEs 22 | .project 23 | .idea/ 24 | *.tmproj 25 | 26 | # Custom values 27 | values.*.yaml 28 | values.*.yml 29 | 30 | # Packages 31 | *.tgz 32 | 33 | # Other staff 34 | Makefile 35 | README.md 36 | -------------------------------------------------------------------------------- /helm/api-firewall/Chart.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | name: api-firewall 3 | version: 0.7.2 4 | appVersion: 0.9.1 5 | description: Wallarm OpenAPI-based API Firewall 6 | home: https://github.com/wallarm/api-firewall 7 | icon: https://static.wallarm.com/wallarm-logo.svg 8 | keywords: 9 | - wallarm 10 | - ingress 11 | - middleware 12 | - firewall 13 | - waf 14 | sources: 15 | - https://github.com/wallarm/api-firewall 16 | maintainers: 17 | - name: Wallarm Support Team 18 | email: support@wallarm.com 19 | engine: gotpl 20 | kubeVersion: ">=1.21.0-0" 21 | -------------------------------------------------------------------------------- /helm/api-firewall/README.ja.md: -------------------------------------------------------------------------------- 1 | # Wallarm API FirewallのためのHelmチャート 2 | 3 | このチャートは、[Helm](https://helm.sh/)パッケージマネージャを使用して[Kubernetes](http://kubernetes.io/)クラスタ上にWallarm API Firewallのデプロイメントを初期化します。 4 | 5 | このチャートはまだ公開のHelmレジストリにはアップロードされていません。Helmチャートのデプロイのためには、このリポジトリを使用してください。 6 | 7 | ## 必要条件 8 | 9 | * Kubernetes 1.16 又はそれ以降 10 | * Helm 2.16 又はそれ以降 11 | 12 | ## デプロイメント 13 | 14 | Wallarm API Firewall Helmチャートをデプロイするには: 15 | 16 | 1. まだ追加していない場合は、リポジトリを追加してください: 17 | 18 | ```bash 19 | helm repo add wallarm https://charts.wallarm.com 20 | ``` 21 | 22 | 2. helmチャートの最新バージョンを取得します: 23 | 24 | ```bash 25 | helm fetch wallarm/api-firewall 26 | tar -xf api-firewall*.tgz 27 | ``` 28 | 29 | 3. コードコメントに従って、`api-firewall/values.yaml` ファイルを変更してチャートを設定します。 30 | 31 | 4. このHelmチャートからWallarm API Firewallをデプロイします。 32 | 33 | このHelmチャートのデプロイメントの例を確認したい場合は、私たちの[Kuberentesデモ](https://github.com/wallarm/api-firewall/tree/main/demo/kubernetes)を走らせることができます。 -------------------------------------------------------------------------------- /helm/api-firewall/README.md: -------------------------------------------------------------------------------- 1 | # Helm chart for Wallarm API Firewall 2 | 3 | This chart bootstraps Wallarm API Firewall deployment on a [Kubernetes](http://kubernetes.io/) cluster using the [Helm](https://helm.sh/) package manager. 4 | 5 | This chart is not uploaded to any public Helm registry yet. To deploy the Helm chart, please use this repository. 6 | 7 | ## Requirements 8 | 9 | * Kubernetes 1.16 or later 10 | * Helm 2.16 or later 11 | 12 | ## Deployment 13 | 14 | To deploy the Wallarm API Firewall Helm chart: 15 | 16 | 1. Add our repository if you haven't yet: 17 | 18 | ```bash 19 | helm repo add wallarm https://charts.wallarm.com 20 | ``` 21 | 22 | 2. Fetch latest version of helm chart: 23 | 24 | ```bash 25 | helm fetch wallarm/api-firewall 26 | tar -xf api-firewall*.tgz 27 | ``` 28 | 29 | 3. Configure chart by changing the `api-firewall/values.yaml` file following the code comments. 30 | 31 | 4. Deploy Wallarm API Firewall from this Helm chart. 32 | 33 | To see the example of this Helm chart deployment, you can run our [Kuberentes demo](https://github.com/wallarm/api-firewall/tree/main/demo/kubernetes). 34 | -------------------------------------------------------------------------------- /helm/api-firewall/templates/configmap.yaml: -------------------------------------------------------------------------------- 1 | {{- if .Values.manifest.enabled -}} 2 | apiVersion: v1 3 | kind: ConfigMap 4 | metadata: 5 | name: {{ template "api-firewall.fullname" . }}-manifest 6 | labels: 7 | app: {{ template "api-firewall.name" . }} 8 | chart: {{ .Chart.Name }}-{{ .Chart.Version }} 9 | component: openapi-manifest 10 | heritage: {{ .Release.Service }} 11 | release: {{ .Release.Name }} 12 | data: 13 | openapi-manifest.json: |- 14 | {{ .Values.manifest.body | indent 4 | trimPrefix " " }} 15 | {{- end }} 16 | -------------------------------------------------------------------------------- /helm/api-firewall/templates/hpa.yaml: -------------------------------------------------------------------------------- 1 | {{- if .Values.apiFirewall.autoscaling.enabled }} 2 | apiVersion: {{ template "horizontalPodAutoscaler.apiVersion" . }} 3 | kind: HorizontalPodAutoscaler 4 | metadata: 5 | name: {{ template "api-firewall.fullname" . }} 6 | labels: 7 | app: {{ template "api-firewall.name" . }} 8 | chart: {{ .Chart.Name }}-{{ .Chart.Version }} 9 | component: api-firewall 10 | heritage: {{ .Release.Service }} 11 | release: {{ .Release.Name }} 12 | spec: 13 | scaleTargetRef: 14 | apiVersion: {{ template "deployment.apiVersion" . }} 15 | kind: Deployment 16 | name: {{ template "api-firewall.fullname" . }} 17 | minReplicas: {{ .Values.apiFirewall.autoscaling.minReplicas }} 18 | maxReplicas: {{ .Values.apiFirewall.autoscaling.maxReplicas }} 19 | {{ if .Capabilities.APIVersions.Has "autoscaling/v2beta2/HorizontalPodAutoscaler" -}} 20 | metrics: 21 | - type: Resource 22 | resource: 23 | name: cpu 24 | target: 25 | type: Utilization 26 | averageUtilization: {{ .Values.apiFirewall.autoscaling.targetCPUUtilizationPercentage }} 27 | resource: 28 | name: memory 29 | target: 30 | type: Utilization 31 | averageUtilization: {{ .Values.apiFirewall.autoscaling.targetMemoryUtilizationPercentage }} 32 | {{ else -}} 33 | {{ if .Capabilities.APIVersions.Has "autoscaling/v2beta1/HorizontalPodAutoscaler" -}} 34 | metrics: 35 | - type: Resource 36 | resource: 37 | name: cpu 38 | targetAverageUtilization: {{ .Values.apiFirewall.autoscaling.targetCPUUtilizationPercentage }} 39 | - type: Resource 40 | resource: 41 | name: memory 42 | targetAverageUtilization: {{ .Values.apiFirewall.autoscaling.targetMemoryUtilizationPercentage }} 43 | {{ end -}} 44 | {{ end -}} 45 | {{- end }} 46 | -------------------------------------------------------------------------------- /helm/api-firewall/templates/ingress.yaml: -------------------------------------------------------------------------------- 1 | {{- if .Values.apiFirewall.ingress.enabled -}} 2 | {{- if not (len .Values.apiFirewall.ingress.hosts) -}} 3 | {{- fail "You must define .apiFirewall.ingress.hosts for using Ingress objects" -}} 4 | {{- end -}} 5 | apiVersion: {{ template "ingress.apiVersion" . }} 6 | kind: Ingress 7 | metadata: 8 | name: {{ template "api-firewall.fullname" . }} 9 | labels: 10 | app: {{ template "api-firewall.name" . }} 11 | chart: {{ .Chart.Name }}-{{ .Chart.Version }} 12 | component: api-firewall 13 | heritage: {{ .Release.Service }} 14 | release: {{ .Release.Name }} 15 | {{- if .Values.apiFirewall.ingress.annotations }} 16 | annotations: {{ .Values.apiFirewall.ingress.annotations | toYaml | trimSuffix "\n" | nindent 4 }} 17 | {{- end }} 18 | spec: 19 | {{ if .Values.apiFirewall.ingress.ingressClass -}} 20 | ingressClassName: {{ .Values.apiFirewall.ingress.ingressClass }} 21 | {{ end -}} 22 | {{ if .Values.apiFirewall.ingress.hosts -}} 23 | rules: 24 | {{ range .Values.apiFirewall.ingress.hosts -}} 25 | - host: {{ . }} 26 | http: 27 | paths: 28 | - path: {{ $.Values.apiFirewall.ingress.path }} 29 | backend: 30 | {{- if $.Capabilities.APIVersions.Has "networking.k8s.io/v1/Ingress" }} 31 | service: 32 | name: {{ template "api-firewall.fullname" $ }} 33 | port: 34 | number: {{ $.Values.apiFirewall.service.port }} 35 | {{- else }} 36 | serviceName: {{ template "api-firewall.fullname" $ }} 37 | servicePort: {{ $.Values.apiFirewall.service.port }} 38 | {{- end }} 39 | {{ end -}} 40 | {{ end -}} 41 | {{ if .Values.apiFirewall.ingress.tls -}} 42 | tls: {{ toYaml .Values.apiFirewall.ingress.tls | trimSuffix "\n" | nindent 2 }} 43 | {{ end -}} 44 | {{- end }} 45 | -------------------------------------------------------------------------------- /helm/api-firewall/templates/pdb.yaml: -------------------------------------------------------------------------------- 1 | {{- if .Values.apiFirewall.podDisruptionBudget.enabled }} 2 | apiVersion: {{ template "podDisruptionBudget.apiVersion" . }} 3 | kind: PodDisruptionBudget 4 | metadata: 5 | name: {{ template "api-firewall.fullname" . }} 6 | labels: 7 | app: {{ template "api-firewall.name" . }} 8 | chart: {{ .Chart.Name }}-{{ .Chart.Version }} 9 | component: api-firewall 10 | heritage: {{ .Release.Service }} 11 | release: {{ .Release.Name }} 12 | spec: 13 | selector: 14 | matchLabels: 15 | app: {{ template "api-firewall.name" . }} 16 | release: {{ .Release.Name }} 17 | component: api-firewall 18 | maxUnavailable: {{ .Values.apiFirewall.podDisruptionBudget.maxUnavailable }} 19 | {{- end }} 20 | -------------------------------------------------------------------------------- /helm/api-firewall/templates/service.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | kind: Service 3 | metadata: 4 | name: {{ template "api-firewall.fullname" . }} 5 | labels: 6 | app: {{ template "api-firewall.name" . }} 7 | chart: {{ .Chart.Name }}-{{ .Chart.Version }} 8 | component: api-firewall 9 | heritage: {{ .Release.Service }} 10 | release: {{ .Release.Name }} 11 | {{- if .Values.apiFirewall.service.annotations }} 12 | annotations: {{ .Values.apiFirewall.service.annotations | toYaml | trimSuffix "\n" | nindent 4 }} 13 | {{- end }} 14 | spec: 15 | type: {{ .Values.apiFirewall.service.type }} 16 | {{ if .Values.apiFirewall.service.clusterIP -}} 17 | clusterIP: {{ .Values.apiFirewall.service.clusterIP | quote }} 18 | {{ end -}} 19 | {{ if eq .Values.apiFirewall.service.type "LoadBalancer" -}} 20 | {{ if .Values.apiFirewall.service.loadBalancerIP -}} 21 | loadBalancerIP: "{{ .Values.apiFirewall.service.loadBalancerIP }}" 22 | {{ end -}} 23 | {{ if .Values.apiFirewall.service.loadBalancerSourceRanges -}} 24 | loadBalancerSourceRanges: {{ toYaml .Values.apiFirewall.service.loadBalancerSourceRanges | trimSuffix "\n" | nindent 2 }} 25 | {{ end -}} 26 | {{ end -}} 27 | {{ if and (has .Values.apiFirewall.service.type (list "NodePort" "LoadBalancer")) (not (empty .Values.apiFirewall.service.externalTrafficPolicy)) -}} 28 | externalTrafficPolicy: {{ .Values.apiFirewall.service.externalTrafficPolicy }} 29 | {{ end -}} 30 | selector: 31 | app: {{ template "api-firewall.name" . }} 32 | component: api-firewall 33 | release: {{ .Release.Name }} 34 | ports: 35 | - name: http 36 | port: {{ .Values.apiFirewall.service.port }} 37 | targetPort: {{ .Values.apiFirewall.config.listenPort }} 38 | {{ if and (has .Values.apiFirewall.service.type (list "NodePort" "LoadBalancer")) (not (empty .Values.apiFirewall.service.nodePort)) -}} 39 | nodePort: {{ .Values.apiFirewall.service.nodePort }} 40 | {{ end -}} 41 | protocol: TCP 42 | -------------------------------------------------------------------------------- /helm/api-firewall/templates/serviceaccount.yaml: -------------------------------------------------------------------------------- 1 | {{- if not .Values.apiFirewall.serviceAccount.name -}} 2 | apiVersion: v1 3 | kind: ServiceAccount 4 | metadata: 5 | name: {{ template "api-firewall.serviceAccountName" . }} 6 | labels: 7 | app: {{ template "api-firewall.name" . }} 8 | chart: {{ .Chart.Name }}-{{ .Chart.Version }} 9 | component: api-firewall 10 | heritage: {{ .Release.Service }} 11 | release: {{ .Release.Name }} 12 | {{ if .Values.apiFirewall.serviceAccount.annotations -}} 13 | annotations: {{ .Values.apiFirewall.serviceAccount.annotations | toYaml | trimSuffix "\n" | nindent 4 }} 14 | {{ end -}} 15 | {{- end }} 16 | -------------------------------------------------------------------------------- /helm/api-firewall/templates/target.yaml: -------------------------------------------------------------------------------- 1 | {{ if eq .Values.apiFirewall.target.type "endpoints" -}} 2 | apiVersion: v1 3 | kind: Service 4 | metadata: 5 | name: {{ template "api-firewall.targetServiceName" . }} 6 | labels: 7 | app: {{ template "api-firewall.name" . }} 8 | chart: {{ .Chart.Name }}-{{ .Chart.Version }} 9 | component: target 10 | heritage: {{ .Release.Service }} 11 | release: {{ .Release.Name }} 12 | {{ if .Values.apiFirewall.target.annotations -}} 13 | annotations: {{ .Values.apiFirewall.target.annotations | toYaml | trimSuffix "\n" | nindent 4 }} 14 | {{ end -}} 15 | spec: 16 | type: ClusterIP 17 | {{ if .Values.apiFirewall.target.clusterIP -}} 18 | clusterIP: {{ .Values.apiFirewall.target.clusterIP | quote }} 19 | {{ end -}} 20 | ports: 21 | - name: http 22 | port: {{ .Values.apiFirewall.target.port }} 23 | protocol: TCP 24 | 25 | --- 26 | {{ if len .Values.apiFirewall.target.endpoints -}} 27 | apiVersion: v1 28 | kind: Endpoints 29 | metadata: 30 | name: {{ template "api-firewall.targetServiceName" . }} 31 | labels: 32 | app: {{ template "api-firewall.name" . }} 33 | chart: {{ .Chart.Name }}-{{ .Chart.Version }} 34 | component: target 35 | heritage: {{ .Release.Service }} 36 | release: {{ .Release.Name }} 37 | subsets: 38 | - addresses: {{ toYaml .Values.apiFirewall.target.endpoints | trimSuffix "\n" | nindent 2 }} 39 | ports: 40 | - name: http 41 | port: {{ .Values.apiFirewall.target.port }} 42 | protocol: TCP 43 | {{ end -}} 44 | {{ end -}} 45 | -------------------------------------------------------------------------------- /images/Firewall opensource - vertical.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wallarm/api-firewall/c0ce5660a109c4d921d07c9fd40d610962b2a7ee/images/Firewall opensource - vertical.gif -------------------------------------------------------------------------------- /images/firewall-as-proxy.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wallarm/api-firewall/c0ce5660a109c4d921d07c9fd40d610962b2a7ee/images/firewall-as-proxy.png -------------------------------------------------------------------------------- /images/graphql-playground.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wallarm/api-firewall/c0ce5660a109c4d921d07c9fd40d610962b2a7ee/images/graphql-playground.png -------------------------------------------------------------------------------- /internal/config/api.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import "time" 4 | 5 | type APIMode struct { 6 | APIFWInit 7 | APIFWServer 8 | ModSecurity 9 | AllowIP AllowIP 10 | TLS TLS 11 | 12 | SpecificationUpdatePeriod time.Duration `conf:"default:1m,env:API_MODE_SPECIFICATION_UPDATE_PERIOD"` 13 | PathToSpecDB string `conf:"env:API_MODE_DEBUG_PATH_DB"` 14 | DBVersion int `conf:"default:0,env:API_MODE_DB_VERSION"` 15 | 16 | UnknownParametersDetection bool `conf:"default:true,env:API_MODE_UNKNOWN_PARAMETERS_DETECTION"` 17 | PassOptionsRequests bool `conf:"default:false,env:PASS_OPTIONS"` 18 | 19 | MaxErrorsInResponse int `conf:"default:0,env:API_MODE_MAX_ERRORS_IN_RESPONSE"` 20 | } 21 | -------------------------------------------------------------------------------- /internal/config/backend.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import "time" 4 | 5 | type JWT struct { 6 | SignatureAlgorithm string `conf:"default:RS256"` 7 | PubCertFile string `conf:""` 8 | SecretKey string `conf:""` 9 | } 10 | 11 | type Introspection struct { 12 | ClientAuthBearerToken string `conf:""` 13 | Endpoint string `conf:""` 14 | EndpointParams string `conf:""` 15 | TokenParamName string `conf:""` 16 | ContentType string `conf:""` 17 | EndpointMethod string `conf:"default:GET"` 18 | RefreshInterval time.Duration `conf:"default:10m"` 19 | } 20 | 21 | type Oauth struct { 22 | ValidationType string `conf:"default:JWT"` 23 | JWT JWT 24 | Introspection Introspection 25 | } 26 | 27 | type ProtectedAPI struct { 28 | URL string `conf:"default:http://localhost:3000/v1/" validate:"required,url"` 29 | RequestHostHeader string `conf:""` 30 | ClientPoolCapacity int `conf:"default:1000" validate:"gt=0"` 31 | InsecureConnection bool `conf:"default:false"` 32 | RootCA string `conf:""` 33 | MaxConnsPerHost int `conf:"default:512"` 34 | ReadTimeout time.Duration `conf:"default:5s"` 35 | WriteTimeout time.Duration `conf:"default:5s"` 36 | DialTimeout time.Duration `conf:"default:200ms"` 37 | ReadBufferSize int `conf:"default:8192"` 38 | WriteBufferSize int `conf:"default:8192"` 39 | MaxResponseBodySize int `conf:"default:0"` 40 | DeleteAcceptEncoding bool `conf:"default:false"` 41 | } 42 | 43 | type Backend struct { 44 | ProtectedAPI 45 | Oauth Oauth 46 | } 47 | -------------------------------------------------------------------------------- /internal/config/config.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "github.com/ardanlabs/conf" 5 | ) 6 | 7 | type APIFWInit struct { 8 | conf.Version 9 | Mode string `conf:"default:PROXY" validate:"oneof=PROXY API GRAPHQL" mapstructure:"mode"` 10 | 11 | LogLevel string `conf:"default:INFO" validate:"oneof=TRACE DEBUG INFO ERROR WARNING"` 12 | LogFormat string `conf:"default:TEXT" validate:"oneof=TEXT JSON"` 13 | } 14 | 15 | type TLS struct { 16 | CertsPath string `conf:"default:certs"` 17 | CertFile string `conf:"default:localhost.crt"` 18 | CertKey string `conf:"default:localhost.key"` 19 | } 20 | 21 | type Token struct { 22 | CookieName string `conf:""` 23 | HeaderName string `conf:""` 24 | TrimBearerPrefix bool `conf:"default:true"` 25 | File string `conf:""` 26 | } 27 | 28 | type AllowIP struct { 29 | File string `conf:""` 30 | HeaderName string `conf:""` 31 | } 32 | type Denylist struct { 33 | Tokens Token 34 | } 35 | 36 | type AllowIPlist struct { 37 | AllowedIP AllowIP 38 | } 39 | -------------------------------------------------------------------------------- /internal/config/dns.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import "time" 4 | 5 | type DNS struct { 6 | Nameserver Nameserver 7 | Cache bool `conf:"default:false"` 8 | FetchTimeout time.Duration `conf:"default:1m"` 9 | LookupTimeout time.Duration `conf:"default:1s"` 10 | } 11 | 12 | type Nameserver struct { 13 | Host string `conf:""` 14 | Port string `conf:"default:53"` 15 | Proto string `conf:"default:udp"` 16 | } 17 | -------------------------------------------------------------------------------- /internal/config/endpoints.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | ) 7 | 8 | type Endpoint struct { 9 | ValidationMode `mapstructure:",squash"` 10 | Path string `conf:"required" validate:"url"` 11 | Method string `conf:"" validate:"required"` 12 | } 13 | 14 | type ValidationMode struct { 15 | RequestValidation string `conf:"required" validate:"required,oneof=DISABLE BLOCK LOG_ONLY"` 16 | ResponseValidation string `conf:"required" validate:"required,oneof=DISABLE BLOCK LOG_ONLY"` 17 | } 18 | 19 | type EndpointList []Endpoint 20 | 21 | // Set method parses list of Endpoints string to the list of Endpoint objects 22 | func (e *EndpointList) Set(value string) error { 23 | if value == "" { 24 | return nil 25 | } 26 | 27 | items := strings.Split(value, ",") 28 | for _, item := range items { 29 | parts := strings.Split(item, "|") 30 | if len(parts) != 3 { 31 | return fmt.Errorf("invalid endpoint format, expected [METHOD:]PATH|REQ|RESP") 32 | } 33 | 34 | method := "" 35 | path := "" 36 | 37 | if strings.Contains(parts[0], ":") { 38 | split := strings.SplitN(parts[0], ":", 2) 39 | method = split[0] 40 | path = split[1] 41 | } else { 42 | path = parts[0] 43 | } 44 | 45 | endpoint := Endpoint{ 46 | Method: method, 47 | Path: strings.TrimSpace(path), 48 | ValidationMode: ValidationMode{ 49 | RequestValidation: strings.TrimSpace(parts[1]), 50 | ResponseValidation: strings.TrimSpace(parts[2]), 51 | }, 52 | } 53 | 54 | if endpoint.Path == "" || endpoint.RequestValidation == "" || endpoint.ResponseValidation == "" { 55 | return fmt.Errorf("invalid endpoint format, expected [METHOD:]PATH|REQ|RESP") 56 | } 57 | 58 | *e = append(*e, endpoint) 59 | } 60 | 61 | return nil 62 | } 63 | 64 | // String method returns a string representation of the Endpoint objects list 65 | func (e EndpointList) String() string { 66 | var entries []string 67 | for _, ep := range e { 68 | entry := fmt.Sprintf("%s:%s|%s|%s", ep.Method, ep.Path, ep.RequestValidation, ep.ResponseValidation) 69 | entries = append(entries, entry) 70 | } 71 | return strings.Join(entries, ",") 72 | } 73 | -------------------------------------------------------------------------------- /internal/config/graphql.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | type GraphQLMode struct { 4 | APIFWInit 5 | APIFWServer 6 | Graphql GraphQL 7 | TLS TLS 8 | Server ProtectedAPI 9 | Denylist Denylist 10 | AllowIP AllowIP 11 | } 12 | 13 | type GraphQL struct { 14 | MaxQueryComplexity int `conf:"required" validate:"required"` 15 | MaxQueryDepth int `conf:"required" validate:"required"` 16 | MaxAliasesNum int `conf:"required" validate:"required"` 17 | NodeCountLimit int `conf:"required" validate:"required"` 18 | BatchQueryLimit int `conf:"required" validate:"required"` 19 | DisableFieldDuplication bool `conf:"default:false"` 20 | Playground bool `conf:"default:false"` 21 | PlaygroundPath string `conf:"default:/" validate:"path"` 22 | Introspection bool `conf:"required" validate:"required"` 23 | Schema string `conf:"required" validate:"required"` 24 | WSCheckOrigin bool `conf:"default:false"` 25 | WSOrigin []string `conf:"" validate:"url"` 26 | 27 | RequestValidation string `conf:"required" validate:"required,oneof=DISABLE BLOCK LOG_ONLY"` 28 | } 29 | -------------------------------------------------------------------------------- /internal/config/logging.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | 7 | "github.com/rs/zerolog" 8 | ) 9 | 10 | func DisableMultiStringFormat(i interface{}) string { 11 | if value, ok := i.(string); ok { 12 | return strings.ReplaceAll(value, "\n", " ") 13 | } 14 | return "" 15 | } 16 | 17 | type ZerologAdapter struct { 18 | Logger zerolog.Logger 19 | } 20 | 21 | // Printf func wrapper 22 | func (z *ZerologAdapter) Printf(format string, args ...interface{}) { 23 | z.Logger.Info().Msg(fmt.Sprintf(format, args...)) 24 | } 25 | -------------------------------------------------------------------------------- /internal/config/modsec.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "errors" 5 | "os" 6 | "path" 7 | 8 | "github.com/corazawaf/coraza/v3" 9 | "github.com/corazawaf/coraza/v3/types" 10 | "github.com/rs/zerolog" 11 | ) 12 | 13 | type ModSecurity struct { 14 | ConfFiles []string `conf:"env:MODSEC_CONF_FILES"` 15 | RulesDir string `conf:"env:MODSEC_RULES_DIR"` 16 | } 17 | 18 | func LoadModSecurityConfiguration(cfg *ModSecurity, logger zerolog.Logger) (coraza.WAF, error) { 19 | 20 | logErr := func(error types.MatchedRule) { 21 | logger.Error(). 22 | Strs("tags", error.Rule().Tags()). 23 | Str("version", error.Rule().Version()). 24 | Str("severity", error.Rule().Severity().String()). 25 | Int("rule_id", error.Rule().ID()). 26 | Str("file", error.Rule().File()). 27 | Int("line", error.Rule().Line()). 28 | Int("maturity", error.Rule().Maturity()). 29 | Int("accuracy", error.Rule().Accuracy()). 30 | Str("uri", error.URI()). 31 | Msg(error.Message()) 32 | } 33 | 34 | var waf coraza.WAF 35 | var err error 36 | 37 | if len(cfg.ConfFiles) > 0 || cfg.RulesDir != "" { 38 | 39 | wafConfig := coraza.NewWAFConfig().WithErrorCallback(logErr) 40 | 41 | if len(cfg.ConfFiles) > 0 { 42 | for _, confFile := range cfg.ConfFiles { 43 | if _, err := os.Stat(confFile); os.IsNotExist(err) { 44 | return nil, errors.New("Loading ModSecurity configuration file error: no such file or directory: " + confFile) 45 | } 46 | wafConfig = wafConfig.WithDirectivesFromFile(confFile) 47 | } 48 | } 49 | 50 | if cfg.RulesDir != "" { 51 | if _, err := os.Stat(cfg.RulesDir); os.IsNotExist(err) { 52 | return nil, errors.New("Loading ModSecurity rules from dir error: no such file or directory: " + cfg.RulesDir) 53 | } 54 | 55 | rules := path.Join(cfg.RulesDir, "*.conf") 56 | wafConfig = wafConfig.WithDirectivesFromFile(rules) 57 | } 58 | 59 | waf, err = coraza.NewWAF(wafConfig) 60 | if err != nil { 61 | return nil, err 62 | } 63 | } 64 | 65 | return waf, nil 66 | } 67 | -------------------------------------------------------------------------------- /internal/config/proxy.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import "time" 4 | 5 | type ProxyMode struct { 6 | APIFWInit `mapstructure:",squash"` 7 | APIFWServer `mapstructure:"Server"` 8 | ModSecurity 9 | TLS TLS 10 | ShadowAPI ShadowAPI 11 | Denylist Denylist 12 | Server Backend `mapstructure:"Backend"` 13 | AllowIP AllowIP 14 | DNS DNS 15 | Endpoints EndpointList 16 | 17 | RequestValidation string `conf:"required" validate:"required,oneof=DISABLE BLOCK LOG_ONLY"` 18 | ResponseValidation string `conf:"required" validate:"required,oneof=DISABLE BLOCK LOG_ONLY"` 19 | CustomBlockStatusCode int `conf:"default:403" validate:"HttpStatusCodes"` 20 | AddValidationStatusHeader bool `conf:"default:false"` 21 | APISpecs string `conf:"required,env:API_SPECS" validate:"required"` 22 | APISpecsCustomHeader CustomHeader `conf:"env:API_SPECS_CUSTOM_HEADER"` 23 | PassOptionsRequests bool `conf:"default:false,env:PASS_OPTIONS"` 24 | 25 | SpecificationUpdatePeriod time.Duration `conf:"default:0"` 26 | } 27 | 28 | type CustomHeader struct { 29 | Name string 30 | Value string 31 | } 32 | 33 | type ShadowAPI struct { 34 | ExcludeList []int `conf:"default:404,env:SHADOW_API_EXCLUDE_LIST" validate:"HttpStatusCodes"` 35 | UnknownParametersDetection bool `conf:"default:true,env:SHADOW_API_UNKNOWN_PARAMETERS_DETECTION"` 36 | } 37 | -------------------------------------------------------------------------------- /internal/config/server.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import "time" 4 | 5 | type APIFWServer struct { 6 | APIHost string `conf:"default:http://0.0.0.0:8282,env:URL" validate:"required,url"` 7 | HealthAPIHost string `conf:"default:0.0.0.0:9667,env:HEALTH_HOST" validate:"required"` 8 | ReadTimeout time.Duration `conf:"default:5s"` 9 | WriteTimeout time.Duration `conf:"default:5s"` 10 | ReadBufferSize int `conf:"default:8192"` 11 | WriteBufferSize int `conf:"default:8192"` 12 | MaxRequestBodySize int `conf:"default:4194304"` 13 | DisableKeepalive bool `conf:"default:false"` 14 | MaxConnsPerIP int `conf:"default:0"` 15 | MaxRequestsPerConn int `conf:"default:0"` 16 | } 17 | -------------------------------------------------------------------------------- /internal/config/status.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "net/http" 5 | "reflect" 6 | 7 | "github.com/go-playground/validator" 8 | ) 9 | 10 | var ( 11 | AllHttpStatuses = []int{ 12 | // 1xx 13 | http.StatusContinue, 14 | http.StatusSwitchingProtocols, 15 | http.StatusProcessing, 16 | http.StatusEarlyHints, 17 | 18 | // 2xx 19 | http.StatusOK, 20 | http.StatusCreated, 21 | http.StatusAccepted, 22 | http.StatusNonAuthoritativeInfo, 23 | http.StatusNoContent, 24 | http.StatusResetContent, 25 | http.StatusPartialContent, 26 | http.StatusMultiStatus, 27 | http.StatusAlreadyReported, 28 | http.StatusIMUsed, 29 | 30 | // 3xx 31 | http.StatusMultipleChoices, 32 | http.StatusMovedPermanently, 33 | http.StatusFound, 34 | http.StatusSeeOther, 35 | http.StatusNotModified, 36 | http.StatusUseProxy, 37 | http.StatusTemporaryRedirect, 38 | http.StatusPermanentRedirect, 39 | 40 | // 4xx 41 | http.StatusBadRequest, 42 | http.StatusUnauthorized, 43 | http.StatusPaymentRequired, 44 | http.StatusForbidden, 45 | http.StatusNotFound, 46 | http.StatusMethodNotAllowed, 47 | http.StatusNotAcceptable, 48 | http.StatusProxyAuthRequired, 49 | http.StatusRequestTimeout, 50 | http.StatusConflict, 51 | http.StatusGone, 52 | http.StatusLengthRequired, 53 | http.StatusPreconditionFailed, 54 | http.StatusRequestEntityTooLarge, 55 | http.StatusRequestURITooLong, 56 | http.StatusUnsupportedMediaType, 57 | http.StatusRequestedRangeNotSatisfiable, 58 | http.StatusExpectationFailed, 59 | http.StatusTeapot, 60 | http.StatusMisdirectedRequest, 61 | http.StatusUnprocessableEntity, 62 | http.StatusLocked, 63 | http.StatusFailedDependency, 64 | http.StatusTooEarly, 65 | http.StatusUpgradeRequired, 66 | http.StatusPreconditionRequired, 67 | http.StatusTooManyRequests, 68 | http.StatusRequestHeaderFieldsTooLarge, 69 | http.StatusUnavailableForLegalReasons, 70 | 71 | // 5xx 72 | http.StatusInternalServerError, 73 | http.StatusNotImplemented, 74 | http.StatusBadGateway, 75 | http.StatusServiceUnavailable, 76 | http.StatusGatewayTimeout, 77 | http.StatusHTTPVersionNotSupported, 78 | http.StatusVariantAlsoNegotiates, 79 | http.StatusInsufficientStorage, 80 | http.StatusLoopDetected, 81 | http.StatusNotExtended, 82 | http.StatusNetworkAuthenticationRequired, 83 | } 84 | ) 85 | 86 | type HTTPStatusCodeList struct { 87 | StatusCodes []int 88 | } 89 | 90 | func ValidateStatusList(fl validator.FieldLevel) bool { 91 | field := fl.Field() 92 | 93 | switch field.Kind() { 94 | case reflect.Int, reflect.Int64, reflect.Float64: 95 | confStatusCode := int(field.Int()) 96 | statusInvalid := true 97 | for _, httpStatus := range AllHttpStatuses { 98 | if confStatusCode == httpStatus { 99 | statusInvalid = false 100 | break 101 | } 102 | } 103 | if statusInvalid { 104 | return false 105 | } 106 | case reflect.Slice: 107 | for i := 0; i < field.Len(); i++ { 108 | confStatusCode := int(field.Index(i).Int()) 109 | statusInvalid := true 110 | for _, httpStatus := range AllHttpStatuses { 111 | if confStatusCode == httpStatus { 112 | statusInvalid = false 113 | break 114 | } 115 | } 116 | if statusInvalid { 117 | return false 118 | } 119 | } 120 | default: 121 | return false 122 | } 123 | return true 124 | } 125 | -------------------------------------------------------------------------------- /internal/mid/denylist.go: -------------------------------------------------------------------------------- 1 | package mid 2 | 3 | import ( 4 | "errors" 5 | "strings" 6 | 7 | "github.com/rs/zerolog" 8 | "github.com/valyala/fasthttp" 9 | 10 | "github.com/wallarm/api-firewall/internal/config" 11 | "github.com/wallarm/api-firewall/internal/platform/denylist" 12 | "github.com/wallarm/api-firewall/internal/platform/router" 13 | "github.com/wallarm/api-firewall/internal/platform/web" 14 | ) 15 | 16 | type DenylistOptions struct { 17 | Mode string 18 | Config *config.Denylist 19 | CustomBlockStatusCode int 20 | DeniedTokens *denylist.DeniedTokens 21 | Logger zerolog.Logger 22 | } 23 | 24 | var errAccessDenied = errors.New("access denied") 25 | 26 | // Denylist forbidden requests with tokens in the blacklist 27 | func Denylist(options *DenylistOptions) web.Middleware { 28 | 29 | // This is the actual middleware function to be executed. 30 | m := func(before router.Handler) router.Handler { 31 | 32 | // Create the handler that will be attached in the middleware chain. 33 | h := func(ctx *fasthttp.RequestCtx) error { 34 | 35 | // check existence and emptiness of the cache 36 | if options.DeniedTokens != nil && options.DeniedTokens.ElementsNum > 0 { 37 | if options.Config.Tokens.CookieName != "" { 38 | token := string(ctx.Request.Header.Cookie(options.Config.Tokens.CookieName)) 39 | if _, found := options.DeniedTokens.Cache.Get(token); found { 40 | options.Logger.Info(). 41 | Interface("request_id", ctx.UserValue(web.RequestID)). 42 | Bytes("host", ctx.Request.Header.Host()). 43 | Bytes("path", ctx.Path()). 44 | Bytes("method", ctx.Request.Header.Method()). 45 | Str("token", token). 46 | Msg("The request with the API token has been blocked") 47 | 48 | if strings.EqualFold(options.Mode, web.GraphQLMode) { 49 | ctx.Response.SetStatusCode(options.CustomBlockStatusCode) 50 | return web.RespondGraphQLErrors(&ctx.Response, errAccessDenied) 51 | } 52 | return web.RespondError(ctx, options.CustomBlockStatusCode, "") 53 | } 54 | } 55 | if options.Config.Tokens.HeaderName != "" { 56 | token := string(ctx.Request.Header.Peek(options.Config.Tokens.HeaderName)) 57 | if options.Config.Tokens.TrimBearerPrefix { 58 | token = strings.TrimPrefix(token, "Bearer ") 59 | } 60 | if _, found := options.DeniedTokens.Cache.Get(token); found { 61 | 62 | options.Logger.Info(). 63 | Interface("request_id", ctx.UserValue(web.RequestID)). 64 | Bytes("host", ctx.Request.Header.Host()). 65 | Bytes("path", ctx.Path()). 66 | Bytes("method", ctx.Request.Header.Method()). 67 | Str("token", token). 68 | Msg("The request with the API token has been blocked") 69 | 70 | if strings.EqualFold(options.Mode, web.GraphQLMode) { 71 | ctx.Response.SetStatusCode(options.CustomBlockStatusCode) 72 | return web.RespondGraphQLErrors(&ctx.Response, errAccessDenied) 73 | } 74 | return web.RespondError(ctx, options.CustomBlockStatusCode, "") 75 | } 76 | } 77 | } 78 | 79 | err := before(ctx) 80 | 81 | // Return the error, so it can be handled further up the chain. 82 | return err 83 | } 84 | 85 | return h 86 | } 87 | 88 | return m 89 | } 90 | -------------------------------------------------------------------------------- /internal/mid/errors.go: -------------------------------------------------------------------------------- 1 | package mid 2 | 3 | import ( 4 | "github.com/rs/zerolog" 5 | "github.com/valyala/fasthttp" 6 | 7 | "github.com/wallarm/api-firewall/internal/platform/router" 8 | "github.com/wallarm/api-firewall/internal/platform/web" 9 | ) 10 | 11 | // Errors handles errors coming out of the call chain. It detects normal 12 | // application errors which are used to respond to the client in a uniform way. 13 | // Unexpected errors (status >= 500) are logged. 14 | func Errors(logger zerolog.Logger) web.Middleware { 15 | 16 | // This is the actual middleware function to be executed. 17 | m := func(before router.Handler) router.Handler { 18 | 19 | // Create the handler that will be attached in the middleware chain. 20 | h := func(ctx *fasthttp.RequestCtx) error { 21 | 22 | // Run the handler chain and catch any propagated error. 23 | if err := before(ctx); err != nil { 24 | 25 | // Log the error. 26 | logger.Error().Err(err). 27 | Interface("request_id", ctx.UserValue(web.RequestID)). 28 | Bytes("host", ctx.Request.Header.Host()). 29 | Bytes("path", ctx.Path()). 30 | Bytes("method", ctx.Request.Header.Method()). 31 | Msg("common error") 32 | 33 | // Respond to the error. 34 | if err := web.RespondError(ctx, fasthttp.StatusInternalServerError, ""); err != nil { 35 | return err 36 | } 37 | 38 | // If we receive the shutdown err we need to return it 39 | // back to the base handler to shutdown the service. 40 | if ok := web.IsShutdown(err); ok { 41 | return err 42 | } 43 | } 44 | 45 | // The error has been handled so we can stop propagating it. 46 | return nil 47 | } 48 | 49 | return h 50 | } 51 | 52 | return m 53 | } 54 | -------------------------------------------------------------------------------- /internal/mid/logger.go: -------------------------------------------------------------------------------- 1 | package mid 2 | 3 | import ( 4 | "time" 5 | 6 | "github.com/rs/zerolog" 7 | "github.com/valyala/fasthttp" 8 | "github.com/wallarm/api-firewall/internal/platform/router" 9 | "github.com/wallarm/api-firewall/internal/platform/web" 10 | ) 11 | 12 | // Logger writes some information about the request to the logs in the 13 | // format: TraceID : (200) GET /foo -> IP ADDR (latency) 14 | func Logger(logger zerolog.Logger) web.Middleware { 15 | 16 | // This is the actual middleware function to be executed. 17 | m := func(before router.Handler) router.Handler { 18 | 19 | // Create the handler that will be attached in the middleware chain. 20 | h := func(ctx *fasthttp.RequestCtx) error { 21 | start := time.Now() 22 | 23 | logger.Debug(). 24 | Interface("request_id", ctx.UserValue(web.RequestID)). 25 | Bytes("uri", ctx.Request.URI().RequestURI()). 26 | Bytes("path", ctx.Path()). 27 | Bytes("method", ctx.Request.Header.Method()). 28 | Str("client_address", ctx.RemoteAddr().String()). 29 | Msg("request received") 30 | 31 | err := before(ctx) 32 | 33 | // check method and path 34 | if isProxyNoRouteValue := ctx.Value(web.RequestProxyNoRoute); isProxyNoRouteValue != nil { 35 | if isProxyNoRouteValue.(bool) { 36 | logger.Error(). 37 | Interface("request_id", ctx.UserValue(web.RequestID)). 38 | Int("status_code", ctx.Response.StatusCode()). 39 | Int("response_length", ctx.Response.Header.ContentLength()). 40 | Bytes("method", ctx.Request.Header.Method()). 41 | Bytes("path", ctx.Path()). 42 | Bytes("uri", ctx.Request.URI().RequestURI()). 43 | Str("client_address", ctx.RemoteAddr().String()). 44 | Msg("method or path not found in the OpenAPI specification") 45 | } 46 | } 47 | 48 | logger.Debug(). 49 | Interface("request_id", ctx.UserValue(web.RequestID)). 50 | Int("status_code", ctx.Response.StatusCode()). 51 | Bytes("method", ctx.Request.Header.Method()). 52 | Bytes("path", ctx.Path()). 53 | Bytes("uri", ctx.Request.URI().RequestURI()). 54 | Str("client_address", ctx.RemoteAddr().String()). 55 | Str("processing_time", time.Since(start).String()). 56 | Msg("request processed") 57 | 58 | // log all information about the request 59 | web.LogRequestResponseAtTraceLevel(ctx, logger) 60 | 61 | // Return the error, so it can be handled further up the chain. 62 | return err 63 | } 64 | 65 | return h 66 | } 67 | 68 | return m 69 | } 70 | -------------------------------------------------------------------------------- /internal/mid/mimetype.go: -------------------------------------------------------------------------------- 1 | package mid 2 | 3 | import ( 4 | "github.com/gabriel-vasile/mimetype" 5 | "github.com/rs/zerolog" 6 | "github.com/valyala/fasthttp" 7 | 8 | "github.com/wallarm/api-firewall/internal/platform/router" 9 | "github.com/wallarm/api-firewall/internal/platform/web" 10 | ) 11 | 12 | // MIMETypeIdentifier identifies the MIME type of the content in case of CT header is missing 13 | func MIMETypeIdentifier(logger zerolog.Logger) web.Middleware { 14 | 15 | // This is the actual middleware function to be executed. 16 | m := func(before router.Handler) router.Handler { 17 | 18 | // Create the handler that will be attached in the middleware chain. 19 | h := func(ctx *fasthttp.RequestCtx) error { 20 | 21 | // get current Wallarm schema ID 22 | if len(ctx.Request.Header.ContentType()) == 0 && len(ctx.Request.Body()) > 0 { 23 | // decode request body 24 | requestContentEncoding := string(ctx.Request.Header.ContentEncoding()) 25 | if requestContentEncoding != "" { 26 | body, err := web.GetDecompressedRequestBody(&ctx.Request, requestContentEncoding) 27 | if err != nil { 28 | logger.Error().Err(err). 29 | Interface("request_id", ctx.UserValue(web.RequestID)). 30 | Bytes("host", ctx.Request.Header.Host()). 31 | Bytes("path", ctx.Path()). 32 | Bytes("method", ctx.Request.Header.Method()). 33 | Msg("request body decompression error") 34 | return web.RespondError(ctx, fasthttp.StatusInternalServerError, "") 35 | } 36 | 37 | mtype, err := mimetype.DetectReader(body) 38 | if err != nil { 39 | logger.Error().Err(err). 40 | Interface("request_id", ctx.UserValue(web.RequestID)). 41 | Bytes("host", ctx.Request.Header.Host()). 42 | Bytes("path", ctx.Path()). 43 | Bytes("method", ctx.Request.Header.Method()). 44 | Msg("schema version mismatch") 45 | return web.RespondError(ctx, fasthttp.StatusInternalServerError, "") 46 | } 47 | 48 | // set the identified mime type 49 | ctx.Request.Header.SetContentType(mtype.String()) 50 | } 51 | 52 | // set the identified mime type 53 | ctx.Request.Header.SetContentType(mimetype.Detect(ctx.Request.Body()).String()) 54 | } 55 | 56 | err := before(ctx) 57 | 58 | return err 59 | } 60 | 61 | return h 62 | } 63 | 64 | return m 65 | } 66 | -------------------------------------------------------------------------------- /internal/mid/panics.go: -------------------------------------------------------------------------------- 1 | package mid 2 | 3 | import ( 4 | "runtime/debug" 5 | 6 | "github.com/pkg/errors" 7 | "github.com/rs/zerolog" 8 | "github.com/valyala/fasthttp" 9 | 10 | "github.com/wallarm/api-firewall/internal/platform/router" 11 | "github.com/wallarm/api-firewall/internal/platform/web" 12 | ) 13 | 14 | // Panics recovers from panics and converts the panic to an error so it is 15 | // reported in Metrics and handled in Errors. 16 | func Panics(logger zerolog.Logger) web.Middleware { 17 | 18 | // This is the actual middleware function to be executed. 19 | m := func(after router.Handler) router.Handler { 20 | 21 | // Create the handler that will be attached in the middleware chain. 22 | h := func(ctx *fasthttp.RequestCtx) (err error) { 23 | 24 | // Defer a function to recover from a panic and set the err return 25 | // variable after the fact. 26 | defer func() { 27 | if r := recover(); r != nil { 28 | err = errors.Errorf("panic: %v", r) 29 | 30 | // Log the Go stack trace for this panic'd goroutine. 31 | logger.Debug().Msgf("%s", debug.Stack()) 32 | } 33 | }() 34 | 35 | // Call the next Handler and set its return value in the err variable. 36 | return after(ctx) 37 | } 38 | 39 | return h 40 | } 41 | 42 | return m 43 | } 44 | -------------------------------------------------------------------------------- /internal/mid/shadowAPI.go: -------------------------------------------------------------------------------- 1 | package mid 2 | 3 | import ( 4 | "slices" 5 | 6 | "github.com/rs/zerolog" 7 | "github.com/valyala/fasthttp" 8 | 9 | "github.com/wallarm/api-firewall/internal/config" 10 | "github.com/wallarm/api-firewall/internal/platform/router" 11 | "github.com/wallarm/api-firewall/internal/platform/web" 12 | ) 13 | 14 | // ShadowAPIMonitor check each request for the params, methods or paths that are not specified 15 | // in the OpenAPI specification and log each violation 16 | func ShadowAPIMonitor(logger zerolog.Logger, cfg *config.ShadowAPI) web.Middleware { 17 | 18 | // This is the actual middleware function to be executed. 19 | m := func(before router.Handler) router.Handler { 20 | 21 | // Create the handler that will be attached in the middleware chain. 22 | h := func(ctx *fasthttp.RequestCtx) error { 23 | 24 | err := before(ctx) 25 | 26 | if isProxyFailedValue := ctx.Value(web.RequestProxyFailed); isProxyFailedValue != nil { 27 | if isProxyFailedValue.(bool) { 28 | return err 29 | } 30 | } 31 | 32 | // skip check if request has been blocked 33 | if isBlockedValue := ctx.Value(web.RequestBlocked); isBlockedValue != nil { 34 | if isBlockedValue.(bool) { 35 | return err 36 | } 37 | } 38 | 39 | currentMethod := string(ctx.Request.Header.Method()) 40 | currentPath := string(ctx.Path()) 41 | 42 | // get the response status code presence in the OpenAPI status 43 | isProxyStatusCodeNotFound := false 44 | statusCodeNotFoundValue := ctx.Value(web.ResponseStatusNotFound) 45 | if statusCodeNotFoundValue != nil { 46 | isProxyStatusCodeNotFound = statusCodeNotFoundValue.(bool) 47 | } 48 | 49 | // check response status code 50 | statusCode := ctx.Response.StatusCode() 51 | idx := slices.IndexFunc(cfg.ExcludeList, func(c int) bool { return c == statusCode }) 52 | 53 | // if response status code not found in the OpenAPI spec AND the code not in the exclude list 54 | if isProxyStatusCodeNotFound && idx < 0 { 55 | logger.Error(). 56 | Interface("request_id", ctx.UserValue(web.RequestID)). 57 | Int("status_code", ctx.Response.StatusCode()). 58 | Int("response_length", ctx.Response.Header.ContentLength()). 59 | Str("method", currentMethod). 60 | Str("path", currentPath). 61 | Str("client_address", ctx.RemoteAddr().String()). 62 | Str("violation", "shadow_api"). 63 | Msg("Shadow API detected: response status code not found in the OpenAPI specification") 64 | } 65 | 66 | // Return the error, so it can be handled further up the chain. 67 | return err 68 | } 69 | 70 | return h 71 | } 72 | 73 | return m 74 | } 75 | -------------------------------------------------------------------------------- /internal/platform/complexity/complexity.go: -------------------------------------------------------------------------------- 1 | package complexity 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/wallarm/api-firewall/internal/config" 7 | "github.com/wundergraph/graphql-go-tools/pkg/graphql" 8 | ) 9 | 10 | // ValidateQuery performs the query complexity checks 11 | func ValidateQuery(cfg *config.GraphQL, s *graphql.Schema, r *graphql.Request) graphql.RequestErrors { 12 | result, err := r.CalculateComplexity(graphql.DefaultComplexityCalculator, s) 13 | if err != nil { 14 | return graphql.RequestErrorsFromError(err) 15 | } 16 | 17 | var requestErrors graphql.RequestErrors 18 | 19 | if cfg.MaxQueryComplexity > 0 && result.Complexity > cfg.MaxQueryComplexity { 20 | requestErrors = append(requestErrors, 21 | graphql.RequestError{Message: fmt.Sprintf("the maximum query complexity value has been exceeded. The maximum query complexity value is %d. The current query complexity is %d", cfg.MaxQueryComplexity, result.Complexity)}) 22 | } 23 | 24 | if cfg.MaxQueryDepth > 0 && result.Depth > cfg.MaxQueryDepth { 25 | requestErrors = append(requestErrors, 26 | graphql.RequestError{Message: fmt.Sprintf("the maximum query depth value has been exceeded. The maximum query depth value is %d. The current query depth is %d", cfg.MaxQueryDepth, result.Depth)}) 27 | } 28 | 29 | if cfg.NodeCountLimit > 0 && result.NodeCount > cfg.NodeCountLimit { 30 | requestErrors = append(requestErrors, 31 | graphql.RequestError{Message: fmt.Sprintf("the query node limit has been exceeded. The query node count limit is %d. The current query node count value is %d", cfg.NodeCountLimit, result.NodeCount)}) 32 | } 33 | 34 | return requestErrors 35 | } 36 | -------------------------------------------------------------------------------- /internal/platform/complexity/complexity_test.go: -------------------------------------------------------------------------------- 1 | package complexity 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/require" 7 | "github.com/wallarm/api-firewall/internal/config" 8 | "github.com/wundergraph/graphql-go-tools/pkg/graphql" 9 | ) 10 | 11 | const ( 12 | testQuery = ` 13 | query { 14 | room(name: "TestChat") { 15 | name 16 | messages { 17 | id 18 | text 19 | createdBy 20 | createdAt 21 | } 22 | } 23 | }` 24 | testSchema = ` 25 | type Chatroom { 26 | name: String! 27 | messages: [Message!]! 28 | } 29 | 30 | type Message { 31 | id: ID! 32 | text: String! 33 | createdBy: String! 34 | createdAt: Time! 35 | } 36 | 37 | type Query { 38 | room(name:String!): Chatroom 39 | } 40 | ` 41 | ) 42 | 43 | func TestComplexity(t *testing.T) { 44 | testCases := map[string]struct { 45 | cfgGraphQL *config.GraphQL 46 | expectedErrorCount int 47 | }{ 48 | "disabled_all": { 49 | cfgGraphQL: &config.GraphQL{}, 50 | }, 51 | "invalid_query": { 52 | cfgGraphQL: &config.GraphQL{ 53 | NodeCountLimit: 1, 54 | MaxQueryDepth: 1, 55 | MaxQueryComplexity: 1, 56 | }, 57 | expectedErrorCount: 3, 58 | }, 59 | "invalid_query_node_count_limit": { 60 | cfgGraphQL: &config.GraphQL{ 61 | NodeCountLimit: 1, 62 | }, 63 | expectedErrorCount: 1, 64 | }, 65 | "invalid_query_max_depth": { 66 | cfgGraphQL: &config.GraphQL{ 67 | MaxQueryDepth: 1, 68 | }, 69 | expectedErrorCount: 1, 70 | }, 71 | "invalid_query_max_complexity": { 72 | cfgGraphQL: &config.GraphQL{ 73 | MaxQueryComplexity: 1, 74 | }, 75 | expectedErrorCount: 1, 76 | }, 77 | "valid_complexity": { 78 | cfgGraphQL: &config.GraphQL{ 79 | MaxQueryComplexity: 2, 80 | }, 81 | expectedErrorCount: 0, 82 | }, 83 | "valid_max_depth": { 84 | cfgGraphQL: &config.GraphQL{ 85 | MaxQueryDepth: 3, 86 | }, 87 | expectedErrorCount: 0, 88 | }, 89 | "valid_node_count_limit": { 90 | cfgGraphQL: &config.GraphQL{ 91 | NodeCountLimit: 2, 92 | }, 93 | expectedErrorCount: 0, 94 | }, 95 | "valid_query_limits": { 96 | cfgGraphQL: &config.GraphQL{ 97 | MaxQueryComplexity: 2, 98 | MaxQueryDepth: 3, 99 | NodeCountLimit: 2, 100 | }, 101 | expectedErrorCount: 0, 102 | }, 103 | } 104 | 105 | s, err := graphql.NewSchemaFromString(testSchema) 106 | if err != nil { 107 | t.Fatal(err) 108 | } 109 | 110 | gqlRequest := &graphql.Request{ 111 | Query: testQuery, 112 | } 113 | 114 | if _, err := s.Normalize(); err != nil { 115 | t.Fatal(err) 116 | } 117 | 118 | if _, err := gqlRequest.Normalize(s); err != nil { 119 | t.Fatal(err) 120 | } 121 | 122 | for name, testCase := range testCases { 123 | requestErrors := ValidateQuery(testCase.cfgGraphQL, s, gqlRequest) 124 | require.Equalf(t, testCase.expectedErrorCount, requestErrors.Count(), "case %s: unexpected error count", name) 125 | } 126 | } 127 | -------------------------------------------------------------------------------- /internal/platform/denylist/denylist.go: -------------------------------------------------------------------------------- 1 | package denylist 2 | 3 | import ( 4 | "bufio" 5 | "io" 6 | "os" 7 | "strings" 8 | 9 | "github.com/dgraph-io/ristretto" 10 | "github.com/rs/zerolog" 11 | "github.com/wallarm/api-firewall/internal/config" 12 | ) 13 | 14 | const ( 15 | BufferItems = 64 16 | ElementCost = 1 17 | // The actual need is 56 (size of ristretto's storeItem struct) 18 | StoreItemSize = 128 19 | ) 20 | 21 | type DeniedTokens struct { 22 | Cache *ristretto.Cache 23 | ElementsNum int64 24 | } 25 | 26 | func New(cfg *config.Denylist, logger zerolog.Logger) (*DeniedTokens, error) { 27 | 28 | if cfg.Tokens.File == "" { 29 | return nil, nil 30 | } 31 | 32 | var totalEntries int64 33 | 34 | // open tokens storage 35 | f, err := os.Open(cfg.Tokens.File) 36 | if err != nil { 37 | return nil, err 38 | } 39 | 40 | // count non-empty entries and total cache capacity in bytes 41 | c := bufio.NewScanner(f) 42 | for c.Scan() { 43 | if c.Text() != "" { 44 | totalEntries += 1 45 | } 46 | } 47 | err = c.Err() 48 | if err != nil { 49 | return nil, err 50 | } 51 | 52 | // go to the beginning of the storage file 53 | if _, err = f.Seek(0, io.SeekStart); err != nil { 54 | return nil, err 55 | } 56 | 57 | logger.Debug().Msgf("Denylist: total entries (lines) found in the file: %d", totalEntries) 58 | 59 | // max cost = total entries * size of ristretto's storeItem struct 60 | maxCost := totalEntries * StoreItemSize 61 | 62 | logger.Debug().Msgf("Denylist: cache capacity: %d bytes", maxCost) 63 | 64 | cache, err := ristretto.NewCache(&ristretto.Config{ 65 | NumCounters: maxCost * 10, // recommended value 66 | MaxCost: maxCost, 67 | BufferItems: BufferItems, 68 | }) 69 | if err != nil { 70 | return nil, err 71 | } 72 | 73 | var numOfElements int64 74 | 75 | // 10% counter 76 | var counter10P int64 77 | 78 | // tokens loading to the cache 79 | s := bufio.NewScanner(f) 80 | for s.Scan() { 81 | if s.Text() != "" { 82 | if ok := cache.Set(strings.TrimSpace(s.Text()), nil, ElementCost); ok { 83 | numOfElements += 1 84 | 85 | currentPercent := numOfElements * 100 / totalEntries 86 | if currentPercent/10 > counter10P { 87 | counter10P = currentPercent / 10 88 | logger.Debug().Msgf("Denylist: loaded %d perecents of tokens. Total elements in the cache: %d", counter10P*10, numOfElements) 89 | } 90 | } else { 91 | logger.Error().Msgf("Denylist: can't add the token to the cache: %s", s.Text()) 92 | } 93 | cache.Wait() 94 | } 95 | } 96 | err = s.Err() 97 | if err != nil { 98 | return nil, err 99 | } 100 | 101 | if err := f.Close(); err != nil { 102 | return nil, err 103 | } 104 | 105 | return &DeniedTokens{Cache: cache, ElementsNum: totalEntries}, nil 106 | } 107 | -------------------------------------------------------------------------------- /internal/platform/loader/loader.go: -------------------------------------------------------------------------------- 1 | package loader 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "fmt" 7 | 8 | "github.com/getkin/kin-openapi/openapi3" 9 | ) 10 | 11 | var ( 12 | ErrOASValidation = errors.New("OpenAPI specification validation error") 13 | ErrOASParsing = errors.New("OpenAPI specification parsing error") 14 | ) 15 | 16 | func validateOAS(spec *openapi3.T) error { 17 | 18 | if err := spec.Validate( 19 | context.Background(), 20 | openapi3.DisableExamplesValidation(), 21 | openapi3.DisableSchemaFormatValidation(), 22 | openapi3.DisableSchemaDefaultsValidation(), 23 | openapi3.DisableSchemaPatternValidation(), 24 | ); err != nil { 25 | return err 26 | } 27 | 28 | return nil 29 | } 30 | 31 | func ParseOAS(schema []byte, SchemaVersion string, schemaID int) (*openapi3.T, error) { 32 | 33 | // parse specification 34 | loader := openapi3.NewLoader() 35 | parsedSpec, err := loader.LoadFromData(schema) 36 | if err != nil { 37 | return nil, fmt.Errorf("%w: schema version '%s', schema ID %d: %w", ErrOASParsing, SchemaVersion, schemaID, err) 38 | } 39 | 40 | if err := validateOAS(parsedSpec); err != nil { 41 | return nil, fmt.Errorf("%w: schema version '%s', schema ID %d: %w: ", ErrOASValidation, SchemaVersion, schemaID, err) 42 | } 43 | 44 | return parsedSpec, nil 45 | } 46 | -------------------------------------------------------------------------------- /internal/platform/loader/router.go: -------------------------------------------------------------------------------- 1 | package loader 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | 7 | "github.com/getkin/kin-openapi/openapi3" 8 | "github.com/getkin/kin-openapi/routers" 9 | ) 10 | 11 | // Router helps link http.Request.s and an OpenAPIv3 spec 12 | type Router struct { 13 | Routes []CustomRoute 14 | SchemaVersion string 15 | } 16 | 17 | type CustomRoute struct { 18 | Route *routers.Route 19 | Path string 20 | Method string 21 | ParametersNumberInPath int 22 | } 23 | 24 | // NewRouter creates a new router. 25 | // 26 | // If the given Swagger has servers, router will use them. 27 | // All operations of the Swagger will be added to the router. 28 | func NewRouter(doc *openapi3.T, validate bool) (*Router, error) { 29 | if validate { 30 | if err := validateOAS(doc); err != nil { 31 | return nil, fmt.Errorf("OpenAPI specification validation failed: %v", err) 32 | } 33 | } 34 | 35 | var router Router 36 | 37 | for path, pathItem := range doc.Paths.Map() { 38 | for method, operation := range pathItem.Operations() { 39 | method = strings.ToUpper(method) 40 | route := routers.Route{ 41 | Spec: doc, 42 | Path: path, 43 | PathItem: pathItem, 44 | Method: method, 45 | Operation: operation, 46 | } 47 | 48 | // count number of parameters in the path 49 | pathParamLength := 0 50 | if getOp := pathItem.GetOperation(route.Method); getOp != nil { 51 | for _, param := range getOp.Parameters { 52 | if param.Value.In == openapi3.ParameterInPath { 53 | pathParamLength += 1 54 | } 55 | } 56 | } 57 | 58 | // check common parameters 59 | if getOp := pathItem.Parameters; getOp != nil { 60 | for _, param := range getOp { 61 | if param.Value.In == openapi3.ParameterInPath { 62 | pathParamLength += 1 63 | } 64 | } 65 | } 66 | 67 | router.Routes = append(router.Routes, CustomRoute{ 68 | Route: &route, 69 | Path: path, 70 | Method: method, 71 | ParametersNumberInPath: pathParamLength, 72 | }) 73 | } 74 | } 75 | 76 | return &router, nil 77 | } 78 | 79 | // NewRouterDBLoader creates a new router based on DB OpenAPI loader. 80 | func NewRouterDBLoader(schemaVersion string, spec *openapi3.T) (*Router, error) { 81 | 82 | newRouter, err := NewRouter(spec, false) 83 | if err != nil { 84 | return nil, err 85 | } 86 | 87 | newRouter.SchemaVersion = schemaVersion 88 | 89 | return newRouter, nil 90 | } 91 | -------------------------------------------------------------------------------- /internal/platform/oauth2/jwt.go: -------------------------------------------------------------------------------- 1 | package oauth2 2 | 3 | import ( 4 | "context" 5 | "crypto/rsa" 6 | "fmt" 7 | "strings" 8 | 9 | "github.com/golang-jwt/jwt/v5" 10 | "github.com/pkg/errors" 11 | "github.com/rs/zerolog" 12 | 13 | "github.com/wallarm/api-firewall/internal/config" 14 | ) 15 | 16 | type JWT struct { 17 | Cfg *config.Oauth 18 | Logger zerolog.Logger 19 | PubKey *rsa.PublicKey 20 | SecretKey []byte 21 | } 22 | 23 | func (j *JWT) Validate(ctx context.Context, tokenWithBearer string, scopes []string) error { 24 | 25 | tokenString := strings.TrimPrefix(tokenWithBearer, "Bearer ") 26 | 27 | type MyCustomClaims struct { 28 | Scope string `json:"scope"` 29 | jwt.RegisteredClaims 30 | } 31 | 32 | token, err := jwt.ParseWithClaims(tokenString, &MyCustomClaims{}, func(token *jwt.Token) (any, error) { 33 | 34 | switch j.Cfg.JWT.SignatureAlgorithm { 35 | case "RS256", "RS384", "RS512": 36 | if _, ok := token.Method.(*jwt.SigningMethodRSA); !ok { 37 | return nil, errors.New("unknown signing method") 38 | } 39 | return j.PubKey, nil 40 | case "HS256", "HS384", "HS512": 41 | if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok { 42 | return nil, errors.New("unknown signing method") 43 | } 44 | return j.SecretKey, nil 45 | } 46 | 47 | return nil, errors.New("unknown signing method") 48 | }) 49 | 50 | if err != nil { 51 | return fmt.Errorf("oauth2 token invalid: %s", err) 52 | } 53 | 54 | claims, ok := token.Claims.(*MyCustomClaims) 55 | if ok && token.Valid { 56 | j.Logger.Debug().Msgf("%v %v", claims.Scope, claims.RegisteredClaims.ExpiresAt) 57 | } else { 58 | return errors.New("oauth2 token invalid") 59 | } 60 | 61 | scopesInToken := strings.Split(strings.ToLower(claims.Scope), " ") 62 | 63 | for _, scope := range scopes { 64 | scopeFound := false 65 | for _, scopeInToken := range scopesInToken { 66 | if strings.EqualFold(scope, scopeInToken) { 67 | scopeFound = true 68 | break 69 | } 70 | } 71 | if !scopeFound { 72 | return errors.New("token doesn't contain a necessary scope") 73 | } 74 | } 75 | 76 | return nil 77 | } 78 | -------------------------------------------------------------------------------- /internal/platform/oauth2/oauth2.go: -------------------------------------------------------------------------------- 1 | package oauth2 2 | 3 | import ( 4 | "context" 5 | ) 6 | 7 | type OAuth2 interface { 8 | Validate(ctx context.Context, tokenWithBearer string, scopes []string) error 9 | } 10 | -------------------------------------------------------------------------------- /internal/platform/proxy/dnscache_mock.go: -------------------------------------------------------------------------------- 1 | // Code generated by MockGen. DO NOT EDIT. 2 | // Source: ./internal/platform/proxy/dnscache.go 3 | 4 | // Package proxy is a generated GoMock package. 5 | package proxy 6 | 7 | import ( 8 | context "context" 9 | net "net" 10 | reflect "reflect" 11 | 12 | gomock "github.com/golang/mock/gomock" 13 | ) 14 | 15 | // MockDNSCache is a mock of DNSCache interface. 16 | type MockDNSCache struct { 17 | ctrl *gomock.Controller 18 | recorder *MockDNSCacheMockRecorder 19 | } 20 | 21 | // MockDNSCacheMockRecorder is the mock recorder for MockDNSCache. 22 | type MockDNSCacheMockRecorder struct { 23 | mock *MockDNSCache 24 | } 25 | 26 | // NewMockDNSCache creates a new mock instance. 27 | func NewMockDNSCache(ctrl *gomock.Controller) *MockDNSCache { 28 | mock := &MockDNSCache{ctrl: ctrl} 29 | mock.recorder = &MockDNSCacheMockRecorder{mock} 30 | return mock 31 | } 32 | 33 | // EXPECT returns an object that allows the caller to indicate expected use. 34 | func (m *MockDNSCache) EXPECT() *MockDNSCacheMockRecorder { 35 | return m.recorder 36 | } 37 | 38 | // LookupIPAddr mocks base method. 39 | func (m *MockDNSCache) LookupIPAddr(arg0 context.Context, arg1 string) ([]net.IPAddr, error) { 40 | m.ctrl.T.Helper() 41 | ret := m.ctrl.Call(m, "LookupIPAddr", arg0, arg1) 42 | ret0, _ := ret[0].([]net.IPAddr) 43 | ret1, _ := ret[1].(error) 44 | return ret0, ret1 45 | } 46 | 47 | // LookupIPAddr indicates an expected call of LookupIPAddr. 48 | func (mr *MockDNSCacheMockRecorder) LookupIPAddr(arg0, arg1 interface{}) *gomock.Call { 49 | mr.mock.ctrl.T.Helper() 50 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "LookupIPAddr", reflect.TypeOf((*MockDNSCache)(nil).LookupIPAddr), arg0, arg1) 51 | } 52 | 53 | // Refresh mocks base method. 54 | func (m *MockDNSCache) Refresh() { 55 | m.ctrl.T.Helper() 56 | m.ctrl.Call(m, "Refresh") 57 | } 58 | 59 | // Refresh indicates an expected call of Refresh. 60 | func (mr *MockDNSCacheMockRecorder) Refresh() *gomock.Call { 61 | mr.mock.ctrl.T.Helper() 62 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Refresh", reflect.TypeOf((*MockDNSCache)(nil).Refresh)) 63 | } 64 | 65 | // Stop mocks base method. 66 | func (m *MockDNSCache) Stop() { 67 | m.ctrl.T.Helper() 68 | m.ctrl.Call(m, "Stop") 69 | } 70 | 71 | // Stop indicates an expected call of Stop. 72 | func (mr *MockDNSCacheMockRecorder) Stop() *gomock.Call { 73 | mr.mock.ctrl.T.Helper() 74 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Stop", reflect.TypeOf((*MockDNSCache)(nil).Stop)) 75 | } 76 | -------------------------------------------------------------------------------- /internal/platform/proxy/proxy.go: -------------------------------------------------------------------------------- 1 | package proxy 2 | 3 | import ( 4 | "github.com/valyala/fasthttp" 5 | "github.com/wallarm/api-firewall/internal/platform/web" 6 | ) 7 | 8 | // Perform function proxies the request to the backend server 9 | func Perform(ctx *fasthttp.RequestCtx, proxyPool Pool, customHostHeader string) error { 10 | 11 | client, ip, err := proxyPool.Get() 12 | if err != nil { 13 | return err 14 | } 15 | defer proxyPool.Put(ip, client) 16 | 17 | if customHostHeader != "" { 18 | ctx.Request.Header.SetHost(customHostHeader) 19 | ctx.Request.URI().SetHost(customHostHeader) 20 | } 21 | 22 | if err := client.Do(&ctx.Request, &ctx.Response); err != nil { 23 | // request proxy has been failed 24 | ctx.SetUserValue(web.RequestProxyFailed, true) 25 | 26 | switch err { 27 | case fasthttp.ErrDialTimeout: 28 | if err := web.RespondError(ctx, fasthttp.StatusGatewayTimeout, ""); err != nil { 29 | return err 30 | } 31 | case fasthttp.ErrNoFreeConns: 32 | if err := web.RespondError(ctx, fasthttp.StatusServiceUnavailable, ""); err != nil { 33 | return err 34 | } 35 | default: 36 | if err := web.RespondError(ctx, fasthttp.StatusBadGateway, ""); err != nil { 37 | return err 38 | } 39 | } 40 | 41 | // The error has been handled so we can stop propagating it 42 | return err 43 | } 44 | 45 | return nil 46 | } 47 | -------------------------------------------------------------------------------- /internal/platform/proxy/wsClient_mock.go: -------------------------------------------------------------------------------- 1 | // Code generated by MockGen. DO NOT EDIT. 2 | // Source: ./internal/platform/proxy/wsClient.go 3 | 4 | // Package proxy is a generated GoMock package. 5 | package proxy 6 | 7 | import ( 8 | reflect "reflect" 9 | 10 | gomock "github.com/golang/mock/gomock" 11 | fasthttp "github.com/valyala/fasthttp" 12 | ) 13 | 14 | // MockWebSocketClient is a mock of WebSocketClient interface. 15 | type MockWebSocketClient struct { 16 | ctrl *gomock.Controller 17 | recorder *MockWebSocketClientMockRecorder 18 | } 19 | 20 | // MockWebSocketClientMockRecorder is the mock recorder for MockWebSocketClient. 21 | type MockWebSocketClientMockRecorder struct { 22 | mock *MockWebSocketClient 23 | } 24 | 25 | // NewMockWebSocketClient creates a new mock instance. 26 | func NewMockWebSocketClient(ctrl *gomock.Controller) *MockWebSocketClient { 27 | mock := &MockWebSocketClient{ctrl: ctrl} 28 | mock.recorder = &MockWebSocketClientMockRecorder{mock} 29 | return mock 30 | } 31 | 32 | // EXPECT returns an object that allows the caller to indicate expected use. 33 | func (m *MockWebSocketClient) EXPECT() *MockWebSocketClientMockRecorder { 34 | return m.recorder 35 | } 36 | 37 | // GetConn mocks base method. 38 | func (m *MockWebSocketClient) GetConn(ctx *fasthttp.RequestCtx) (*FastHTTPWebSocketConn, error) { 39 | m.ctrl.T.Helper() 40 | ret := m.ctrl.Call(m, "GetConn", ctx) 41 | ret0, _ := ret[0].(*FastHTTPWebSocketConn) 42 | ret1, _ := ret[1].(error) 43 | return ret0, ret1 44 | } 45 | 46 | // GetConn indicates an expected call of GetConn. 47 | func (mr *MockWebSocketClientMockRecorder) GetConn(ctx interface{}) *gomock.Call { 48 | mr.mock.ctrl.T.Helper() 49 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetConn", reflect.TypeOf((*MockWebSocketClient)(nil).GetConn), ctx) 50 | } 51 | -------------------------------------------------------------------------------- /internal/platform/router/LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2015-present Peter Kieltyka (https://github.com/pkieltyka), Google Inc. 2 | 3 | MIT License 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of 6 | this software and associated documentation files (the "Software"), to deal in 7 | the Software without restriction, including without limitation the rights to 8 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of 9 | the Software, and to permit persons to whom the Software is furnished to do so, 10 | 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, FITNESS 17 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 18 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 19 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 20 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /internal/platform/router/chi.go: -------------------------------------------------------------------------------- 1 | package router 2 | 3 | // NewRouter returns a new Mux object that implements the Router interface. 4 | func NewRouter() *Mux { 5 | return NewMux() 6 | } 7 | 8 | // Router consisting of the core routing methods used by chi's Mux, 9 | // using only the standard net/http. 10 | type Router interface { 11 | Routes 12 | 13 | // AddEndpoint adds routes for `pattern` that matches 14 | // the `method` HTTP method. 15 | AddEndpoint(method, pattern string, handler Handler) error 16 | } 17 | 18 | // Routes interface adds two methods for router traversal, which is also 19 | // used by the `docgen` subpackage to generation documentation for Routers. 20 | type Routes interface { 21 | // Find searches the routing tree for a handler that matches 22 | // the method/path - similar to routing a http request, but without 23 | // executing the handler thereafter. 24 | Find(rctx *Context, method, path string) Handler 25 | 26 | // FindWithActions searches the routing tree for a handler and actions that matches 27 | // the method/path - similar to routing a http request, but without 28 | // executing the handler thereafter. 29 | FindWithActions(rctx *Context, method, path string) (Handler, *Actions) 30 | } 31 | -------------------------------------------------------------------------------- /internal/platform/router/context_test.go: -------------------------------------------------------------------------------- 1 | package router 2 | 3 | import "testing" 4 | 5 | // TestRoutePattern tests correct in-the-middle wildcard removals. 6 | // If user organizes a router like this: 7 | // 8 | // (router.go) 9 | // 10 | // r.Route("/v1", func(r chi.Router) { 11 | // r.Mount("/resources", resourcesController{}.Router()) 12 | // } 13 | // 14 | // (resources_controller.go) 15 | // 16 | // r.Route("/", func(r chi.Router) { 17 | // r.Get("/{resource_id}", getResource()) 18 | // // other routes... 19 | // } 20 | // 21 | // This test checks how the route pattern is calculated 22 | // "/v1/resources/{resource_id}" (right) 23 | // "/v1/resources/*/{resource_id}" (wrong) 24 | func TestRoutePattern(t *testing.T) { 25 | routePatterns := []string{ 26 | "/v1/*", 27 | "/resources/*", 28 | "/{resource_id}", 29 | } 30 | 31 | x := &Context{ 32 | RoutePatterns: routePatterns, 33 | } 34 | 35 | if p := x.RoutePattern(); p != "/v1/resources/{resource_id}" { 36 | t.Fatal("unexpected route pattern: " + p) 37 | } 38 | 39 | x.RoutePatterns = []string{ 40 | "/v1/*", 41 | "/resources/*", 42 | // Additional wildcard, depending on the router structure of the user 43 | "/*", 44 | "/{resource_id}", 45 | } 46 | 47 | // Correctly removes in-the-middle wildcards instead of "/v1/resources/*/{resource_id}" 48 | if p := x.RoutePattern(); p != "/v1/resources/{resource_id}" { 49 | t.Fatal("unexpected route pattern: " + p) 50 | } 51 | 52 | x.RoutePatterns = []string{ 53 | "/v1/*", 54 | "/resources/*", 55 | // Even with many wildcards 56 | "/*", 57 | "/*", 58 | "/*", 59 | "/{resource_id}/*", // Keeping trailing wildcard 60 | } 61 | 62 | // Correctly removes in-the-middle wildcards instead of "/v1/resources/*/*/{resource_id}/*" 63 | if p := x.RoutePattern(); p != "/v1/resources/{resource_id}/*" { 64 | t.Fatal("unexpected route pattern: " + p) 65 | } 66 | 67 | x.RoutePatterns = []string{ 68 | "/v1/*", 69 | "/resources/*", 70 | // And respects asterisks as part of the paths 71 | "/*special_path/*", 72 | "/with_asterisks*/*", 73 | "/{resource_id}", 74 | } 75 | 76 | // Correctly removes in-the-middle wildcards instead of "/v1/resourcesspecial_path/with_asterisks{resource_id}" 77 | if p := x.RoutePattern(); p != "/v1/resources/*special_path/with_asterisks*/{resource_id}" { 78 | t.Fatal("unexpected route pattern: " + p) 79 | } 80 | 81 | // Testing for the root route pattern 82 | x.RoutePatterns = []string{"/"} 83 | // It should just return "/" as the pattern 84 | if p := x.RoutePattern(); p != "/" { 85 | t.Fatal("unexpected route pattern for root: " + p) 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /internal/platform/router/handler.go: -------------------------------------------------------------------------------- 1 | package router 2 | 3 | import "github.com/valyala/fasthttp" 4 | 5 | // A Handler is a type that handles an http request within our own little mini 6 | // framework. 7 | type Handler func(ctx *fasthttp.RequestCtx) error 8 | -------------------------------------------------------------------------------- /internal/platform/storage/file.go: -------------------------------------------------------------------------------- 1 | package storage 2 | 3 | import ( 4 | "bytes" 5 | "crypto/sha256" 6 | "errors" 7 | "io" 8 | "log" 9 | "os" 10 | "sync" 11 | "time" 12 | 13 | "github.com/getkin/kin-openapi/openapi3" 14 | _ "github.com/mattn/go-sqlite3" 15 | "github.com/savsgio/gotils/strconv" 16 | 17 | "github.com/wallarm/api-firewall/internal/platform/loader" 18 | ) 19 | 20 | const ( 21 | currentFileVersion = 0 22 | undefinedSchemaID = 0 23 | ) 24 | 25 | type File struct { 26 | isReady bool 27 | RawSpec string 28 | LastUpdate time.Time 29 | OpenAPISpec *openapi3.T 30 | lock *sync.RWMutex 31 | } 32 | 33 | var _ DBOpenAPILoader = (*File)(nil) 34 | 35 | func NewOpenAPIFromFile(OASPath string) (DBOpenAPILoader, error) { 36 | 37 | fileObj := File{ 38 | lock: &sync.RWMutex{}, 39 | isReady: false, 40 | } 41 | 42 | var err error 43 | fileObj.isReady, err = fileObj.Load(OASPath) 44 | 45 | return &fileObj, err 46 | } 47 | 48 | func getChecksum(oasFile []byte) []byte { 49 | h := sha256.New() 50 | return h.Sum(oasFile) 51 | } 52 | 53 | func (f *File) Load(OASPath string) (bool, error) { 54 | 55 | var parsingErrs error 56 | var isReady bool 57 | 58 | // check if file exists 59 | if _, err := os.Stat(OASPath); errors.Is(err, os.ErrNotExist) { 60 | return isReady, err 61 | } 62 | 63 | fSpec, err := os.Open(OASPath) 64 | if err != nil { 65 | log.Fatal(err) 66 | } 67 | defer fSpec.Close() 68 | 69 | rawSpec, err := io.ReadAll(fSpec) 70 | if err != nil { 71 | return isReady, err 72 | } 73 | 74 | parsedSpec, err := loader.ParseOAS(rawSpec, "", undefinedSchemaID) 75 | if err != nil { 76 | parsingErrs = errors.Join(parsingErrs, err) 77 | } 78 | 79 | f.lock.Lock() 80 | defer f.lock.Unlock() 81 | 82 | f.RawSpec = strconv.B2S(rawSpec) 83 | f.OpenAPISpec = parsedSpec 84 | isReady = true 85 | 86 | return isReady, parsingErrs 87 | } 88 | 89 | func (s *File) Specification(_ int) *openapi3.T { 90 | s.lock.RLock() 91 | defer s.lock.RUnlock() 92 | 93 | return s.OpenAPISpec 94 | } 95 | 96 | func (s *File) SpecificationRaw(_ int) any { 97 | s.lock.RLock() 98 | defer s.lock.RUnlock() 99 | 100 | return s.RawSpec 101 | } 102 | 103 | func (s *File) SpecificationRawContent(_ int) []byte { 104 | s.lock.RLock() 105 | defer s.lock.RUnlock() 106 | 107 | return getSpecBytes(s.RawSpec) 108 | } 109 | 110 | func (s *File) SpecificationVersion(_ int) string { 111 | return "" 112 | } 113 | 114 | func (s *File) IsLoaded(_ int) bool { 115 | s.lock.RLock() 116 | defer s.lock.RUnlock() 117 | 118 | return s.OpenAPISpec != nil 119 | } 120 | 121 | func (s *File) SchemaIDs() []int { 122 | return []int{} 123 | } 124 | 125 | func (s *File) IsReady() bool { 126 | s.lock.RLock() 127 | defer s.lock.RUnlock() 128 | 129 | return s.isReady 130 | } 131 | 132 | func (s *File) Version() int { 133 | return currentFileVersion 134 | } 135 | 136 | func (s *File) AfterLoad(_ string) error { 137 | return nil 138 | } 139 | 140 | func (s *File) ShouldUpdate(newStorage DBOpenAPILoader) bool { 141 | 142 | beforeUpdateSpecs := getChecksum(s.SpecificationRawContent(undefinedSchemaID)) 143 | afterUpdateSpecs := getChecksum(newStorage.SpecificationRawContent(undefinedSchemaID)) 144 | 145 | return !bytes.Equal(beforeUpdateSpecs, afterUpdateSpecs) 146 | } 147 | -------------------------------------------------------------------------------- /internal/platform/storage/storage.go: -------------------------------------------------------------------------------- 1 | package storage 2 | 3 | import ( 4 | "bytes" 5 | "net/url" 6 | 7 | "github.com/getkin/kin-openapi/openapi3" 8 | _ "github.com/mattn/go-sqlite3" 9 | "github.com/pkg/errors" 10 | "github.com/wallarm/api-firewall/internal/config" 11 | ) 12 | 13 | type DBOpenAPILoader interface { 14 | Load(dbStoragePath string) (bool, error) 15 | AfterLoad(dbStoragePath string) error 16 | SpecificationRaw(schemaID int) any 17 | SpecificationRawContent(schemaID int) []byte 18 | SpecificationVersion(schemaID int) string 19 | Specification(schemaID int) *openapi3.T 20 | IsLoaded(schemaID int) bool 21 | SchemaIDs() []int 22 | IsReady() bool 23 | ShouldUpdate(newStorage DBOpenAPILoader) bool 24 | Version() int 25 | } 26 | 27 | func getSpecBytes(spec string) []byte { 28 | return bytes.NewBufferString(spec).Bytes() 29 | } 30 | 31 | // NewOpenAPIDB loads OAS specs from the database and returns the struct with the parsed specs WITH database entry status update 32 | func NewOpenAPIDB(dbStoragePath string, version int) (DBOpenAPILoader, error) { 33 | return loadOpenAPIDBVersion(dbStoragePath, version, true) 34 | } 35 | 36 | // LoadOpenAPIDB loads OAS specs from the database and returns the struct with the parsed specs WITHOUT database entry status update 37 | func LoadOpenAPIDB(dbStoragePath string, version int) (DBOpenAPILoader, error) { 38 | return loadOpenAPIDBVersion(dbStoragePath, version, false) 39 | } 40 | 41 | // loadOpenAPIDBVersion chooses the right database schema version and then loads OAS specs from the database 42 | func loadOpenAPIDBVersion(dbStoragePath string, version int, execAfterLoad bool) (DBOpenAPILoader, error) { 43 | switch version { 44 | case 1: 45 | return NewOpenAPIDBV1(dbStoragePath) 46 | case 2: 47 | return NewOpenAPIDBV2(dbStoragePath, execAfterLoad) 48 | default: 49 | // first trying to load db v2 50 | storageV2, errV2 := NewOpenAPIDBV2(dbStoragePath, execAfterLoad) 51 | if errV2 == nil { 52 | return storageV2, errV2 53 | } 54 | 55 | return NewOpenAPIDBV1(dbStoragePath) 56 | 57 | } 58 | } 59 | 60 | // NewOpenAPIFromFileOrURL loads OAS specs from the file or URL and returns the struct with the parsed specs 61 | func NewOpenAPIFromFileOrURL(specPath string, header *config.CustomHeader) (DBOpenAPILoader, error) { 62 | 63 | var specStorage DBOpenAPILoader 64 | var err error 65 | 66 | // try to parse path or URL 67 | apiSpecURL, err := url.ParseRequestURI(specPath) 68 | 69 | // can't parse string as URL. Try to load spec from file 70 | if err != nil || apiSpecURL == nil || apiSpecURL.Scheme == "" { 71 | specStorage, err = NewOpenAPIFromFile(specPath) 72 | if err != nil { 73 | return nil, errors.Wrap(err, "loading OpenAPI specification from file") 74 | } 75 | 76 | return specStorage, err 77 | } 78 | 79 | // try to load spec from 80 | specStorage, err = NewOpenAPIFromURL(specPath, header) 81 | if err != nil { 82 | return nil, errors.Wrap(err, "loading OpenAPI specification from URL") 83 | } 84 | 85 | return specStorage, err 86 | } 87 | -------------------------------------------------------------------------------- /internal/platform/storage/updater/updater.go: -------------------------------------------------------------------------------- 1 | package updater 2 | 3 | import ( 4 | "github.com/wallarm/api-firewall/internal/platform/router" 5 | "github.com/wallarm/api-firewall/internal/platform/storage" 6 | ) 7 | 8 | type Updater interface { 9 | Start() error 10 | Shutdown() error 11 | Load() (storage.DBOpenAPILoader, error) 12 | Find(rctx *router.Context, schemaID int, method, path string) (router.Handler, error) 13 | } 14 | -------------------------------------------------------------------------------- /internal/platform/storage/updater/updater_mock.go: -------------------------------------------------------------------------------- 1 | // Code generated by MockGen. DO NOT EDIT. 2 | // Source: ./internal/platform/storage/updater/updater.go 3 | 4 | // Package updater is a generated GoMock package. 5 | package updater 6 | 7 | import ( 8 | reflect "reflect" 9 | 10 | gomock "github.com/golang/mock/gomock" 11 | router "github.com/wallarm/api-firewall/internal/platform/router" 12 | storage "github.com/wallarm/api-firewall/internal/platform/storage" 13 | ) 14 | 15 | // MockUpdater is a mock of Updater interface. 16 | type MockUpdater struct { 17 | ctrl *gomock.Controller 18 | recorder *MockUpdaterMockRecorder 19 | } 20 | 21 | // MockUpdaterMockRecorder is the mock recorder for MockUpdater. 22 | type MockUpdaterMockRecorder struct { 23 | mock *MockUpdater 24 | } 25 | 26 | // NewMockUpdater creates a new mock instance. 27 | func NewMockUpdater(ctrl *gomock.Controller) *MockUpdater { 28 | mock := &MockUpdater{ctrl: ctrl} 29 | mock.recorder = &MockUpdaterMockRecorder{mock} 30 | return mock 31 | } 32 | 33 | // EXPECT returns an object that allows the caller to indicate expected use. 34 | func (m *MockUpdater) EXPECT() *MockUpdaterMockRecorder { 35 | return m.recorder 36 | } 37 | 38 | // Find mocks base method. 39 | func (m *MockUpdater) Find(rctx *router.Context, schemaID int, method, path string) (router.Handler, error) { 40 | m.ctrl.T.Helper() 41 | ret := m.ctrl.Call(m, "Find", rctx, schemaID, method, path) 42 | ret0, _ := ret[0].(router.Handler) 43 | ret1, _ := ret[1].(error) 44 | return ret0, ret1 45 | } 46 | 47 | // Find indicates an expected call of Find. 48 | func (mr *MockUpdaterMockRecorder) Find(rctx, schemaID, method, path interface{}) *gomock.Call { 49 | mr.mock.ctrl.T.Helper() 50 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Find", reflect.TypeOf((*MockUpdater)(nil).Find), rctx, schemaID, method, path) 51 | } 52 | 53 | // Load mocks base method. 54 | func (m *MockUpdater) Load() (storage.DBOpenAPILoader, error) { 55 | m.ctrl.T.Helper() 56 | ret := m.ctrl.Call(m, "Load") 57 | ret0, _ := ret[0].(storage.DBOpenAPILoader) 58 | ret1, _ := ret[1].(error) 59 | return ret0, ret1 60 | } 61 | 62 | // Load indicates an expected call of Load. 63 | func (mr *MockUpdaterMockRecorder) Load() *gomock.Call { 64 | mr.mock.ctrl.T.Helper() 65 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Load", reflect.TypeOf((*MockUpdater)(nil).Load)) 66 | } 67 | 68 | // Shutdown mocks base method. 69 | func (m *MockUpdater) Shutdown() error { 70 | m.ctrl.T.Helper() 71 | ret := m.ctrl.Call(m, "Shutdown") 72 | ret0, _ := ret[0].(error) 73 | return ret0 74 | } 75 | 76 | // Shutdown indicates an expected call of Shutdown. 77 | func (mr *MockUpdaterMockRecorder) Shutdown() *gomock.Call { 78 | mr.mock.ctrl.T.Helper() 79 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Shutdown", reflect.TypeOf((*MockUpdater)(nil).Shutdown)) 80 | } 81 | 82 | // Start mocks base method. 83 | func (m *MockUpdater) Start() error { 84 | m.ctrl.T.Helper() 85 | ret := m.ctrl.Call(m, "Start") 86 | ret0, _ := ret[0].(error) 87 | return ret0 88 | } 89 | 90 | // Start indicates an expected call of Start. 91 | func (mr *MockUpdaterMockRecorder) Start() *gomock.Call { 92 | mr.mock.ctrl.T.Helper() 93 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Start", reflect.TypeOf((*MockUpdater)(nil).Start)) 94 | } 95 | -------------------------------------------------------------------------------- /internal/platform/validator/internal.go: -------------------------------------------------------------------------------- 1 | package validator 2 | 3 | import ( 4 | "encoding/json" 5 | "reflect" 6 | "strings" 7 | 8 | "github.com/valyala/fastjson" 9 | ) 10 | 11 | // parseMediaType func parses content type and returns media type and suffix 12 | func parseMediaType(contentType string) (string, string) { 13 | 14 | var mtSubtype, suffix string 15 | mediaType := contentType 16 | 17 | if i := strings.IndexByte(mediaType, ';'); i >= 0 { 18 | mediaType = strings.TrimSpace(mediaType[:i]) 19 | } 20 | 21 | if i := strings.IndexByte(mediaType, '/'); i >= 0 { 22 | mtSubtype = mediaType[i+1:] 23 | } 24 | 25 | if i := strings.LastIndexByte(mtSubtype, '+'); i >= 0 { 26 | suffix = mtSubtype[i:] 27 | } 28 | 29 | return mediaType, suffix 30 | } 31 | 32 | func isNilValue(value any) bool { 33 | if value == nil { 34 | return true 35 | } 36 | switch reflect.TypeOf(value).Kind() { 37 | case reflect.Ptr, reflect.Map, reflect.Array, reflect.Chan, reflect.Slice: 38 | return reflect.ValueOf(value).IsNil() 39 | } 40 | return false 41 | } 42 | 43 | func convertToMap(v *fastjson.Value) any { 44 | switch v.Type() { 45 | case fastjson.TypeObject: 46 | m := make(map[string]any) 47 | v.GetObject().Visit(func(k []byte, v *fastjson.Value) { 48 | m[string(k)] = convertToMap(v) 49 | }) 50 | return m 51 | case fastjson.TypeArray: 52 | var a []any 53 | for _, v := range v.GetArray() { 54 | a = append(a, convertToMap(v)) 55 | } 56 | return a 57 | case fastjson.TypeNumber: 58 | return json.Number(v.String()) 59 | case fastjson.TypeString: 60 | return string(v.GetStringBytes()) 61 | case fastjson.TypeTrue, fastjson.TypeFalse: 62 | return v.GetBool() 63 | default: 64 | return nil 65 | } 66 | } 67 | 68 | // Contains returns true if v is present in the elems slice, false otherwise 69 | func Contains[T comparable](elems []T, v T) bool { 70 | for _, s := range elems { 71 | if v == s { 72 | return true 73 | } 74 | } 75 | return false 76 | } 77 | -------------------------------------------------------------------------------- /internal/platform/validator/internal_test.go: -------------------------------------------------------------------------------- 1 | package validator 2 | 3 | import ( 4 | "fmt" 5 | "testing" 6 | 7 | "github.com/stretchr/testify/require" 8 | ) 9 | 10 | func Test_parseMediaType(t *testing.T) { 11 | type response struct { 12 | mediaType string 13 | suffix string 14 | } 15 | tests := []struct { 16 | name string 17 | contentType string 18 | response response 19 | }{ 20 | { 21 | name: "json", 22 | contentType: "application/json", 23 | response: response{ 24 | mediaType: "application/json", 25 | suffix: "", 26 | }, 27 | }, 28 | { 29 | name: "json with charset", 30 | contentType: "application/json; charset=utf-8", 31 | response: response{ 32 | mediaType: "application/json", 33 | suffix: "", 34 | }, 35 | }, 36 | { 37 | name: "json with suffix", 38 | contentType: "application/vnd.mycompany.myapp.v2+json", 39 | response: response{ 40 | mediaType: "application/vnd.mycompany.myapp.v2+json", 41 | suffix: "+json", 42 | }, 43 | }, 44 | { 45 | name: "xml", 46 | contentType: "application/xml", 47 | response: response{ 48 | mediaType: "application/xml", 49 | suffix: "", 50 | }, 51 | }, 52 | { 53 | name: "xml with charset", 54 | contentType: "application/xml; charset=utf-8", 55 | response: response{ 56 | mediaType: "application/xml", 57 | suffix: "", 58 | }, 59 | }, 60 | { 61 | name: "xml with suffix", 62 | contentType: "application/vnd.openstreetmap.data+xml", 63 | response: response{ 64 | mediaType: "application/vnd.openstreetmap.data+xml", 65 | suffix: "+xml", 66 | }, 67 | }, 68 | { 69 | name: "json with suffix 2", 70 | contentType: "application/test+myapp+json; charset=utf8", 71 | response: response{ 72 | mediaType: "application/test+myapp+json", 73 | suffix: "+json", 74 | }, 75 | }, 76 | } 77 | for _, tt := range tests { 78 | t.Run(tt.name, func(t *testing.T) { 79 | 80 | mt, suffix := parseMediaType(tt.contentType) 81 | if tt.response.mediaType != mt { 82 | require.Error(t, fmt.Errorf("test name - %s: content type is invalid. Expected: %s. Got: %s", tt.name, tt.response.mediaType, mt)) 83 | } 84 | 85 | if tt.response.suffix != suffix { 86 | require.Error(t, fmt.Errorf("test name - %s: content type suffix is invalid. Expected: %s. Got: %s", tt.name, tt.response.suffix, suffix)) 87 | } 88 | }) 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /internal/platform/validator/issue436_test.go: -------------------------------------------------------------------------------- 1 | package validator 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "io" 7 | "mime/multipart" 8 | "net/http" 9 | "net/textproto" 10 | "strings" 11 | 12 | "github.com/getkin/kin-openapi/openapi3" 13 | "github.com/getkin/kin-openapi/openapi3filter" 14 | "github.com/getkin/kin-openapi/routers/gorillamux" 15 | ) 16 | 17 | func Example_validateMultipartFormData() { 18 | const spec = ` 19 | openapi: 3.0.0 20 | info: 21 | title: 'Validator' 22 | version: 0.0.1 23 | paths: 24 | /test: 25 | post: 26 | requestBody: 27 | required: true 28 | content: 29 | multipart/form-data: 30 | schema: 31 | type: object 32 | required: 33 | - file 34 | properties: 35 | file: 36 | type: string 37 | format: binary 38 | categories: 39 | type: array 40 | items: 41 | $ref: "#/components/schemas/Category" 42 | responses: 43 | '200': 44 | description: Created 45 | 46 | components: 47 | schemas: 48 | Category: 49 | type: object 50 | properties: 51 | name: 52 | type: string 53 | required: 54 | - name 55 | ` 56 | 57 | loader := openapi3.NewLoader() 58 | doc, err := loader.LoadFromData([]byte(spec)) 59 | if err != nil { 60 | panic(err) 61 | } 62 | if err = doc.Validate(loader.Context); err != nil { 63 | panic(err) 64 | } 65 | 66 | router, err := gorillamux.NewRouter(doc) 67 | if err != nil { 68 | panic(err) 69 | } 70 | 71 | body := &bytes.Buffer{} 72 | writer := multipart.NewWriter(body) 73 | 74 | { // Add a single "categories" item as part data 75 | h := make(textproto.MIMEHeader) 76 | h.Set("Content-Disposition", `form-data; name="categories"`) 77 | h.Set("Content-Type", "application/json") 78 | fw, err := writer.CreatePart(h) 79 | if err != nil { 80 | panic(err) 81 | } 82 | if _, err = io.Copy(fw, strings.NewReader(`{"name": "foo"}`)); err != nil { 83 | panic(err) 84 | } 85 | } 86 | 87 | { // Add a single "categories" item as part data, again 88 | h := make(textproto.MIMEHeader) 89 | h.Set("Content-Disposition", `form-data; name="categories"`) 90 | h.Set("Content-Type", "application/json") 91 | fw, err := writer.CreatePart(h) 92 | if err != nil { 93 | panic(err) 94 | } 95 | if _, err = io.Copy(fw, strings.NewReader(`{"name": "bar"}`)); err != nil { 96 | panic(err) 97 | } 98 | } 99 | 100 | { // Add file data 101 | fw, err := writer.CreateFormFile("file", "hello.txt") 102 | if err != nil { 103 | panic(err) 104 | } 105 | if _, err = io.Copy(fw, strings.NewReader("hello")); err != nil { 106 | panic(err) 107 | } 108 | } 109 | 110 | writer.Close() 111 | 112 | req, err := http.NewRequest(http.MethodPost, "/test", bytes.NewReader(body.Bytes())) 113 | if err != nil { 114 | panic(err) 115 | } 116 | req.Header.Set("Content-Type", writer.FormDataContentType()) 117 | 118 | route, pathParams, err := router.FindRoute(req) 119 | if err != nil { 120 | panic(err) 121 | } 122 | 123 | if err = openapi3filter.ValidateRequestBody( 124 | context.Background(), 125 | &openapi3filter.RequestValidationInput{ 126 | Request: req, 127 | PathParams: pathParams, 128 | Route: route, 129 | }, 130 | route.Operation.RequestBody.Value, 131 | ); err != nil { 132 | panic(err) 133 | } 134 | // Output: 135 | } 136 | -------------------------------------------------------------------------------- /internal/platform/validator/issue624_test.go: -------------------------------------------------------------------------------- 1 | package validator 2 | 3 | import ( 4 | "net/http" 5 | "testing" 6 | 7 | "github.com/getkin/kin-openapi/openapi3" 8 | "github.com/getkin/kin-openapi/openapi3filter" 9 | "github.com/getkin/kin-openapi/routers/gorillamux" 10 | "github.com/stretchr/testify/require" 11 | "github.com/valyala/fastjson" 12 | ) 13 | 14 | func TestIssue624(t *testing.T) { 15 | loader := openapi3.NewLoader() 16 | ctx := loader.Context 17 | spec := ` 18 | openapi: 3.0.0 19 | info: 20 | version: 1.0.0 21 | title: Sample API 22 | paths: 23 | /items: 24 | get: 25 | description: Returns a list of stuff 26 | parameters: 27 | - description: "test non object" 28 | explode: true 29 | style: form 30 | in: query 31 | name: test 32 | required: false 33 | content: 34 | application/json: 35 | schema: 36 | anyOf: 37 | - type: string 38 | - type: integer 39 | responses: 40 | '200': 41 | description: Successful response 42 | `[1:] 43 | 44 | doc, err := loader.LoadFromData([]byte(spec)) 45 | require.NoError(t, err) 46 | 47 | err = doc.Validate(ctx) 48 | require.NoError(t, err) 49 | 50 | router, err := gorillamux.NewRouter(doc) 51 | require.NoError(t, err) 52 | 53 | for _, testcase := range []string{`test1`, `test[1`} { 54 | t.Run(testcase, func(t *testing.T) { 55 | httpReq, err := http.NewRequest(http.MethodGet, `/items?test=`+testcase, nil) 56 | require.NoError(t, err) 57 | 58 | route, pathParams, err := router.FindRoute(httpReq) 59 | require.NoError(t, err) 60 | 61 | requestValidationInput := &openapi3filter.RequestValidationInput{ 62 | Request: httpReq, 63 | PathParams: pathParams, 64 | Route: route, 65 | } 66 | 67 | jsonParser := &fastjson.Parser{} 68 | 69 | err = ValidateRequest(ctx, requestValidationInput, jsonParser) 70 | require.NoError(t, err) 71 | }) 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /internal/platform/validator/issue639_test.go: -------------------------------------------------------------------------------- 1 | package validator 2 | 3 | import ( 4 | "encoding/json" 5 | "github.com/getkin/kin-openapi/openapi3filter" 6 | "github.com/valyala/fastjson" 7 | "io" 8 | "net/http" 9 | "strings" 10 | "testing" 11 | 12 | "github.com/stretchr/testify/require" 13 | 14 | "github.com/getkin/kin-openapi/openapi3" 15 | "github.com/getkin/kin-openapi/routers/gorillamux" 16 | ) 17 | 18 | func TestIssue639(t *testing.T) { 19 | loader := openapi3.NewLoader() 20 | ctx := loader.Context 21 | spec := ` 22 | openapi: 3.0.0 23 | info: 24 | version: 1.0.0 25 | title: Sample API 26 | paths: 27 | /items: 28 | put: 29 | requestBody: 30 | content: 31 | application/json: 32 | schema: 33 | properties: 34 | testWithdefault: 35 | default: false 36 | type: boolean 37 | testNoDefault: 38 | type: boolean 39 | type: object 40 | responses: 41 | '200': 42 | description: Successful response 43 | `[1:] 44 | 45 | doc, err := loader.LoadFromData([]byte(spec)) 46 | require.NoError(t, err) 47 | 48 | err = doc.Validate(ctx) 49 | require.NoError(t, err) 50 | 51 | router, err := gorillamux.NewRouter(doc) 52 | require.NoError(t, err) 53 | 54 | tests := []struct { 55 | name string 56 | options *openapi3filter.Options 57 | expectedDefaultVal any 58 | }{ 59 | { 60 | name: "no defaults are added to requests", 61 | options: &openapi3filter.Options{ 62 | SkipSettingDefaults: true, 63 | }, 64 | expectedDefaultVal: nil, 65 | }, 66 | 67 | { 68 | name: "defaults are added to requests", 69 | expectedDefaultVal: false, 70 | }, 71 | } 72 | 73 | for _, testcase := range tests { 74 | t.Run(testcase.name, func(t *testing.T) { 75 | body := "{\"testNoDefault\": true}" 76 | httpReq, err := http.NewRequest(http.MethodPut, "/items", strings.NewReader(body)) 77 | require.NoError(t, err) 78 | httpReq.Header.Set("Content-Type", "application/json") 79 | require.NoError(t, err) 80 | 81 | route, pathParams, err := router.FindRoute(httpReq) 82 | require.NoError(t, err) 83 | 84 | requestValidationInput := &openapi3filter.RequestValidationInput{ 85 | Request: httpReq, 86 | PathParams: pathParams, 87 | Route: route, 88 | Options: testcase.options, 89 | } 90 | 91 | jsonParser := &fastjson.Parser{} 92 | 93 | err = ValidateRequest(ctx, requestValidationInput, jsonParser) 94 | require.NoError(t, err) 95 | bodyAfterValidation, err := io.ReadAll(httpReq.Body) 96 | require.NoError(t, err) 97 | 98 | raw := map[string]any{} 99 | err = json.Unmarshal(bodyAfterValidation, &raw) 100 | require.NoError(t, err) 101 | require.Equal(t, testcase.expectedDefaultVal, 102 | raw["testWithdefault"], "default value must not be included") 103 | }) 104 | } 105 | } 106 | -------------------------------------------------------------------------------- /internal/platform/validator/issue641_test.go: -------------------------------------------------------------------------------- 1 | package validator 2 | 3 | import ( 4 | "net/http" 5 | "strings" 6 | "testing" 7 | 8 | "github.com/getkin/kin-openapi/openapi3" 9 | "github.com/getkin/kin-openapi/openapi3filter" 10 | "github.com/getkin/kin-openapi/routers/gorillamux" 11 | "github.com/stretchr/testify/require" 12 | "github.com/valyala/fastjson" 13 | ) 14 | 15 | func TestIssue641(t *testing.T) { 16 | 17 | anyOfSpec := ` 18 | openapi: 3.0.0 19 | info: 20 | version: 1.0.0 21 | title: Sample API 22 | paths: 23 | /items: 24 | get: 25 | description: Returns a list of stuff 26 | parameters: 27 | - description: test object 28 | explode: false 29 | in: query 30 | name: test 31 | required: false 32 | schema: 33 | anyOf: 34 | - pattern: "^[0-9]{1,4}$" 35 | - pattern: "^[0-9]{1,4}$" 36 | type: string 37 | responses: 38 | '200': 39 | description: Successful response 40 | `[1:] 41 | 42 | allOfSpec := strings.ReplaceAll(anyOfSpec, "anyOf", "allOf") 43 | 44 | tests := []struct { 45 | name string 46 | spec string 47 | req string 48 | errStr string 49 | }{ 50 | 51 | { 52 | name: "success anyof pattern", 53 | spec: anyOfSpec, 54 | req: "/items?test=51", 55 | }, 56 | { 57 | name: "failed anyof pattern", 58 | spec: anyOfSpec, 59 | req: "/items?test=999999", 60 | errStr: `parameter "test" in query has an error: doesn't match any schema from "anyOf"`, 61 | }, 62 | 63 | { 64 | name: "success allof pattern", 65 | spec: allOfSpec, 66 | req: `/items?test=51`, 67 | }, 68 | { 69 | name: "failed allof pattern", 70 | spec: allOfSpec, 71 | req: `/items?test=999999`, 72 | errStr: `parameter "test" in query has an error: string doesn't match the regular expression "^[0-9]{1,4}$"`, 73 | }, 74 | } 75 | 76 | for _, testcase := range tests { 77 | t.Run(testcase.name, func(t *testing.T) { 78 | loader := openapi3.NewLoader() 79 | ctx := loader.Context 80 | 81 | doc, err := loader.LoadFromData([]byte(testcase.spec)) 82 | require.NoError(t, err) 83 | 84 | err = doc.Validate(ctx) 85 | require.NoError(t, err) 86 | 87 | router, err := gorillamux.NewRouter(doc) 88 | require.NoError(t, err) 89 | httpReq, err := http.NewRequest(http.MethodGet, testcase.req, nil) 90 | require.NoError(t, err) 91 | 92 | route, pathParams, err := router.FindRoute(httpReq) 93 | require.NoError(t, err) 94 | 95 | requestValidationInput := &openapi3filter.RequestValidationInput{ 96 | Request: httpReq, 97 | PathParams: pathParams, 98 | Route: route, 99 | } 100 | 101 | jsonParser := &fastjson.Parser{} 102 | err = ValidateRequest(ctx, requestValidationInput, jsonParser) 103 | if testcase.errStr == "" { 104 | require.NoError(t, err) 105 | } else { 106 | require.ErrorContains(t, err, testcase.errStr) 107 | } 108 | }, 109 | ) 110 | } 111 | } 112 | -------------------------------------------------------------------------------- /internal/platform/validator/issue707_test.go: -------------------------------------------------------------------------------- 1 | package validator 2 | 3 | import ( 4 | "net/http" 5 | "strings" 6 | "testing" 7 | 8 | "github.com/getkin/kin-openapi/openapi3" 9 | "github.com/getkin/kin-openapi/openapi3filter" 10 | "github.com/getkin/kin-openapi/routers/gorillamux" 11 | "github.com/stretchr/testify/require" 12 | "github.com/valyala/fastjson" 13 | ) 14 | 15 | func TestIssue707(t *testing.T) { 16 | loader := openapi3.NewLoader() 17 | ctx := loader.Context 18 | spec := ` 19 | openapi: 3.0.0 20 | info: 21 | version: 1.0.0 22 | title: Sample API 23 | paths: 24 | /items: 25 | get: 26 | description: Returns a list of stuff 27 | parameters: 28 | - description: parameter with a default value 29 | explode: true 30 | in: query 31 | name: param-with-default 32 | schema: 33 | default: 124 34 | type: integer 35 | required: false 36 | responses: 37 | '200': 38 | description: Successful response 39 | `[1:] 40 | 41 | doc, err := loader.LoadFromData([]byte(spec)) 42 | require.NoError(t, err) 43 | 44 | err = doc.Validate(ctx) 45 | require.NoError(t, err) 46 | 47 | router, err := gorillamux.NewRouter(doc) 48 | require.NoError(t, err) 49 | 50 | tests := []struct { 51 | name string 52 | options *openapi3filter.Options 53 | expectedQuery string 54 | }{ 55 | { 56 | name: "no defaults are added to requests parameters", 57 | options: &openapi3filter.Options{ 58 | SkipSettingDefaults: true, 59 | }, 60 | expectedQuery: "", 61 | }, 62 | 63 | { 64 | name: "defaults are added to requests", 65 | expectedQuery: "param-with-default=124", 66 | }, 67 | } 68 | 69 | for _, testcase := range tests { 70 | t.Run(testcase.name, func(t *testing.T) { 71 | httpReq, err := http.NewRequest(http.MethodGet, "/items", strings.NewReader("")) 72 | require.NoError(t, err) 73 | 74 | route, pathParams, err := router.FindRoute(httpReq) 75 | require.NoError(t, err) 76 | 77 | requestValidationInput := &openapi3filter.RequestValidationInput{ 78 | Request: httpReq, 79 | PathParams: pathParams, 80 | Route: route, 81 | Options: testcase.options, 82 | } 83 | 84 | jsonParser := &fastjson.Parser{} 85 | err = ValidateRequest(ctx, requestValidationInput, jsonParser) 86 | require.NoError(t, err) 87 | 88 | require.NoError(t, err) 89 | require.Equal(t, testcase.expectedQuery, 90 | httpReq.URL.RawQuery, "default value must not be included") 91 | }) 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /internal/platform/validator/issue733_test.go: -------------------------------------------------------------------------------- 1 | package validator 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "encoding/json" 7 | "math" 8 | "math/big" 9 | "net/http" 10 | "testing" 11 | 12 | "github.com/getkin/kin-openapi/openapi3" 13 | "github.com/getkin/kin-openapi/openapi3filter" 14 | "github.com/getkin/kin-openapi/routers/gorillamux" 15 | "github.com/stretchr/testify/assert" 16 | "github.com/stretchr/testify/require" 17 | "github.com/valyala/fastjson" 18 | ) 19 | 20 | func TestIntMax(t *testing.T) { 21 | spec := ` 22 | openapi: 3.0.0 23 | info: 24 | version: 1.0.0 25 | title: test large integer value 26 | paths: 27 | /test: 28 | post: 29 | requestBody: 30 | content: 31 | application/json: 32 | schema: 33 | type: object 34 | properties: 35 | testInteger: 36 | type: integer 37 | format: int64 38 | testDefault: 39 | type: boolean 40 | default: false 41 | responses: 42 | '200': 43 | description: Successful response 44 | `[1:] 45 | 46 | loader := openapi3.NewLoader() 47 | 48 | doc, err := loader.LoadFromData([]byte(spec)) 49 | require.NoError(t, err) 50 | 51 | err = doc.Validate(loader.Context) 52 | require.NoError(t, err) 53 | 54 | router, err := gorillamux.NewRouter(doc) 55 | require.NoError(t, err) 56 | 57 | testOne := func(value *big.Int, pass bool) { 58 | valueString := value.String() 59 | 60 | req, err := http.NewRequest(http.MethodPost, "/test", bytes.NewReader([]byte(`{"testInteger":`+valueString+`}`))) 61 | require.NoError(t, err) 62 | req.Header.Set("Content-Type", "application/json") 63 | 64 | route, pathParams, err := router.FindRoute(req) 65 | require.NoError(t, err) 66 | 67 | jsonParser := &fastjson.Parser{} 68 | err = ValidateRequest( 69 | context.Background(), 70 | &openapi3filter.RequestValidationInput{ 71 | Request: req, 72 | PathParams: pathParams, 73 | Route: route, 74 | }, 75 | jsonParser) 76 | if pass { 77 | require.NoError(t, err) 78 | 79 | dec := json.NewDecoder(req.Body) 80 | dec.UseNumber() 81 | var jsonAfter map[string]any 82 | err = dec.Decode(&jsonAfter) 83 | require.NoError(t, err) 84 | 85 | valueAfter := jsonAfter["testInteger"] 86 | require.IsType(t, json.Number(""), valueAfter) 87 | assert.Equal(t, valueString, string(valueAfter.(json.Number))) 88 | } else { 89 | if assert.Error(t, err) { 90 | var serr *openapi3.SchemaError 91 | if assert.ErrorAs(t, err, &serr) { 92 | assert.Equal(t, "number must be an int64", serr.Reason) 93 | } 94 | } 95 | } 96 | } 97 | 98 | bigMaxInt64 := big.NewInt(math.MaxInt64) 99 | bigMaxInt64Plus1 := new(big.Int).Add(bigMaxInt64, big.NewInt(1)) 100 | bigMinInt64 := big.NewInt(math.MinInt64) 101 | bigMinInt64Minus1 := new(big.Int).Sub(bigMinInt64, big.NewInt(1)) 102 | 103 | testOne(bigMaxInt64, true) 104 | // XXX not yet fixed 105 | // testOne(bigMaxInt64Plus1, false) 106 | testOne(bigMaxInt64Plus1, true) 107 | testOne(bigMinInt64, true) 108 | // XXX not yet fixed 109 | // testOne(bigMinInt64Minus1, false) 110 | testOne(bigMinInt64Minus1, true) 111 | } 112 | -------------------------------------------------------------------------------- /internal/platform/validator/issue884_test.go: -------------------------------------------------------------------------------- 1 | package validator 2 | 3 | import ( 4 | "github.com/getkin/kin-openapi/openapi3filter" 5 | "github.com/valyala/fastjson" 6 | "net/http" 7 | "net/url" 8 | "testing" 9 | 10 | "github.com/stretchr/testify/assert" 11 | "github.com/stretchr/testify/require" 12 | 13 | "github.com/getkin/kin-openapi/openapi3" 14 | "github.com/getkin/kin-openapi/routers/gorillamux" 15 | ) 16 | 17 | func TestIssue884(t *testing.T) { 18 | loader := openapi3.NewLoader() 19 | ctx := loader.Context 20 | spec := ` 21 | openapi: 3.0.0 22 | info: 23 | version: 1.0.0 24 | title: Sample API 25 | components: 26 | schemas: 27 | TaskSortEnum: 28 | enum: 29 | - createdAt 30 | - -createdAt 31 | - updatedAt 32 | - -updatedAt 33 | paths: 34 | /tasks: 35 | get: 36 | operationId: ListTask 37 | parameters: 38 | - in: query 39 | name: withDefault 40 | schema: 41 | allOf: 42 | - $ref: '#/components/schemas/TaskSortEnum' 43 | - default: -createdAt 44 | - in: query 45 | name: withoutDefault 46 | schema: 47 | allOf: 48 | - $ref: '#/components/schemas/TaskSortEnum' 49 | - in: query 50 | name: withManyDefaults 51 | schema: 52 | allOf: 53 | - default: -updatedAt 54 | - $ref: '#/components/schemas/TaskSortEnum' 55 | - default: -createdAt 56 | responses: 57 | '200': 58 | description: Successful response 59 | `[1:] 60 | 61 | doc, err := loader.LoadFromData([]byte(spec)) 62 | require.NoError(t, err) 63 | 64 | err = doc.Validate(ctx) 65 | require.NoError(t, err) 66 | 67 | router, err := gorillamux.NewRouter(doc) 68 | require.NoError(t, err) 69 | 70 | tests := []struct { 71 | name string 72 | options *openapi3filter.Options 73 | expectedQuery url.Values 74 | }{ 75 | { 76 | name: "no defaults are added to requests", 77 | options: &openapi3filter.Options{ 78 | SkipSettingDefaults: true, 79 | }, 80 | expectedQuery: url.Values{}, 81 | }, 82 | 83 | { 84 | name: "defaults are added to requests", 85 | expectedQuery: url.Values{ 86 | "withDefault": []string{"-createdAt"}, 87 | "withManyDefaults": []string{"-updatedAt"}, // first default is win 88 | }, 89 | }, 90 | } 91 | 92 | for _, testcase := range tests { 93 | t.Run(testcase.name, func(t *testing.T) { 94 | httpReq, err := http.NewRequest(http.MethodGet, "/tasks", nil) 95 | require.NoError(t, err) 96 | httpReq.Header.Set("Content-Type", "application/json") 97 | require.NoError(t, err) 98 | 99 | route, pathParams, err := router.FindRoute(httpReq) 100 | require.NoError(t, err) 101 | 102 | jsonParser := &fastjson.Parser{} 103 | requestValidationInput := &openapi3filter.RequestValidationInput{ 104 | Request: httpReq, 105 | PathParams: pathParams, 106 | Route: route, 107 | Options: testcase.options, 108 | } 109 | err = ValidateRequest(ctx, requestValidationInput, jsonParser) 110 | require.NoError(t, err) 111 | 112 | q := httpReq.URL.Query() 113 | assert.Equal(t, testcase.expectedQuery, q) 114 | }) 115 | } 116 | } 117 | -------------------------------------------------------------------------------- /internal/platform/validator/req_resp_encoder.go: -------------------------------------------------------------------------------- 1 | package validator 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | ) 7 | 8 | func encodeBody(body any, mediaType string) ([]byte, error) { 9 | encoder, ok := bodyEncoders[mediaType] 10 | if !ok { 11 | return nil, &ParseError{ 12 | Kind: KindUnsupportedFormat, 13 | Reason: fmt.Sprintf("%s %q", prefixUnsupportedCT, mediaType), 14 | } 15 | } 16 | return encoder(body) 17 | } 18 | 19 | type BodyEncoder func(body any) ([]byte, error) 20 | 21 | var bodyEncoders = map[string]BodyEncoder{ 22 | "application/json": json.Marshal, 23 | } 24 | 25 | func RegisterBodyEncoder(contentType string, encoder BodyEncoder) { 26 | if contentType == "" { 27 | panic("contentType is empty") 28 | } 29 | if encoder == nil { 30 | panic("encoder is not defined") 31 | } 32 | bodyEncoders[contentType] = encoder 33 | } 34 | 35 | // This call is not thread-safe: body encoders should not be created/destroyed by multiple goroutines. 36 | func UnregisterBodyEncoder(contentType string) { 37 | if contentType == "" { 38 | panic("contentType is empty") 39 | } 40 | delete(bodyEncoders, contentType) 41 | } 42 | 43 | // RegisteredBodyEncoder returns the registered body encoder for the given content type. 44 | // 45 | // If no encoder was registered for the given content type, nil is returned. 46 | // This call is not thread-safe: body encoders should not be created/destroyed by multiple goroutines. 47 | func RegisteredBodyEncoder(contentType string) BodyEncoder { 48 | return bodyEncoders[contentType] 49 | } 50 | -------------------------------------------------------------------------------- /internal/platform/validator/req_resp_encoder_test.go: -------------------------------------------------------------------------------- 1 | package validator 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | "strings" 7 | "testing" 8 | 9 | "github.com/stretchr/testify/require" 10 | ) 11 | 12 | func TestRegisterAndUnregisterBodyEncoder(t *testing.T) { 13 | var encoder BodyEncoder 14 | encoder = func(body any) (data []byte, err error) { 15 | return []byte(strings.Join(body.([]string), ",")), nil 16 | } 17 | contentType := "text/csv" 18 | h := make(http.Header) 19 | h.Set(headerCT, contentType) 20 | 21 | originalEncoder := RegisteredBodyEncoder(contentType) 22 | require.Nil(t, originalEncoder) 23 | 24 | RegisterBodyEncoder(contentType, encoder) 25 | require.Equal(t, fmt.Sprintf("%v", encoder), fmt.Sprintf("%v", RegisteredBodyEncoder(contentType))) 26 | 27 | body := []string{"foo", "bar"} 28 | got, err := encodeBody(body, contentType) 29 | 30 | require.NoError(t, err) 31 | require.Equal(t, []byte("foo,bar"), got) 32 | 33 | UnregisterBodyEncoder(contentType) 34 | 35 | originalEncoder = RegisteredBodyEncoder(contentType) 36 | require.Nil(t, originalEncoder) 37 | 38 | _, err = encodeBody(body, contentType) 39 | require.Equal(t, &ParseError{ 40 | Kind: KindUnsupportedFormat, 41 | Reason: prefixUnsupportedCT + ` "text/csv"`, 42 | }, err) 43 | } 44 | -------------------------------------------------------------------------------- /internal/platform/web/adaptor.go: -------------------------------------------------------------------------------- 1 | package web 2 | 3 | import ( 4 | "io" 5 | "net/http" 6 | 7 | "github.com/valyala/fasthttp" 8 | "github.com/valyala/fasthttp/fasthttpadaptor" 9 | "github.com/wallarm/api-firewall/internal/platform/router" 10 | ) 11 | 12 | // NewFastHTTPHandler wraps net/http handler to fasthttp request handler, 13 | // so it can be passed to fasthttp server. 14 | // 15 | // While this function may be used for easy switching from net/http to fasthttp, 16 | // it has the following drawbacks comparing to using manually written fasthttp 17 | // request handler: 18 | // 19 | // - A lot of useful functionality provided by fasthttp is missing 20 | // from net/http handler. 21 | // - net/http -> fasthttp handler conversion has some overhead, 22 | // so the returned handler will be always slower than manually written 23 | // fasthttp handler. 24 | // 25 | // So it is advisable using this function only for quick net/http -> fasthttp 26 | // switching. Then manually convert net/http handlers to fasthttp handlers 27 | // according to https://github.com/valyala/fasthttp#switching-from-nethttp-to-fasthttp . 28 | func NewFastHTTPHandler(h http.Handler, isPlayground bool) router.Handler { 29 | return func(ctx *fasthttp.RequestCtx) error { 30 | var r http.Request 31 | if err := fasthttpadaptor.ConvertRequest(ctx, &r, true); err != nil { 32 | ctx.Logger().Printf("cannot parse requestURI %q: %v", r.RequestURI, err) 33 | ctx.Error("Internal Server Error", fasthttp.StatusInternalServerError) 34 | return err 35 | } 36 | 37 | w := netHTTPResponseWriter{w: ctx.Response.BodyWriter()} 38 | h.ServeHTTP(&w, r.WithContext(ctx)) 39 | 40 | ctx.SetStatusCode(w.StatusCode()) 41 | haveContentType := false 42 | for k, vv := range w.Header() { 43 | if k == fasthttp.HeaderContentType { 44 | haveContentType = true 45 | } 46 | 47 | for _, v := range vv { 48 | ctx.Response.Header.Add(k, v) 49 | } 50 | } 51 | if !haveContentType { 52 | // From net/http.ResponseWriter.Write: 53 | // If the Header does not contain a Content-Type line, Write adds a Content-Type set 54 | // to the result of passing the initial 512 bytes of written data to DetectContentType. 55 | l := 512 56 | b := ctx.Response.Body() 57 | if len(b) < 512 { 58 | l = len(b) 59 | } 60 | ctx.Response.Header.Set(fasthttp.HeaderContentType, http.DetectContentType(b[:l])) 61 | } 62 | 63 | // mark requests to the playground 64 | if isPlayground { 65 | ctx.SetUserValue(Playground, struct{}{}) 66 | } 67 | 68 | return nil 69 | } 70 | } 71 | 72 | type netHTTPResponseWriter struct { 73 | statusCode int 74 | h http.Header 75 | w io.Writer 76 | } 77 | 78 | func (w *netHTTPResponseWriter) StatusCode() int { 79 | if w.statusCode == 0 { 80 | return http.StatusOK 81 | } 82 | return w.statusCode 83 | } 84 | 85 | func (w *netHTTPResponseWriter) Header() http.Header { 86 | if w.h == nil { 87 | w.h = make(http.Header) 88 | } 89 | return w.h 90 | } 91 | 92 | func (w *netHTTPResponseWriter) WriteHeader(statusCode int) { 93 | w.statusCode = statusCode 94 | } 95 | 96 | func (w *netHTTPResponseWriter) Write(p []byte) (int, error) { 97 | return w.w.Write(p) 98 | } 99 | 100 | func (w *netHTTPResponseWriter) Flush() {} 101 | -------------------------------------------------------------------------------- /internal/platform/web/errors.go: -------------------------------------------------------------------------------- 1 | package web 2 | 3 | import ( 4 | "github.com/pkg/errors" 5 | ) 6 | 7 | // FieldError is used to indicate an error with a specific request field. 8 | type FieldError struct { 9 | Field string `json:"field"` 10 | Error string `json:"error"` 11 | } 12 | 13 | // ErrorResponse is the form used for API responses from failures in the API. 14 | type ErrorResponse struct { 15 | Error string `json:"error"` 16 | Fields []FieldError `json:"fields,omitempty"` 17 | } 18 | 19 | // Error is used to pass an error during the request through the 20 | // application with web specific context. 21 | type Error struct { 22 | Err error 23 | Status int 24 | Fields []FieldError 25 | } 26 | 27 | // NewRequestError wraps a provided error with an HTTP status code. This 28 | // function should be used when handlers encounter expected errors. 29 | func NewRequestError(err error, status int) error { 30 | return &Error{err, status, nil} 31 | } 32 | 33 | // Error implements the error interface. It uses the default message of the 34 | // wrapped error. This is what will be shown in the services' logs. 35 | func (err *Error) Error() string { 36 | return err.Err.Error() 37 | } 38 | 39 | // shutdown is a type used to help with the graceful termination of the service. 40 | type shutdown struct { 41 | Message string 42 | } 43 | 44 | // Error is the implementation of the error interface. 45 | func (s *shutdown) Error() string { 46 | return s.Message 47 | } 48 | 49 | // NewShutdownError returns an error that causes the framework to signal 50 | // a graceful shutdown. 51 | func NewShutdownError(message string) error { 52 | return &shutdown{message} 53 | } 54 | 55 | // IsShutdown checks to see if the shutdown error is contained 56 | // in the specified error value. 57 | func IsShutdown(err error) bool { 58 | if _, ok := errors.Cause(err).(*shutdown); ok { 59 | return true 60 | } 61 | return false 62 | } 63 | -------------------------------------------------------------------------------- /internal/platform/web/middleware.go: -------------------------------------------------------------------------------- 1 | package web 2 | 3 | import "github.com/wallarm/api-firewall/internal/platform/router" 4 | 5 | // Middleware is a function designed to run some code before and/or after 6 | // another Handler. It is designed to remove boilerplate or other concerns not 7 | // direct to any given Handler. 8 | type Middleware func(router.Handler) router.Handler 9 | 10 | // WrapMiddleware creates a new handler by wrapping middleware around a final 11 | // handler. The middlewares' Handlers will be executed by requests in the order 12 | // they are provided. 13 | func WrapMiddleware(mw []Middleware, handler router.Handler) router.Handler { 14 | 15 | // Loop backwards through the middleware invoking each one. Replace the 16 | // handler with the new wrapped handler. Looping backwards ensures that the 17 | // first middleware of the slice is the first to be executed by requests. 18 | for i := len(mw) - 1; i >= 0; i-- { 19 | h := mw[i] 20 | if h != nil { 21 | handler = h(handler) 22 | } 23 | } 24 | 25 | return handler 26 | } 27 | -------------------------------------------------------------------------------- /internal/platform/web/trace.go: -------------------------------------------------------------------------------- 1 | package web 2 | 3 | import ( 4 | "strings" 5 | 6 | "github.com/rs/zerolog" 7 | "github.com/savsgio/gotils/strconv" 8 | "github.com/valyala/fasthttp" 9 | ) 10 | 11 | const responseBodyOmitted = "" 12 | 13 | func LogRequestResponseAtTraceLevel(ctx *fasthttp.RequestCtx, logger zerolog.Logger) { 14 | 15 | if logger.GetLevel() != zerolog.TraceLevel { 16 | return 17 | } 18 | 19 | var strBuild strings.Builder 20 | ctx.Request.Header.VisitAll(func(key, value []byte) { 21 | strBuild.WriteString(strconv.B2S(key)) 22 | strBuild.WriteString(":") 23 | strBuild.WriteString(strconv.B2S(value)) 24 | strBuild.WriteString("\n") 25 | }) 26 | 27 | logger.Trace(). 28 | Str("request_id", ctx.UserValue(RequestID).(string)). 29 | Str("method", strconv.B2S(ctx.Request.Header.Method())). 30 | Str("uri", strconv.B2S(ctx.Request.URI().RequestURI())). 31 | Str("headers", strings.ReplaceAll(strBuild.String(), "\n", `\r\n`)). 32 | Str("body", strings.ReplaceAll(strconv.B2S(ctx.Request.Body()), "\n", `\r\n`)). 33 | Str("client_address", ctx.RemoteAddr().String()). 34 | Msg("new request") 35 | 36 | strBuild.Reset() 37 | ctx.Response.Header.VisitAll(func(key, value []byte) { 38 | strBuild.WriteString(strconv.B2S(key)) 39 | strBuild.WriteString(":") 40 | strBuild.WriteString(strconv.B2S(value)) 41 | strBuild.WriteString("\n") 42 | }) 43 | 44 | isPlayground := false 45 | if ctx.UserValue(Playground) != nil { 46 | isPlayground = true 47 | } 48 | 49 | body := responseBodyOmitted 50 | if !isPlayground { 51 | body = string(ctx.Response.Body()) 52 | } 53 | 54 | logger.Trace(). 55 | Str("request_id", ctx.UserValue(RequestID).(string)). 56 | Int("status_code", ctx.Response.StatusCode()). 57 | Str("headers", strings.ReplaceAll(strBuild.String(), "\n", `\r\n`)). 58 | Str("body", strings.ReplaceAll(body, "\n", `\r\n`)). 59 | Str("client_address", ctx.RemoteAddr().String()). 60 | Msg("response from the API-Firewall") 61 | } 62 | -------------------------------------------------------------------------------- /internal/version/version.go: -------------------------------------------------------------------------------- 1 | package version 2 | 3 | const ( 4 | ProjectName = "Wallarm API-Firewall" 5 | Namespace = "apifw" 6 | ) 7 | 8 | var Version = "develop" 9 | -------------------------------------------------------------------------------- /pkg/APIMode/handler.go: -------------------------------------------------------------------------------- 1 | package APIMode 2 | 3 | import ( 4 | "fmt" 5 | strconv2 "strconv" 6 | 7 | "github.com/valyala/fasthttp" 8 | "github.com/valyala/fastjson" 9 | "github.com/wallarm/api-firewall/internal/platform/loader" 10 | apiMode "github.com/wallarm/api-firewall/internal/platform/validator" 11 | "github.com/wallarm/api-firewall/pkg/APIMode/validator" 12 | ) 13 | 14 | type RequestValidator struct { 15 | CustomRoute *loader.CustomRoute 16 | OpenAPIRouter *loader.Router 17 | ParserPool *fastjson.ParserPool 18 | SchemaID int 19 | Options *Configuration 20 | } 21 | 22 | // APIModeHandler finds route in the OpenAPI spec and validates request 23 | func (rv *RequestValidator) APIModeHandler(ctx *fasthttp.RequestCtx) (err error) { 24 | 25 | // handle panic 26 | defer func() { 27 | if r := recover(); r != nil { 28 | 29 | switch e := r.(type) { 30 | case error: 31 | err = e 32 | default: 33 | err = fmt.Errorf("panic: %v", r) 34 | } 35 | 36 | return 37 | } 38 | }() 39 | 40 | keyValidationErrors := strconv2.Itoa(rv.SchemaID) + validator.APIModePostfixValidationErrors 41 | keyStatusCode := strconv2.Itoa(rv.SchemaID) + validator.APIModePostfixStatusCode 42 | 43 | // Route not found 44 | if rv.CustomRoute == nil { 45 | ctx.SetUserValue(keyValidationErrors, []*validator.ValidationError{{Message: validator.ErrMethodAndPathNotFound.Error(), Code: validator.ErrCodeMethodAndPathNotFound, SchemaID: &rv.SchemaID}}) 46 | ctx.SetUserValue(keyStatusCode, fasthttp.StatusForbidden) 47 | return nil 48 | } 49 | 50 | validationErrors, err := apiMode.APIModeValidateRequest(ctx, rv.ParserPool, rv.CustomRoute, rv.Options.UnknownParametersDetection) 51 | if err != nil { 52 | return err 53 | } 54 | 55 | // Respond 403 with errors 56 | if len(validationErrors) > 0 { 57 | // add schema IDs to the validation error messages 58 | for _, r := range validationErrors { 59 | r.SchemaID = &rv.SchemaID 60 | r.SchemaVersion = rv.OpenAPIRouter.SchemaVersion 61 | } 62 | 63 | ctx.SetUserValue(keyValidationErrors, validationErrors) 64 | ctx.SetUserValue(keyStatusCode, fasthttp.StatusForbidden) 65 | return nil 66 | } 67 | 68 | // request successfully validated 69 | ctx.SetUserValue(keyStatusCode, fasthttp.StatusOK) 70 | return nil 71 | } 72 | -------------------------------------------------------------------------------- /pkg/APIMode/helpers.go: -------------------------------------------------------------------------------- 1 | package APIMode 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "net/url" 7 | 8 | "github.com/valyala/fastjson" 9 | 10 | "github.com/wallarm/api-firewall/internal/platform/loader" 11 | "github.com/wallarm/api-firewall/internal/platform/router" 12 | "github.com/wallarm/api-firewall/internal/platform/storage" 13 | "github.com/wallarm/api-firewall/pkg/APIMode/validator" 14 | ) 15 | 16 | // wrapOASpecErrs wraps errors by the following high level errors ErrSpecValidation, ErrSpecParsing, ErrSpecLoading 17 | func wrapOASpecErrs(err error) error { 18 | 19 | switch { 20 | case errors.Is(err, loader.ErrOASValidation): 21 | return fmt.Errorf("%w: %w", validator.ErrSpecValidation, err) 22 | case errors.Is(err, loader.ErrOASParsing): 23 | return fmt.Errorf("%w: %w", validator.ErrSpecParsing, err) 24 | } 25 | 26 | return fmt.Errorf("%w: %w", validator.ErrSpecLoading, err) 27 | } 28 | 29 | // getRouters function prepares router.Mux with the routes from OpenAPI specs 30 | func getRouters(specStorage storage.DBOpenAPILoader, parserPool *fastjson.ParserPool, options *Configuration) (map[int]*router.Mux, error) { 31 | 32 | // Init routers 33 | routers := make(map[int]*router.Mux) 34 | for _, schemaID := range specStorage.SchemaIDs() { 35 | routers[schemaID] = router.NewRouter() 36 | 37 | serverURLStr := "/" 38 | spec := specStorage.Specification(schemaID) 39 | servers := spec.Servers 40 | if servers != nil { 41 | var err error 42 | if serverURLStr, err = servers.BasePath(); err != nil { 43 | return nil, fmt.Errorf("getting server URL from OpenAPI specification with ID %d: %w", schemaID, err) 44 | } 45 | } 46 | 47 | serverURL, err := url.Parse(serverURLStr) 48 | if err != nil { 49 | return nil, fmt.Errorf("parsing server URL from OpenAPI specification with ID %d: %w", schemaID, err) 50 | } 51 | 52 | if serverURL.Path == "" { 53 | serverURL.Path = "/" 54 | } 55 | 56 | // get new router 57 | newSwagRouter, err := loader.NewRouterDBLoader(specStorage.SpecificationVersion(schemaID), specStorage.Specification(schemaID)) 58 | if err != nil { 59 | return nil, fmt.Errorf("new router creation failed for specification with ID %d: %w", schemaID, err) 60 | } 61 | 62 | for i := 0; i < len(newSwagRouter.Routes); i++ { 63 | 64 | s := RequestValidator{ 65 | CustomRoute: &newSwagRouter.Routes[i], 66 | ParserPool: parserPool, 67 | OpenAPIRouter: newSwagRouter, 68 | SchemaID: schemaID, 69 | Options: options, 70 | } 71 | updRoutePathEsc, err := url.JoinPath(serverURL.Path, newSwagRouter.Routes[i].Path) 72 | if err != nil { 73 | return nil, fmt.Errorf("join path error for route %s in specification with ID %d: %w", newSwagRouter.Routes[i].Path, schemaID, err) 74 | } 75 | 76 | updRoutePath, err := url.PathUnescape(updRoutePathEsc) 77 | if err != nil { 78 | return nil, fmt.Errorf("path unescape error for route %s in specification with ID %d: %w", newSwagRouter.Routes[i].Path, schemaID, err) 79 | } 80 | 81 | if err := routers[schemaID].AddEndpoint(newSwagRouter.Routes[i].Method, updRoutePath, s.APIModeHandler); err != nil { 82 | return nil, fmt.Errorf("the OAS endpoint registration failed: method %s, path %s: %w", newSwagRouter.Routes[i].Method, updRoutePath, err) 83 | } 84 | } 85 | } 86 | 87 | return routers, nil 88 | } 89 | -------------------------------------------------------------------------------- /pkg/APIMode/validator/errors.go: -------------------------------------------------------------------------------- 1 | package validator 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/pkg/errors" 7 | ) 8 | 9 | const ( 10 | ErrCodeMethodAndPathNotFound = "method_and_path_not_found" 11 | ErrCodeRequiredBodyMissed = "required_body_missed" 12 | ErrCodeRequiredBodyParseError = "required_body_parse_error" 13 | ErrCodeRequiredBodyParameterMissed = "required_body_parameter_missed" 14 | ErrCodeRequiredBodyParameterInvalidValue = "required_body_parameter_invalid_value" 15 | ErrCodeRequiredPathParameterMissed = "required_path_parameter_missed" 16 | ErrCodeRequiredPathParameterInvalidValue = "required_path_parameter_invalid_value" 17 | ErrCodeRequiredQueryParameterMissed = "required_query_parameter_missed" 18 | ErrCodeRequiredQueryParameterInvalidValue = "required_query_parameter_invalid_value" 19 | ErrCodeRequiredCookieParameterMissed = "required_cookie_parameter_missed" 20 | ErrCodeRequiredCookieParameterInvalidValue = "required_cookie_parameter_invalid_value" 21 | ErrCodeRequiredHeaderMissed = "required_header_missed" 22 | ErrCodeRequiredHeaderInvalidValue = "required_header_invalid_value" 23 | 24 | ErrCodeSecRequirementsFailed = "required_security_requirements_failed" 25 | 26 | ErrCodeUnknownParameterFound = "unknown_parameter_found" 27 | 28 | ErrCodeUnknownValidationError = "unknown_validation_error" 29 | ) 30 | 31 | var ( 32 | ErrMethodAndPathNotFound = errors.New("method and path are not found") 33 | ErrAuthHeaderMissed = errors.New("missing Authorization header") 34 | ErrAPITokenMissed = errors.New("missing API keys for authorization") 35 | ErrRequiredBodyIsMissing = errors.New("required body is missing") 36 | ErrMissedRequiredParameters = errors.New("required parameters missed") 37 | 38 | ErrSchemaNotFound = fmt.Errorf("schema not found") 39 | ErrRequestParsing = fmt.Errorf("request parsing error") 40 | ErrSpecParsing = fmt.Errorf("OpenAPI specification parsing error") 41 | ErrSpecValidation = fmt.Errorf("OpenAPI specification validator error") 42 | ErrSpecLoading = fmt.Errorf("OpenAPI specifications reading from database error") 43 | ErrHandlersInit = fmt.Errorf("handlers initialization error") 44 | ) 45 | -------------------------------------------------------------------------------- /pkg/APIMode/validator/response.go: -------------------------------------------------------------------------------- 1 | package validator 2 | 3 | import "math/rand" 4 | 5 | type FieldTypeError struct { 6 | Name string `json:"name"` 7 | ExpectedType string `json:"expected_type,omitempty"` 8 | Pattern string `json:"pattern,omitempty"` 9 | CurrentValue string `json:"current_value,omitempty"` 10 | } 11 | 12 | type ValidationError struct { 13 | Message string `json:"message"` 14 | Code string `json:"code"` 15 | SchemaVersion string `json:"schema_version,omitempty"` 16 | SchemaID *int `json:"schema_id"` 17 | Fields []string `json:"related_fields,omitempty"` 18 | FieldsDetails []FieldTypeError `json:"related_fields_details,omitempty"` 19 | } 20 | 21 | type ValidationResponseSummary struct { 22 | SchemaID *int `json:"schema_id"` 23 | StatusCode *int `json:"status_code"` 24 | } 25 | 26 | type ValidationResponse struct { 27 | Summary []*ValidationResponseSummary `json:"summary"` 28 | Errors []*ValidationError `json:"errors,omitempty"` 29 | } 30 | 31 | // SampleSlice function samples data in slice and responds by the subset of slice data 32 | func SampleSlice[T any](rawData []T, limit int) []T { 33 | if len(rawData) <= limit || limit == 0 { 34 | return rawData 35 | } 36 | 37 | indices := rand.Perm(len(rawData))[:limit] 38 | 39 | sampled := make([]T, limit) 40 | for i, idx := range indices { 41 | sampled[i] = rawData[idx] 42 | } 43 | return sampled 44 | } 45 | -------------------------------------------------------------------------------- /pkg/APIMode/validator/response_test.go: -------------------------------------------------------------------------------- 1 | package validator 2 | 3 | import ( 4 | "slices" 5 | "testing" 6 | ) 7 | 8 | func TestSampleSlice_SizeLessThanLimit(t *testing.T) { 9 | input := []int{1, 2, 3} 10 | n := 5 11 | 12 | result := SampleSlice(input, n) 13 | 14 | if len(result) != len(input) { 15 | t.Errorf("Expected %d elements, got %d", len(input), len(result)) 16 | } 17 | } 18 | 19 | func TestSampleSlice_SizeEqualToLimit(t *testing.T) { 20 | input := []int{1, 2, 3} 21 | n := 3 22 | 23 | result := SampleSlice(input, n) 24 | 25 | if len(result) != n { 26 | t.Errorf("Expected %d elements, got %d", n, len(result)) 27 | } 28 | } 29 | 30 | func TestSampleSlice_SizeGreaterThanLimit(t *testing.T) { 31 | input := []int{1, 2, 3, 4, 5} 32 | n := 3 33 | 34 | result := SampleSlice(input, n) 35 | 36 | if len(result) != n { 37 | t.Errorf("Expected %d elements, got %d", n, len(result)) 38 | } 39 | 40 | originalSet := make(map[int]bool) 41 | for _, v := range input { 42 | originalSet[v] = true 43 | } 44 | 45 | for _, v := range result { 46 | if !originalSet[v] { 47 | t.Errorf("Sample returned element not in original slice: %v", v) 48 | } 49 | } 50 | } 51 | 52 | func TestSampleSlice_NoDuplicates(t *testing.T) { 53 | input := []int{10, 20, 30, 40, 50} 54 | n := 5 55 | 56 | result := SampleSlice(input, n) 57 | 58 | seen := make(map[int]bool) 59 | for _, v := range result { 60 | if seen[v] { 61 | t.Errorf("Duplicate element found in sample: %v", v) 62 | } 63 | seen[v] = true 64 | } 65 | } 66 | 67 | func TestSampleSlice_NoLimit(t *testing.T) { 68 | input := []int{10, 20, 30, 40, 50} 69 | n := 0 70 | 71 | result := SampleSlice(input, n) 72 | 73 | if len(result) != len(input) { 74 | t.Errorf("Expected %d elements, got %d", n, len(result)) 75 | } 76 | 77 | if !slices.Equal(input, result) { 78 | t.Errorf("Expected %v, got %v", input, result) 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /pkg/APIMode/wallarm_apifw_empty.db: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wallarm/api-firewall/c0ce5660a109c4d921d07c9fd40d610962b2a7ee/pkg/APIMode/wallarm_apifw_empty.db -------------------------------------------------------------------------------- /pkg/APIMode/wallarm_apifw_invalid_db_schema.db: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wallarm/api-firewall/c0ce5660a109c4d921d07c9fd40d610962b2a7ee/pkg/APIMode/wallarm_apifw_invalid_db_schema.db -------------------------------------------------------------------------------- /pkg/APIMode/wallarm_apifw_invalid_spec.db: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wallarm/api-firewall/c0ce5660a109c4d921d07c9fd40d610962b2a7ee/pkg/APIMode/wallarm_apifw_invalid_spec.db -------------------------------------------------------------------------------- /pkg/APIMode/wallarm_apifw_spec_validation_failed.db: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wallarm/api-firewall/c0ce5660a109c4d921d07c9fd40d610962b2a7ee/pkg/APIMode/wallarm_apifw_spec_validation_failed.db -------------------------------------------------------------------------------- /pkg/APIMode/wallarm_apifw_test.db: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wallarm/api-firewall/c0ce5660a109c4d921d07c9fd40d610962b2a7ee/pkg/APIMode/wallarm_apifw_test.db -------------------------------------------------------------------------------- /resources/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wallarm/api-firewall/c0ce5660a109c4d921d07c9fd40d610962b2a7ee/resources/__init__.py -------------------------------------------------------------------------------- /resources/dev/wallarm_api.db: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wallarm/api-firewall/c0ce5660a109c4d921d07c9fd40d610962b2a7ee/resources/dev/wallarm_api.db -------------------------------------------------------------------------------- /resources/test/allowed.iplist.db: -------------------------------------------------------------------------------- 1 | 127.0.0.1 2 | 127.0.0.2 3 | 127.0.0.3 4 | 2001:0db8:11a3:09d7:1f34:8a2e:07a0:765d/128 5 | 2001:0db8:11a3:09d7:1f34:8a2e:07a0:7655 6 | 10.1.2.0/24 7 | -------------------------------------------------------------------------------- /resources/test/database/wallarm_api.db: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wallarm/api-firewall/c0ce5660a109c4d921d07c9fd40d610962b2a7ee/resources/test/database/wallarm_api.db -------------------------------------------------------------------------------- /resources/test/database/wallarm_api_v2.db: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wallarm/api-firewall/c0ce5660a109c4d921d07c9fd40d610962b2a7ee/resources/test/database/wallarm_api_v2.db -------------------------------------------------------------------------------- /resources/test/docker-compose-api-mode.yml: -------------------------------------------------------------------------------- 1 | version: '3.8' 2 | services: 3 | api-firewall: 4 | container_name: api-firewall 5 | image: wallarm/api-firewall:v0.9.1 6 | build: 7 | context: ../../ 8 | dockerfile: Dockerfile 9 | restart: on-failure 10 | environment: 11 | APIFW_MODE: "api" 12 | APIFW_SPECIFICATION_UPDATE_PERIOD: "1m" 13 | APIFW_API_MODE_UNKNOWN_PARAMETERS_DETECTION: "true" 14 | APIFW_PASS_OPTIONS: "false" 15 | APIFW_URL: "http://0.0.0.0:8080" 16 | APIFW_HEALTH_HOST: "0.0.0.0:9667" 17 | APIFW_READ_TIMEOUT: "5s" 18 | APIFW_WRITE_TIMEOUT: "5s" 19 | APIFW_LOG_LEVEL: "info" 20 | deploy: 21 | resources: 22 | limits: 23 | cpus: '1.0' 24 | memory: 1024M 25 | volumes: 26 | - ./database/wallarm_api.db:/var/lib/wallarm-api/1/wallarm_api.db:ro 27 | ports: 28 | - "8282:8080" 29 | - "9667:9667" 30 | stop_grace_period: 1s 31 | -------------------------------------------------------------------------------- /resources/test/jwt/pub.pem: -------------------------------------------------------------------------------- 1 | -----BEGIN PUBLIC KEY----- 2 | MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAia/LxEn6HjBp39mxHzpA 3 | HjBVe0t0sRqmguFIwfFEgCwRtIplhbiLDtNwZeLcXxsfVC57r4l40Wm+UcSAlX2l 4 | Bu4tl6LNFuMMfdAbDowTwx6CYBNSnu4+4TE1tvisNVnLR/EaERcwt8n5MtCxC7mV 5 | idyCd9Muh10H11J0grOEzyNeGIsT5rcRoboCC9Z7rY+UrX9UtM+nHwWn1rDB0S5v 6 | QlMryGtsxwCDNEwQssrbLKG2suu0/OnTUuQVahV0b4leT67mhhJzLa+2BmlYnsDA 7 | 882Ub44FJKWwiZ1wi00y75G9MJkMi80GVzdeDDGoY9aEMaMVUBK2AmNSV/CGYLsW 8 | fwIDAQAB 9 | -----END PUBLIC KEY----- -------------------------------------------------------------------------------- /resources/test/modsec/rules_test/test.conf: -------------------------------------------------------------------------------- 1 | SecRule TX:DETECTION_PARANOIA_LEVEL "@lt 1" "id:942011,phase:1,pass,nolog,skipAfter:END-REQUEST-942-APPLICATION-ATTACK-SQLI" 2 | SecRule TX:DETECTION_PARANOIA_LEVEL "@lt 1" "id:942012,phase:2,pass,nolog,skipAfter:END-REQUEST-942-APPLICATION-ATTACK-SQLI" 3 | # 4 | # -= Paranoia Level 1 (default) =- (apply only when tx.detection_paranoia_level is sufficiently high: 1 or higher) 5 | # 6 | 7 | 8 | SecRule REQUEST_HEADERS:User-Agent "Test" "phase:1,id:130,log,redirect:http://www.example.com/failed.html" 9 | 10 | 11 | SecRule REQUEST_BODY|REQUEST_HEADERS|REQUEST_URI|REQUEST_COOKIES|ARGS_NAMES|ARGS|XML:/* "@detectSQLi" \ 12 | "id:942100,\ 13 | phase:2,\ 14 | t:none,\ 15 | t:urlDecode,\ 16 | t:htmlEntityDecode,\ 17 | log,\ 18 | deny,\ 19 | msg:'SQL Injection Attack Detected via libinjection',\ 20 | tag:'attack-sqli',\ 21 | severity:'CRITICAL'" 22 | 23 | SecRule RESPONSE_BODY "@rx (r57 Shell Version [0-9.]+|r57 shell)" \ 24 | "id:955110,\ 25 | phase:4,\ 26 | log,\ 27 | deny,\ 28 | t:none,\ 29 | msg:'r57 web shell',\ 30 | logdata:'Matched Data: %{TX.0} found within %{MATCHED_VAR_NAME}',\ 31 | tag:'language-php',\ 32 | tag:'platform-multi',\ 33 | tag:'attack-rce',\ 34 | tag:'paranoia-level/1',\ 35 | tag:'OWASP_CRS',\ 36 | tag:'capec/1000/225/122/17/650',\ 37 | ver:'OWASP_CRS/4.0.0-rc2',\ 38 | severity:'CRITICAL',\ 39 | setvar:'tx.outbound_anomaly_score_pl1=+%{tx.critical_anomaly_score}'" -------------------------------------------------------------------------------- /resources/test/tokens/test.db: -------------------------------------------------------------------------------- 1 | eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJzb21lIjoicGF5bG9hZDk5OTk5ODIifQ.CUq8iJ_LUzQMfDTvArpz6jUyK0Qyn7jZ9WCqE0xKTCA 2 | eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJzb21lIjoicGF5bG9hZDk5OTk5ODMifQ.BinZ4AcJp_SQz-iFfgKOKPz_jWjEgiVTb9cS8PP4BI0 3 | eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJzb21lIjoicGF5bG9hZDk5OTk5ODQifQ.j5Iea7KGm7GqjMGBuEZc2akTIoByUaQc5SSX7w_qjY8 4 | eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJzb21lIjoicGF5bG9hZDk5OTk5ODUifQ.S9P-DEiWg7dlI81rLjnJWCA6h9Q4ewTizxrsxOPGmNA 5 | eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJzb21lIjoicGF5bG9hZDk5OTk5ODYifQ.HdINfOmk59NdNYBnMjrqUdD4gEikAUafKjAhBI1_Ue8 6 | eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJzb21lIjoicGF5bG9hZDk5OTk5ODcifQ.MDPMmuAquxi55sGTajKQjcFzoaNzFZJFMkDg3fIyhx0 7 | eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJzb21lIjoicGF5bG9hZDk5OTk5ODgifQ.-HfLUDIIHawNbZJkAbml_Um8vlQw7UMeiYmzdRbbwHs 8 | eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJzb21lIjoicGF5bG9hZDk5OTk5ODkifQ.zyFgVDFYCKyp10GKbC8HCUpeT0rRajqG192gb-s7L8U -------------------------------------------------------------------------------- /stylesheets-docs/.icons/material/chevron-right.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /stylesheets-docs/extra.js: -------------------------------------------------------------------------------- 1 | // Open external links in new tab 2 | var links = document.links; 3 | 4 | for(var i = 0; i < links.length; i++) { 5 | if (links[i].hostname != window.location.hostname) { 6 | links[i].target = '_blank'; 7 | links[i].rel = 'noopener'; 8 | } 9 | } 10 | 11 | function injectScript(src, cb) { 12 | let script = document.createElement('script'); 13 | 14 | script.src = src; 15 | cb && (script.onload = cb); 16 | document.body.append(script); 17 | } 18 | 19 | // Collapse expanded menu items when a new item is expanded 20 | var navClassName = ".md-nav__toggle"; 21 | var navigationElements = document.querySelectorAll(navClassName); 22 | 23 | function getAllNavigationElements(element, selector){ 24 | if(element.parentElement && element.parentElement.parentElement && element.parentElement.parentElement.children){ 25 | var allChildren = element.parentElement.parentElement.children; 26 | for (let index = 0; index < allChildren.length; index++) { 27 | var child = allChildren[index]; 28 | var navigationInput = child.querySelector(selector); 29 | if(navigationInput && navigationInput !== element){ 30 | navigationInput.checked = false; 31 | } 32 | } 33 | } 34 | } 35 | 36 | navigationElements.forEach(el => { 37 | el.addEventListener('change', function(){ 38 | getAllNavigationElements(this, navClassName); 39 | }, false); 40 | }) 41 | 42 | // Highlight the search string if URL contains ?search 43 | 44 | const urlParams = new URLSearchParams(window.location.search); 45 | const myParam = urlParams.get('search'); 46 | var searchBar = document.getElementsByClassName('md-search__input'); 47 | 48 | if(myParam !== null) { 49 | document.getElementById("__search").checked = true; 50 | } 51 | --------------------------------------------------------------------------------