diff --git a/.github/workflows/binaries.yml b/.github/workflows/binaries.yml index ebf6312..89cca53 100644 --- a/.github/workflows/binaries.yml +++ b/.github/workflows/binaries.yml @@ -160,7 +160,7 @@ jobs: needs: - draft-release env: - X_GO_VERSION: "1.20.7-r0" + X_GO_VERSION: "1.20.8-r0" strategy: matrix: include: diff --git a/Makefile b/Makefile index 39c2a76..7a3d95d 100644 --- a/Makefile +++ b/Makefile @@ -19,6 +19,8 @@ genmocks: mockgen -source ./internal/platform/proxy/chainpool.go -destination ./internal/platform/proxy/httppool_mock.go -package proxy mockgen -source ./internal/platform/database/database.go -destination ./internal/platform/database/database_mock.go -package database mockgen -source ./cmd/api-firewall/internal/updater/updater.go -destination ./cmd/api-firewall/internal/updater/updater_mock.go -package updater + mockgen -source ./internal/platform/proxy/ws.go -destination ./internal/platform/proxy/ws_mock.go -package proxy + mockgen -source ./internal/platform/proxy/wsClient.go -destination ./internal/platform/proxy/wsClient_mock.go -package proxy update: go get -u ./... diff --git a/README.md b/README.md index f45251d..765a2a3 100644 --- a/README.md +++ b/README.md @@ -1,46 +1,57 @@ # Open Source API Firewall by Wallarm [![Black Hat Arsenal USA 2022](https://github.com/wallarm/api-firewall/blob/main/images/BHA2022.svg?raw=true)](https://www.blackhat.com/us-22/arsenal/schedule/index.html#open-source-api-firewall-new-features--functionalities-28038) -API Firewall is a high-performance proxy with API request and response validation based on [OpenAPI/Swagger](https://www.wallarm.com/what/what-is-openapi) schema. It is designed to protect REST API endpoints in cloud-native environments. API Firewall provides API hardening with the use of a positive security model allowing calls that match a predefined API specification for requests and responses, while rejecting everything else. +API Firewall is a high-performance proxy with API request and response validation based on [OpenAPI](https://wallarm.github.io/api-firewall/installation-guides/docker-container/) and [GraphQL](https://wallarm.github.io/api-firewall/installation-guides/graphql/docker-container/) schemas. It is designed to protect REST and GraphQL API endpoints in cloud-native environments. API Firewall provides API hardening with the use of a positive security model allowing calls that match a predefined API specification for requests and responses, while rejecting everything else. The **key features** of API Firewall are: -* Secure REST API endpoints by blocking malicious requests +* Secure REST and GraphQL API endpoints by blocking malicious requests * Stop API data breaches by blocking malformed API responses * Discover Shadow API endpoints * Validate JWT access tokens for OAuth 2.0 protocol-based authentication -* (NEW) Denylist compromised API tokens, keys, and Cookies +* Denylist compromised API tokens, keys, and Cookies The product is **open source**, available at DockerHub and already got 1 billion (!!!) pulls. To support this project, you can star the [repository](https://hub.docker.com/r/wallarm/api-firewall). +## Operating modes + +Wallarm API Firewall offers several operating modes: + +* [`PROXY`](https://wallarm.github.io/api-firewall/installation-guides/docker-container/): validates HTTP requests and responses against OpenAPI 3.0 and proxies matching requests to the backend. +* [`API`](https://wallarm.github.io/api-firewall/installation-guides/api-mode/): validates individual requests against OpenAPI 3.0 without further proxying. +* [`graphql`](https://wallarm.github.io/api-firewall/installation-guides/graphql/docker-container/): validates HTTP and WebSocket requests against GraphQL schema and proxies matching requests to the backend. + ## Use cases ### Running in blocking mode -* Block malicious requests that do not match the OpenAPI 3.0 specification + +* Block malicious requests that do not match the specification * Block malformed API responses to stop data breaches and sensitive information exposure ### Running in monitoring mode + * Discover Shadow APIs and undocumented API endpoints -* Log malformed requests and responses that do not match the OpenAPI 3.0 specification +* Log malformed requests and responses that do not match the specification ## API schema validation and positive security model -When starting API Firewall, you should provide the [OpenAPI 3.0 specification](https://swagger.io/specification/) of the application to be protected with API Firewall. The started API Firewall will operate as a reverse proxy and validate whether requests and responses match the schema defined in the specification. +When starting API Firewall, you should provide the REST or GraphQL API specification of the application to be protected with API Firewall. The started API Firewall will operate as a reverse proxy and validate whether requests and responses match the schema defined in the specification. -The traffic that does not match the schema will be logged using the [`STDOUT` and `STDERR` Docker services](https://docs.docker.com/config/containers/logging/) or blocked (depending on the configured API Firewall operation mode). When operating in the logging mode, API Firewall also logs so-called shadow API endpoints, those that are not covered in API specification but respond to requests (except for endpoints returning the code `404`). +The traffic that does not match the schema will be logged using the [`STDOUT` and `STDERR` Docker services](https://docs.docker.com/config/containers/logging/) or blocked (depending on the configured API Firewall operation mode). When operating in the logging mode on REST API, API Firewall also logs so-called shadow API endpoints, those that are not covered in API specification but respond to requests (except for endpoints returning the code `404`). ![API Firewall scheme](https://github.com/wallarm/api-firewall/blob/main/images/Firewall%20opensource%20-%20vertical.gif?raw=true) -[OpenAPI 3.0 specification](https://swagger.io/specification/) is supported and should be provided as a YAML or JSON file (`.yaml`, `.yml`, `.json` file extensions). - -By allowing you to set the traffic requirements with the OpenAPI 3.0 specification, API Firewall relies on a positive security model. +By allowing you to set the traffic requirements with the API specification, API Firewall relies on a positive security model. ## Technical data -[API Firewall works](https://www.wallarm.com/what/the-concept-of-a-firewall) as a reverse proxy with a built-in OpenAPI 3.0 request and response validator. It's written in Golang and using fasthttp proxy. The project is optimized for extreme performance and near-zero added latency. +[API Firewall works](https://www.wallarm.com/what/the-concept-of-a-firewall) as a reverse proxy with a built-in OpenAPI 3.0 or GraphQL request and response validator. It is written in Golang and using fasthttp proxy. The project is optimized for extreme performance and near-zero added latency. ## Starting API Firewall -To download, install, and start API Firewall on Docker, see the [instructions](https://docs.wallarm.com/api-firewall/installation-guides/docker-container/). +To download, install, and start API Firewall on Docker, refer to: + +* [REST API guide](https://wallarm.github.io/api-firewall/installation-guides/docker-container/) +* [GraphQL API guide](https://wallarm.github.io/api-firewall/installation-guides/graphql/docker-container/) ## Demos @@ -78,4 +89,3 @@ Time per request: 0.127 [ms] (mean, across all concurrent requests) ``` These performance results are not the only ones we have got during API Firewall testing. Other results along with the methods used to improve API Firewall performance are described in this [Wallarm's blog article](https://lab.wallarm.com/wallarm-api-firewall-outperforms-nginx-in-a-production-environment/). - diff --git a/cmd/api-firewall/internal/handlers/api/openapi.go b/cmd/api-firewall/internal/handlers/api/openapi.go index c01694b..bd89789 100644 --- a/cmd/api-firewall/internal/handlers/api/openapi.go +++ b/cmd/api-firewall/internal/handlers/api/openapi.go @@ -68,7 +68,7 @@ type APIMode struct { CustomRoute *router.CustomRoute OpenAPIRouter *router.Router Log *logrus.Logger - Cfg *config.APIFWConfigurationAPIMode + Cfg *config.APIMode ParserPool *fastjson.ParserPool } diff --git a/cmd/api-firewall/internal/handlers/api/routes.go b/cmd/api-firewall/internal/handlers/api/routes.go index d6ef1d8..1c2b5c3 100644 --- a/cmd/api-firewall/internal/handlers/api/routes.go +++ b/cmd/api-firewall/internal/handlers/api/routes.go @@ -16,7 +16,7 @@ import ( "github.com/wallarm/api-firewall/internal/platform/web" ) -func Handlers(lock *sync.RWMutex, cfg *config.APIFWConfigurationAPIMode, shutdown chan os.Signal, logger *logrus.Logger, storedSpecs database.DBOpenAPILoader) fasthttp.RequestHandler { +func Handlers(lock *sync.RWMutex, cfg *config.APIMode, shutdown chan os.Signal, logger *logrus.Logger, storedSpecs database.DBOpenAPILoader) fasthttp.RequestHandler { // define FastJSON parsers pool var parserPool fastjson.ParserPool diff --git a/cmd/api-firewall/internal/handlers/graphql/httpHandler.go b/cmd/api-firewall/internal/handlers/graphql/httpHandler.go new file mode 100644 index 0000000..ef5a0b6 --- /dev/null +++ b/cmd/api-firewall/internal/handlers/graphql/httpHandler.go @@ -0,0 +1,137 @@ +package graphql + +import ( + "bytes" + "errors" + "fmt" + "net/url" + "strings" + "sync" + + "github.com/fasthttp/websocket" + "github.com/savsgio/gotils/strconv" + "github.com/sirupsen/logrus" + "github.com/valyala/fasthttp" + "github.com/valyala/fastjson" + "github.com/wallarm/api-firewall/internal/config" + "github.com/wallarm/api-firewall/internal/platform/proxy" + "github.com/wallarm/api-firewall/internal/platform/validator" + "github.com/wallarm/api-firewall/internal/platform/web" + "github.com/wundergraph/graphql-go-tools/pkg/graphql" +) + +type Handler struct { + cfg *config.GraphQLMode + serverURL *url.URL + proxyPool proxy.Pool + logger *logrus.Logger + schema *graphql.Schema + parserPool *fastjson.ParserPool + wsClient proxy.WebSocketClient + upgrader *websocket.FastHTTPUpgrader + mu sync.Mutex +} + +var ErrNetworkConnection = errors.New("network connection error") + +// GraphQLHandle performs complexity checks to the GraphQL query and proxy request to the backend if all checks are passed +func (h *Handler) GraphQLHandle(ctx *fasthttp.RequestCtx) error { + + // handle WS + if websocket.FastHTTPIsWebSocketUpgrade(ctx) { + return h.HandleWebSocketProxy(ctx) + } + + // respond with 403 status code in case of content-type of POST request is not application/json + if strconv.B2S(ctx.Request.Header.Method()) == fasthttp.MethodPost && + !bytes.EqualFold(ctx.Request.Header.ContentType(), []byte("application/json")) { + h.logger.WithFields(logrus.Fields{ + "protocol": "HTTP", + "request_id": fmt.Sprintf("#%016X", ctx.ID()), + }).Debug("POST request without application/json content-type is received") + + return web.RespondError(ctx, fasthttp.StatusForbidden, "") + } + + // respond with 403 status code in case of lack of "query" query parameter in GET request + if strconv.B2S(ctx.Request.Header.Method()) == fasthttp.MethodGet && + len(ctx.Request.URI().QueryArgs().Peek("query")) == 0 { + h.logger.WithFields(logrus.Fields{ + "protocol": "HTTP", + "request_id": fmt.Sprintf("#%016X", ctx.ID()), + }).Debug("GET request without \"query\" query parameter is received") + + return web.RespondError(ctx, fasthttp.StatusForbidden, "") + } + + // Proxy request if the validation is disabled + if strings.EqualFold(h.cfg.Graphql.RequestValidation, web.ValidationDisable) { + if err := proxy.Perform(ctx, h.proxyPool); err != nil { + h.logger.WithFields(logrus.Fields{ + "error": err, + "protocol": "HTTP", + "request_id": fmt.Sprintf("#%016X", ctx.ID()), + }).Error("request proxying") + + ctx.Response.SetStatusCode(fasthttp.StatusInternalServerError) + return web.RespondGraphQLErrors(&ctx.Response, ErrNetworkConnection) + } + return nil + } + + gqlRequest, err := validator.ParseGraphQLRequest(ctx) + if err != nil { + h.logger.WithFields(logrus.Fields{ + "error": err, + "protocol": "HTTP", + "request_id": fmt.Sprintf("#%016X", ctx.ID()), + }).Error("GraphQL request unmarshal") + + if strings.EqualFold(h.cfg.Graphql.RequestValidation, web.ValidationBlock) { + return web.RespondGraphQLErrors(&ctx.Response, err) + } + } + + // validate request + if gqlRequest != nil { + validationResult, err := validator.ValidateGraphQLRequest(&h.cfg.Graphql, h.schema, gqlRequest) + // internal errors + if err != nil { + h.logger.WithFields(logrus.Fields{ + "error": err, + "protocol": "HTTP", + "request_id": fmt.Sprintf("#%016X", ctx.ID()), + }).Error("GraphQL query validation") + + if strings.EqualFold(h.cfg.Graphql.RequestValidation, web.ValidationBlock) { + return web.RespondGraphQLErrors(&ctx.Response, err) + } + } + + // validation failed + if !validationResult.Valid && validationResult.Errors != nil { + h.logger.WithFields(logrus.Fields{ + "error": validationResult.Errors, + "protocol": "HTTP", + "request_id": fmt.Sprintf("#%016X", ctx.ID()), + }).Error("GraphQL query validation") + + if strings.EqualFold(h.cfg.Graphql.RequestValidation, web.ValidationBlock) { + return web.RespondGraphQLErrors(&ctx.Response, validationResult.Errors) + } + } + } + + if err := proxy.Perform(ctx, h.proxyPool); err != nil { + h.logger.WithFields(logrus.Fields{ + "error": err, + "protocol": "HTTP", + "request_id": fmt.Sprintf("#%016X", ctx.ID()), + }).Error("request proxying") + + ctx.Response.SetStatusCode(fasthttp.StatusInternalServerError) + return web.RespondGraphQLErrors(&ctx.Response, ErrNetworkConnection) + } + + return nil +} diff --git a/cmd/api-firewall/internal/handlers/graphql/routes.go b/cmd/api-firewall/internal/handlers/graphql/routes.go new file mode 100644 index 0000000..2cfd408 --- /dev/null +++ b/cmd/api-firewall/internal/handlers/graphql/routes.go @@ -0,0 +1,101 @@ +package graphql + +import ( + "github.com/savsgio/gotils/strconv" + "github.com/savsgio/gotils/strings" + "net/url" + "os" + "sync" + + "github.com/fasthttp/websocket" + "github.com/sirupsen/logrus" + "github.com/valyala/fasthttp" + "github.com/valyala/fastjson" + "github.com/wallarm/api-firewall/internal/config" + "github.com/wallarm/api-firewall/internal/mid" + "github.com/wallarm/api-firewall/internal/platform/denylist" + "github.com/wallarm/api-firewall/internal/platform/proxy" + "github.com/wallarm/api-firewall/internal/platform/web" + "github.com/wundergraph/graphql-go-tools/pkg/graphql" + "github.com/wundergraph/graphql-go-tools/pkg/playground" +) + +func Handlers(cfg *config.GraphQLMode, schema *graphql.Schema, serverURL *url.URL, shutdown chan os.Signal, logger *logrus.Logger, proxy proxy.Pool, wsClient proxy.WebSocketClient, deniedTokens *denylist.DeniedTokens) fasthttp.RequestHandler { + + // Construct the web.App which holds all routes as well as common Middleware. + appOptions := web.AppAdditionalOptions{ + Mode: cfg.Mode, + PassOptions: false, + } + + proxyOptions := mid.ProxyOptions{ + Mode: web.GraphQLMode, + RequestValidation: cfg.Graphql.RequestValidation, + DeleteAcceptEncoding: cfg.Server.DeleteAcceptEncoding, + ServerURL: serverURL, + } + + denylistOptions := mid.DenylistOptions{ + Mode: web.GraphQLMode, + Config: &cfg.Denylist, + CustomBlockStatusCode: fasthttp.StatusUnauthorized, + DeniedTokens: deniedTokens, + Logger: logger, + } + app := web.NewApp(&appOptions, shutdown, logger, mid.Logger(logger), mid.Errors(logger), mid.Panics(logger), mid.Proxy(&proxyOptions), mid.Denylist(&denylistOptions)) + + // define FastJSON parsers pool + var parserPool fastjson.ParserPool + + var upgrader = websocket.FastHTTPUpgrader{ + Subprotocols: []string{"graphql-ws"}, + CheckOrigin: func(ctx *fasthttp.RequestCtx) bool { + if !cfg.Graphql.WSCheckOrigin { + return true + } + return strings.Include(cfg.Graphql.WSOrigin, strconv.B2S(ctx.Request.Header.Peek("Origin"))) + }, + } + + s := Handler{ + cfg: cfg, + serverURL: serverURL, + proxyPool: proxy, + logger: logger, + schema: schema, + parserPool: &parserPool, + wsClient: wsClient, + upgrader: &upgrader, + mu: sync.Mutex{}, + } + + graphqlPath := serverURL.Path + if graphqlPath == "" { + graphqlPath = "/" + } + + app.Handle(fasthttp.MethodGet, graphqlPath, s.GraphQLHandle) + app.Handle(fasthttp.MethodPost, graphqlPath, s.GraphQLHandle) + + // enable playground + if cfg.Graphql.Playground { + p := playground.New(playground.Config{ + PathPrefix: "", + PlaygroundPath: cfg.Graphql.PlaygroundPath, + GraphqlEndpointPath: graphqlPath, + GraphQLSubscriptionEndpointPath: graphqlPath, + }) + + handlers, err := p.Handlers() + if err != nil { + logger.Fatalf("playground handlers error: %v", err) + return nil + } + + for i := range handlers { + app.Handle(fasthttp.MethodGet, handlers[i].Path, web.NewFastHTTPHandler(handlers[i].Handler, true)) + } + } + + return app.Router.Handler +} diff --git a/cmd/api-firewall/internal/handlers/graphql/wsHandler.go b/cmd/api-firewall/internal/handlers/graphql/wsHandler.go new file mode 100644 index 0000000..cf46b86 --- /dev/null +++ b/cmd/api-firewall/internal/handlers/graphql/wsHandler.go @@ -0,0 +1,347 @@ +package graphql + +import ( + "errors" + "fmt" + "io" + "strings" + "sync" + + "github.com/fasthttp/websocket" + "github.com/sirupsen/logrus" + "github.com/valyala/fasthttp" + "github.com/valyala/fastjson" + "github.com/wallarm/api-firewall/internal/platform/proxy" + "github.com/wallarm/api-firewall/internal/platform/validator" + "github.com/wallarm/api-firewall/internal/platform/web" + "github.com/wundergraph/graphql-go-tools/pkg/graphql" +) + +func closeWSConn(ctx *fasthttp.RequestCtx, logger *logrus.Logger, conn proxy.WebSocketConn) { + if err := conn.SendCloseConnection(websocket.CloseNormalClosure); err != nil { + logger.WithFields(logrus.Fields{ + "error": err, + "protocol": "websocket", + "request_id": fmt.Sprintf("#%016X", ctx.ID()), + }).Debug("send close message") + } + + if err := conn.Close(); err != nil { + logger.WithFields(logrus.Fields{ + "error": err, + "protocol": "websocket", + "request_id": fmt.Sprintf("#%016X", ctx.ID()), + }).Error("closing connection") + } +} + +func (h *Handler) HandleWebSocketProxy(ctx *fasthttp.RequestCtx) error { + + // connect to backend + backendWSConnect, err := h.wsClient.GetConn(ctx) + if err != nil { + h.logger.WithFields(logrus.Fields{ + "error": err, + "protocol": "websocket", + "request_id": fmt.Sprintf("#%016X", ctx.ID()), + }).Error("Connecting to the server WS error") + + return web.RespondError(ctx, fasthttp.StatusServiceUnavailable, "") + } + + // Get fastjson parser + jsonParser := h.parserPool.Get() + defer h.parserPool.Put(jsonParser) + + var wg sync.WaitGroup + wg.Add(2) + + errClient := make(chan struct{}, 1) + errBackend := make(chan struct{}, 1) + + err = h.upgrader.Upgrade(ctx, func(clientConnPub *websocket.Conn) { + + clientConn := &proxy.FastHTTPWebSocketConn{Conn: clientConnPub, Logger: h.logger, Ctx: ctx} + + // close client WS connection + defer closeWSConn(ctx, h.logger, clientConn) + // close backend WS connection + defer closeWSConn(ctx, h.logger, backendWSConnect) + + // sends messages from client to backend + go func() { + defer wg.Done() + for { + select { + case <-errBackend: + close(errClient) + return + default: + // read message from the client + messageType, p, err := clientConn.ReadMessage() + if err != nil { + if !websocket.IsCloseError(err, websocket.CloseNormalClosure, websocket.CloseGoingAway) { + h.logger.WithFields(logrus.Fields{ + "error": err, + "protocol": "websocket", + "request_id": fmt.Sprintf("#%016X", ctx.ID()), + }).Debug("read from client") + } + + close(errClient) + return + } + + // write to backend server if request validation is disabled OR + // websocket message type is not TextMessage or BinaryMessage OR + // received an empty message + if strings.EqualFold(h.cfg.Graphql.RequestValidation, web.ValidationDisable) || len(p) == 0 || + messageType != websocket.TextMessage && messageType != websocket.BinaryMessage { + + if err := backendWSConnect.WriteMessage(messageType, p); err != nil { + h.logger.WithFields(logrus.Fields{ + "error": err, + "protocol": "websocket", + "request_id": fmt.Sprintf("#%016X", ctx.ID()), + }).Debug("write to backend") + + close(errClient) + return + } + continue + } + + var msg *fastjson.Value + + // try to parse graphql ws message + if msg, err = jsonParser.ParseBytes(p); err != nil { + h.logger.WithFields(logrus.Fields{ + "error": err, + "protocol": "websocket", + "request_id": fmt.Sprintf("#%016X", ctx.ID()), + }).Error("read from client: request unmarshal") + + // if validation is in log_only mode then the request should be proxied to the backend server + if strings.EqualFold(h.cfg.Graphql.RequestValidation, web.ValidationLog) { + if err := backendWSConnect.WriteMessage(messageType, p); err != nil { + h.logger.WithFields(logrus.Fields{ + "error": err, + "protocol": "websocket", + "request_id": fmt.Sprintf("#%016X", ctx.ID()), + }).Debug("write to backend") + + close(errClient) + return + } + } + + continue + } + + msgType := string(msg.Get("type").GetStringBytes()) + msgID := string(msg.Get("id").GetStringBytes()) + + // skip message types that do not contain payload + if msgType != "subscribe" && msgType != "start" { + if err := backendWSConnect.WriteMessage(messageType, p); err != nil { + h.logger.WithFields(logrus.Fields{ + "error": err, + "protocol": "websocket", + "request_id": fmt.Sprintf("#%016X", ctx.ID()), + }).Debug("write to backend") + + close(errClient) + return + } + continue + } + + request := new(graphql.Request) + + msgPayload := msg.Get("payload").String() + + // unmarshal the graphql request. + // send error and complete messages to the client in case of an error occurred and do not proxy request to the backend in BLOCK mode + // log error and proxy request to the backend server in LOG_ONLY mode + if err := graphql.UnmarshalRequest(io.NopCloser(strings.NewReader(msgPayload)), request); err != nil { + + h.logger.WithFields(logrus.Fields{ + "error": err, + "protocol": "websocket", + "payload": msgPayload, + "request_id": fmt.Sprintf("#%016X", ctx.ID()), + }).Error("GraphQL request unmarshal") + + // block request and respond by error in BLOCK mode + if strings.EqualFold(h.cfg.Graphql.RequestValidation, web.ValidationBlock) { + if err := clientConn.SendError(messageType, msgID, errors.New("invalid graphql request")); err != nil { + h.logger.WithFields(logrus.Fields{ + "error": err, + "protocol": "websocket", + "request_id": fmt.Sprintf("#%016X", ctx.ID()), + }).Debug("write to client") + } + + if err := clientConn.SendComplete(messageType, msgID); err != nil { + h.logger.WithFields(logrus.Fields{ + "error": err, + "protocol": "websocket", + "request_id": fmt.Sprintf("#%016X", ctx.ID()), + }).Debug("write to client") + } + + continue + } + // send request to the backend server (LOG_ONLY mode) + if err := backendWSConnect.WriteMessage(messageType, p); err != nil { + h.logger.WithFields(logrus.Fields{ + "error": err, + "protocol": "websocket", + "request_id": fmt.Sprintf("#%016X", ctx.ID()), + }).Debug("write to backend") + + close(errClient) + return + } + } + + // validate request + // send error and complete messages to the client in case of the APIFW can't validate the request + // and do not proxy request to the backend + validationResult, err := validator.ValidateGraphQLRequest(&h.cfg.Graphql, h.schema, request) + if err != nil { + h.logger.WithFields(logrus.Fields{ + "error": err, + "protocol": "websocket", + "request_id": fmt.Sprintf("#%016X", ctx.ID()), + }).Error("GraphQL query validation") + + // block request and respond by error in BLOCK mode + if strings.EqualFold(h.cfg.Graphql.RequestValidation, web.ValidationBlock) { + + if err := clientConn.SendError(messageType, msgID, errors.New("invalid graphql request")); err != nil { + h.logger.WithFields(logrus.Fields{ + "error": err, + "protocol": "websocket", + "request_id": fmt.Sprintf("#%016X", ctx.ID()), + }).Debug("write to client") + } + + if err := clientConn.SendComplete(messageType, msgID); err != nil { + h.logger.WithFields(logrus.Fields{ + "error": err, + "protocol": "websocket", + "request_id": fmt.Sprintf("#%016X", ctx.ID()), + }).Debug("write to client") + } + continue + } + // send request to the backend server + if err := backendWSConnect.WriteMessage(messageType, p); err != nil { + h.logger.WithFields(logrus.Fields{ + "error": err, + "protocol": "websocket", + "request_id": fmt.Sprintf("#%016X", ctx.ID()), + }).Debug("write to backend") + + close(errClient) + return + } + } + + // send error and complete messages to the client in case of the validation has been failed + // and do not proxy request to the backend + if !validationResult.Valid { + h.logger.WithFields(logrus.Fields{ + "error": validationResult.Errors, + "protocol": "websocket", + "request_id": fmt.Sprintf("#%016X", ctx.ID()), + }).Error("GraphQL query validation") + + // block request and respond by error in BLOCK mode + if strings.EqualFold(h.cfg.Graphql.RequestValidation, web.ValidationBlock) { + + if err := clientConn.SendError(messageType, msgID, validationResult.Errors); err != nil { + h.logger.WithFields(logrus.Fields{ + "error": err, + "protocol": "websocket", + "request_id": fmt.Sprintf("#%016X", ctx.ID()), + }).Debug("write to client") + } + + if err := clientConn.SendComplete(messageType, msgID); err != nil { + h.logger.WithFields(logrus.Fields{ + "error": err, + "protocol": "websocket", + "request_id": fmt.Sprintf("#%016X", ctx.ID()), + }).Debug("write to client") + } + continue + } + } + + // send request to the backend server + if err := backendWSConnect.WriteMessage(messageType, p); err != nil { + h.logger.WithFields(logrus.Fields{ + "error": err, + "protocol": "websocket", + "request_id": fmt.Sprintf("#%016X", ctx.ID()), + }).Debug("write to backend") + + close(errClient) + return + } + } + } + }() + + // sends messages from backend to client + go func() { + defer wg.Done() + for { + select { + case <-errClient: + close(errBackend) + return + default: + messageType, p, err := backendWSConnect.ReadMessage() + if err != nil { + h.logger.WithFields(logrus.Fields{ + "error": err, + "protocol": "websocket", + "request_id": fmt.Sprintf("#%016X", ctx.ID()), + }).Debug("read from backend") + + close(errBackend) + return + } + + if err := clientConn.WriteMessage(messageType, p); err != nil { + h.logger.WithFields(logrus.Fields{ + "error": err, + "protocol": "websocket", + "request_id": fmt.Sprintf("#%016X", ctx.ID()), + }).Debug("write to client") + + close(errBackend) + return + } + } + } + }() + + wg.Wait() + }) + + // upgrader will set response status code and add the error message + if err != nil { + h.logger.WithFields(logrus.Fields{ + "error": err, + "request_id": fmt.Sprintf("#%016X", ctx.ID()), + }).Error("WebSocket handler") + + return nil + } + + return nil +} diff --git a/cmd/api-firewall/internal/handlers/proxy/openapi.go b/cmd/api-firewall/internal/handlers/proxy/openapi.go index d180ae4..528197d 100644 --- a/cmd/api-firewall/internal/handlers/proxy/openapi.go +++ b/cmd/api-firewall/internal/handlers/proxy/openapi.go @@ -26,7 +26,7 @@ type openapiWaf struct { customRoute *router.CustomRoute proxyPool proxy.Pool logger *logrus.Logger - cfg *config.APIFWConfiguration + cfg *config.ProxyMode parserPool *fastjson.ParserPool oauthValidator oauth2.OAuth2 } @@ -94,46 +94,11 @@ func getValidationHeader(ctx *fasthttp.RequestCtx, err error) *string { return nil } -// Proxy request -func performProxy(ctx *fasthttp.RequestCtx, proxyPool proxy.Pool) error { - - client, err := proxyPool.Get() - if err != nil { - return err - } - defer proxyPool.Put(client) - - if err := client.Do(&ctx.Request, &ctx.Response); err != nil { - // request proxy has been failed - ctx.SetUserValue(web.RequestProxyFailed, true) - - switch err { - case fasthttp.ErrDialTimeout: - if err := web.RespondError(ctx, fasthttp.StatusGatewayTimeout, ""); err != nil { - return err - } - case fasthttp.ErrNoFreeConns: - if err := web.RespondError(ctx, fasthttp.StatusServiceUnavailable, ""); err != nil { - return err - } - default: - if err := web.RespondError(ctx, fasthttp.StatusBadGateway, ""); err != nil { - return err - } - } - - // The error has been handled so we can stop propagating it - return err - } - - return nil -} - func (s *openapiWaf) openapiWafHandler(ctx *fasthttp.RequestCtx) error { - // Proxy request if APIFW is disabled OR pass requests with OPTIONS method is enabled and request method is OPTIONS - if s.cfg.RequestValidation == web.ValidationDisable && s.cfg.ResponseValidation == web.ValidationDisable { - if err := performProxy(ctx, s.proxyPool); err != nil { + // Proxy request if APIFW is disabled + if strings.EqualFold(s.cfg.RequestValidation, web.ValidationDisable) && strings.EqualFold(s.cfg.ResponseValidation, web.ValidationDisable) { + if err := proxy.Perform(ctx, s.proxyPool); err != nil { s.logger.WithFields(logrus.Fields{ "error": err, "request_id": fmt.Sprintf("#%016X", ctx.ID()), @@ -147,7 +112,7 @@ func (s *openapiWaf) openapiWafHandler(ctx *fasthttp.RequestCtx) error { // route for the request not found ctx.SetUserValue(web.RequestProxyNoRoute, true) - if s.cfg.RequestValidation == web.ValidationBlock || s.cfg.ResponseValidation == web.ValidationBlock { + if strings.EqualFold(s.cfg.RequestValidation, web.ValidationBlock) || strings.EqualFold(s.cfg.ResponseValidation, web.ValidationBlock) { if s.cfg.AddValidationStatusHeader { vh := "request: customRoute not found" return web.RespondError(ctx, s.cfg.CustomBlockStatusCode, vh) @@ -155,7 +120,7 @@ func (s *openapiWaf) openapiWafHandler(ctx *fasthttp.RequestCtx) error { return web.RespondError(ctx, s.cfg.CustomBlockStatusCode, "") } - if err := performProxy(ctx, s.proxyPool); err != nil { + if err := proxy.Perform(ctx, s.proxyPool); err != nil { s.logger.WithFields(logrus.Fields{ "error": err, "request_id": fmt.Sprintf("#%016X", ctx.ID()), @@ -254,7 +219,7 @@ func (s *openapiWaf) openapiWafHandler(ctx *fasthttp.RequestCtx) error { jsonParser := s.parserPool.Get() defer s.parserPool.Put(jsonParser) - switch s.cfg.RequestValidation { + switch strings.ToLower(s.cfg.RequestValidation) { case web.ValidationBlock: if err := validator.ValidateRequest(ctx, requestValidationInput, jsonParser); err != nil { @@ -343,7 +308,7 @@ func (s *openapiWaf) openapiWafHandler(ctx *fasthttp.RequestCtx) error { } } - if err := performProxy(ctx, s.proxyPool); err != nil { + if err := proxy.Perform(ctx, s.proxyPool); err != nil { s.logger.WithFields(logrus.Fields{ "error": err, "request_id": fmt.Sprintf("#%016X", ctx.ID()), @@ -385,7 +350,7 @@ func (s *openapiWaf) openapiWafHandler(ctx *fasthttp.RequestCtx) error { } // Validate response - switch s.cfg.ResponseValidation { + switch strings.ToLower(s.cfg.ResponseValidation) { case web.ValidationBlock: if err := validator.ValidateResponse(ctx, responseValidationInput, jsonParser); err != nil { // response has been blocked diff --git a/cmd/api-firewall/internal/handlers/proxy/routes.go b/cmd/api-firewall/internal/handlers/proxy/routes.go index 64fb5c1..c69e167 100644 --- a/cmd/api-firewall/internal/handlers/proxy/routes.go +++ b/cmd/api-firewall/internal/handlers/proxy/routes.go @@ -21,7 +21,7 @@ import ( "github.com/wallarm/api-firewall/internal/platform/web" ) -func Handlers(cfg *config.APIFWConfiguration, serverURL *url.URL, shutdown chan os.Signal, logger *logrus.Logger, proxy proxy.Pool, swagRouter *router.Router, deniedTokens *denylist.DeniedTokens) fasthttp.RequestHandler { +func Handlers(cfg *config.ProxyMode, serverURL *url.URL, shutdown chan os.Signal, logger *logrus.Logger, httpClientsPool proxy.Pool, swagRouter *router.Router, deniedTokens *denylist.DeniedTokens) fasthttp.RequestHandler { // define FastJSON parsers pool var parserPool fastjson.ParserPool @@ -64,12 +64,34 @@ func Handlers(cfg *config.APIFWConfiguration, serverURL *url.URL, shutdown chan } // Construct the web.App which holds all routes as well as common Middleware. - app := web.NewApp(shutdown, cfg, logger, mid.Logger(logger), mid.Errors(logger), mid.Panics(logger), mid.Proxy(cfg, serverURL), mid.Denylist(cfg, deniedTokens, logger), mid.ShadowAPIMonitor(logger, &cfg.ShadowAPI)) + options := web.AppAdditionalOptions{ + Mode: cfg.Mode, + PassOptions: cfg.PassOptionsRequests, + RequestValidation: cfg.RequestValidation, + ResponseValidation: cfg.ResponseValidation, + CustomBlockStatusCode: cfg.CustomBlockStatusCode, + } + + proxyOptions := mid.ProxyOptions{ + Mode: web.ProxyMode, + RequestValidation: cfg.RequestValidation, + DeleteAcceptEncoding: cfg.Server.DeleteAcceptEncoding, + ServerURL: serverURL, + } + + denylistOptions := mid.DenylistOptions{ + Mode: web.GraphQLMode, + Config: &cfg.Denylist, + CustomBlockStatusCode: cfg.CustomBlockStatusCode, + DeniedTokens: deniedTokens, + Logger: logger, + } + app := web.NewApp(&options, shutdown, logger, mid.Logger(logger), mid.Errors(logger), mid.Panics(logger), mid.Proxy(&proxyOptions), mid.Denylist(&denylistOptions), mid.ShadowAPIMonitor(logger, &cfg.ShadowAPI)) for i := 0; i < len(swagRouter.Routes); i++ { s := openapiWaf{ customRoute: &swagRouter.Routes[i], - proxyPool: proxy, + proxyPool: httpClientsPool, logger: logger, cfg: cfg, parserPool: &parserPool, @@ -85,7 +107,7 @@ func Handlers(cfg *config.APIFWConfiguration, serverURL *url.URL, shutdown chan // set handler for default behavior (404, 405) s := openapiWaf{ customRoute: nil, - proxyPool: proxy, + proxyPool: httpClientsPool, logger: logger, cfg: cfg, parserPool: &parserPool, diff --git a/cmd/api-firewall/internal/updater/updater.go b/cmd/api-firewall/internal/updater/updater.go index eaf6295..424154b 100644 --- a/cmd/api-firewall/internal/updater/updater.go +++ b/cmd/api-firewall/internal/updater/updater.go @@ -25,7 +25,7 @@ type Specification struct { sqlLiteStorage database.DBOpenAPILoader stop chan struct{} updateTime time.Duration - cfg *config.APIFWConfigurationAPIMode + cfg *config.APIMode api *fasthttp.Server shutdown chan os.Signal health *handlersAPI.Health @@ -33,7 +33,7 @@ type Specification struct { } // NewController function defines configuration updater controller -func NewController(lock *sync.RWMutex, logger *logrus.Logger, sqlLiteStorage database.DBOpenAPILoader, cfg *config.APIFWConfigurationAPIMode, api *fasthttp.Server, shutdown chan os.Signal, health *handlersAPI.Health) Updater { +func NewController(lock *sync.RWMutex, logger *logrus.Logger, sqlLiteStorage database.DBOpenAPILoader, cfg *config.APIMode, api *fasthttp.Server, shutdown chan os.Signal, health *handlersAPI.Health) Updater { return &Specification{ logger: logger, sqlLiteStorage: sqlLiteStorage, @@ -56,32 +56,37 @@ func getSchemaVersions(dbSpecs database.DBOpenAPILoader) map[int]string { return result } -// Start function starts update process every ConfigurationUpdatePeriod -func (s *Specification) Start() error { - - go func() { - updateTicker := time.NewTicker(s.updateTime) - for { - select { - case <-updateTicker.C: - beforeUpdateSpecs := getSchemaVersions(s.sqlLiteStorage) - if err := s.Update(); err != nil { - s.logger.WithFields(logrus.Fields{"error": err}).Error("updating OpenAPI specification") - continue - } - afterUpdateSpecs := getSchemaVersions(s.sqlLiteStorage) - if !reflect.DeepEqual(beforeUpdateSpecs, afterUpdateSpecs) { - s.logger.Debugf("OpenAPI specifications has been updated. Loaded OpenAPI specification versions: %v", afterUpdateSpecs) - s.lock.Lock() - s.api.Handler = handlersAPI.Handlers(s.lock, s.cfg, s.shutdown, s.logger, s.sqlLiteStorage) - s.health.OpenAPIDB = s.sqlLiteStorage - s.lock.Unlock() - continue - } - s.logger.Debugf("regular update checker: new OpenAPI specifications not found") +// Run function performs update of the specification +func (s *Specification) Run() { + updateTicker := time.NewTicker(s.updateTime) + for { + select { + case <-updateTicker.C: + beforeUpdateSpecs := getSchemaVersions(s.sqlLiteStorage) + if err := s.Update(); err != nil { + s.logger.WithFields(logrus.Fields{"error": err}).Error("updating OpenAPI specification") + continue } + afterUpdateSpecs := getSchemaVersions(s.sqlLiteStorage) + if !reflect.DeepEqual(beforeUpdateSpecs, afterUpdateSpecs) { + s.logger.Debugf("OpenAPI specifications has been updated. Loaded OpenAPI specification versions: %v", afterUpdateSpecs) + s.lock.Lock() + s.api.Handler = handlersAPI.Handlers(s.lock, s.cfg, s.shutdown, s.logger, s.sqlLiteStorage) + s.health.OpenAPIDB = s.sqlLiteStorage + s.lock.Unlock() + continue + } + s.logger.Debugf("regular update checker: new OpenAPI specifications not found") + case <-s.stop: + updateTicker.Stop() + return } - }() + } +} + +// Start function starts update process every ConfigurationUpdatePeriod +func (s *Specification) Start() error { + go s.Run() <-s.stop return nil @@ -90,7 +95,12 @@ func (s *Specification) Start() error { // Shutdown function stops update process func (s *Specification) Shutdown() error { defer s.logger.Infof("specification updater: stopped") - s.stop <- struct{}{} + + // close worker and finish Start function + for i := 0; i < 2; i++ { + s.stop <- struct{}{} + } + return nil } diff --git a/cmd/api-firewall/internal/updater/updater_test.go b/cmd/api-firewall/internal/updater/updater_test.go index 346fa3b..9155320 100644 --- a/cmd/api-firewall/internal/updater/updater_test.go +++ b/cmd/api-firewall/internal/updater/updater_test.go @@ -21,7 +21,7 @@ const ( DefaultSchemaID = 1 ) -var cfg = config.APIFWConfigurationAPIMode{ +var cfg = config.APIMode{ APIFWMode: config.APIFWMode{Mode: web.APIMode}, SpecificationUpdatePeriod: 2 * time.Second, PathToSpecDB: "./wallarm_api_after_update.db", diff --git a/cmd/api-firewall/main.go b/cmd/api-firewall/main.go index dc084d6..6ce7d5b 100644 --- a/cmd/api-firewall/main.go +++ b/cmd/api-firewall/main.go @@ -19,6 +19,7 @@ import ( "github.com/sirupsen/logrus" "github.com/valyala/fasthttp" handlersAPI "github.com/wallarm/api-firewall/cmd/api-firewall/internal/handlers/api" + handlersGQL "github.com/wallarm/api-firewall/cmd/api-firewall/internal/handlers/graphql" handlersProxy "github.com/wallarm/api-firewall/cmd/api-firewall/internal/handlers/proxy" "github.com/wallarm/api-firewall/cmd/api-firewall/internal/updater" "github.com/wallarm/api-firewall/internal/config" @@ -27,6 +28,7 @@ import ( "github.com/wallarm/api-firewall/internal/platform/proxy" "github.com/wallarm/api-firewall/internal/platform/router" "github.com/wallarm/api-firewall/internal/platform/web" + "github.com/wundergraph/graphql-go-tools/pkg/graphql" ) var build = "develop" @@ -37,6 +39,12 @@ const ( projectName = "Wallarm API-Firewall" ) +const ( + initialPoolCapacity = 100 + livenessEndpoint = "/v1/liveness" + readinessEndpoint = "/v1/readiness" +) + func main() { logger := logrus.New() @@ -65,6 +73,11 @@ func main() { logger.Infof("%s: error: %s", logPrefix, err) os.Exit(1) } + case web.GraphQLMode: + if err := runGraphQLMode(logger); err != nil { + logger.Infof("%s: error: %s", logPrefix, err) + os.Exit(1) + } default: if err := runProxyMode(logger); err != nil { logger.Infof("%s: error: %s", logPrefix, err) @@ -79,7 +92,7 @@ func runAPIMode(logger *logrus.Logger) error { // ========================================================================= // Configuration - var cfg config.APIFWConfigurationAPIMode + var cfg config.APIMode cfg.Version.SVN = build cfg.Version.Desc = projectName @@ -106,7 +119,7 @@ func runAPIMode(logger *logrus.Logger) error { // ========================================================================= // Init Logger - if strings.ToLower(cfg.LogFormat) == "json" { + if strings.EqualFold(cfg.LogFormat, "json") { logger.SetFormatter(&logrus.JSONFormatter{}) } @@ -173,11 +186,11 @@ func runAPIMode(logger *logrus.Logger) error { // health service handler healthHandler := func(ctx *fasthttp.RequestCtx) { switch string(ctx.Path()) { - case "/v1/liveness": + case livenessEndpoint: if err := healthData.Liveness(ctx); err != nil { healthData.Logger.Errorf("%s: liveness: %s", logPrefix, err.Error()) } - case "/v1/readiness": + case readinessEndpoint: if err := healthData.Readiness(ctx); err != nil { healthData.Logger.Errorf("%s: readiness: %s", logPrefix, err.Error()) } @@ -186,7 +199,7 @@ func runAPIMode(logger *logrus.Logger) error { } } - healthApi := fasthttp.Server{ + healthAPI := fasthttp.Server{ Handler: healthHandler, ReadTimeout: cfg.ReadTimeout, WriteTimeout: cfg.WriteTimeout, @@ -197,7 +210,7 @@ func runAPIMode(logger *logrus.Logger) error { // Start the service listening for requests. go func() { logger.Infof("%s: Health API listening on %s", logPrefix, cfg.HealthAPIHost) - serverErrors <- healthApi.ListenAndServe(cfg.HealthAPIHost) + serverErrors <- healthAPI.ListenAndServe(cfg.HealthAPIHost) }() // ========================================================================= @@ -285,12 +298,298 @@ func runAPIMode(logger *logrus.Logger) error { } +func runGraphQLMode(logger *logrus.Logger) error { + + // ========================================================================= + // Configuration + + var cfg config.GraphQLMode + cfg.Version.SVN = build + cfg.Version.Desc = projectName + + if err := conf.Parse(os.Args[1:], namespace, &cfg); err != nil { + switch err { + case conf.ErrHelpWanted: + usage, err := conf.Usage(namespace, &cfg) + if err != nil { + return errors.Wrap(err, "generating config usage") + } + fmt.Println(usage) + return nil + case conf.ErrVersionWanted: + version, err := conf.VersionString(namespace, &cfg) + if err != nil { + return errors.Wrap(err, "generating config version") + } + fmt.Println(version) + return nil + } + return errors.Wrap(err, "parsing config") + } + + // ========================================================================= + // Init Logger + + if strings.EqualFold(cfg.LogFormat, "json") { + logger.SetFormatter(&logrus.JSONFormatter{}) + } + + switch strings.ToLower(cfg.LogLevel) { + case "trace": + logger.SetLevel(logrus.TraceLevel) + case "debug": + logger.SetLevel(logrus.DebugLevel) + case "error": + logger.SetLevel(logrus.ErrorLevel) + case "warning": + logger.SetLevel(logrus.WarnLevel) + case "info": + logger.SetLevel(logrus.InfoLevel) + default: + return errors.New("invalid log level") + } + + // Print the build version for our logs. Also expose it under /debug/vars. + expvar.NewString("build").Set(build) + + logger.Infof("%s : Started : Application initializing : version %q", logPrefix, build) + defer logger.Infof("%s: Completed", logPrefix) + + out, err := conf.String(&cfg) + if err != nil { + return errors.Wrap(err, "generating config for output") + } + logger.Infof("%s: Configuration Loaded :\n%v\n", logPrefix, out) + + // Make a channel to listen for an interrupt or terminate signal from the OS. + // Use a buffered channel because the signal package requires it. + shutdown := make(chan os.Signal, 1) + signal.Notify(shutdown, os.Interrupt, syscall.SIGTERM) + + // Make a channel to listen for errors coming from the listener. Use a + // buffered channel so the goroutine can exit if we don't collect this error. + serverErrors := make(chan error, 1) + + // ========================================================================= + // Init GraphQL schema + + // load file with GraphQL schema + f, err := os.Open(cfg.Graphql.Schema) + if err != nil { + logger.Fatalf("Loading GraphQL Schema error: %v", err) + return err + } + + // parse the GraphQL schema + schema, err := graphql.NewSchemaFromReader(f) + if err != nil { + logger.Fatalf("Loading GraphQL Schema error: %v", err) + return err + } + + validationRes, err := schema.Validate() + if err != nil { + logger.Fatalf("GraphQL Schema validation error: %v", err) + return err + } + + if !validationRes.Valid { + logger.Fatalf("GraphQL Schema validation error: %v", validationRes.Errors) + return validationRes.Errors + } + + if err := f.Close(); err != nil { + logger.Fatalf("Loading GraphQL Schema error: %v", err) + return err + } + + // ========================================================================= + // Init Proxy Pool + + serverURL, err := url.ParseRequestURI(cfg.Server.URL) + if err != nil { + return errors.Wrap(err, "parsing proxy URL") + } + host := serverURL.Host + if serverURL.Port() == "" { + switch serverURL.Scheme { + case "https": + host += ":443" + case "http": + host += ":80" + } + } + + initialCap := initialPoolCapacity + + if cfg.Server.ClientPoolCapacity < initialPoolCapacity { + initialCap = 1 + } + + options := proxy.Options{ + InitialPoolCapacity: initialCap, + ClientPoolCapacity: cfg.Server.ClientPoolCapacity, + InsecureConnection: cfg.Server.InsecureConnection, + RootCA: cfg.Server.RootCA, + MaxConnsPerHost: cfg.Server.MaxConnsPerHost, + ReadTimeout: cfg.Server.ReadTimeout, + WriteTimeout: cfg.Server.WriteTimeout, + DialTimeout: cfg.Server.DialTimeout, + } + pool, err := proxy.NewChanPool(host, &options) + if err != nil { + return errors.Wrap(err, "proxy pool init") + } + + // ========================================================================= + // Init WS Pool + + wsScheme := "ws" + if serverURL.Scheme == "https" { + wsScheme = "wss" + } + wsConnPoolOptions := &proxy.WSClientOptions{ + Scheme: wsScheme, + Host: serverURL.Host, + Path: serverURL.Path, + InsecureConnection: cfg.Server.InsecureConnection, + RootCA: cfg.Server.RootCA, + DialTimeout: cfg.Server.DialTimeout, + } + + wsPool, err := proxy.NewWSClient(logger, wsConnPoolOptions) + if err != nil { + return errors.Wrap(err, "ws connections pool init") + } + + // ========================================================================= + // Init Cache + + logger.Infof("%s: Initializing Cache", logPrefix) + + deniedTokens, err := denylist.New(&cfg.Denylist, logger) + if err != nil { + return errors.Wrap(err, "denylist init error") + } + + switch deniedTokens { + case nil: + logger.Infof("%s: Denylist not configured", logPrefix) + default: + logger.Infof("%s: Loaded %d tokens to the cache", logPrefix, deniedTokens.ElementsNum) + } + + // ========================================================================= + // Init Handlers + + requestHandlers := handlersGQL.Handlers(&cfg, schema, serverURL, shutdown, logger, pool, wsPool, deniedTokens) + + // ========================================================================= + // Start Health API Service + + healthData := handlersProxy.Health{ + Build: build, + Logger: logger, + Pool: pool, + } + + // health service handler + healthHandler := func(ctx *fasthttp.RequestCtx) { + switch string(ctx.Path()) { + case livenessEndpoint: + if err := healthData.Liveness(ctx); err != nil { + healthData.Logger.Errorf("%s: liveness: %s", logPrefix, err.Error()) + } + case readinessEndpoint: + if err := healthData.Readiness(ctx); err != nil { + healthData.Logger.Errorf("%s: readiness: %s", logPrefix, err.Error()) + } + default: + ctx.Error("Unsupported path", fasthttp.StatusNotFound) + } + } + + healthAPI := fasthttp.Server{ + Handler: healthHandler, + ReadTimeout: cfg.ReadTimeout, + WriteTimeout: cfg.WriteTimeout, + Logger: logger, + NoDefaultServerHeader: true, + } + + // Start the service listening for requests. + go func() { + logger.Infof("%s: Health API listening on %s", logPrefix, cfg.HealthAPIHost) + serverErrors <- healthAPI.ListenAndServe(cfg.HealthAPIHost) + }() + + // ========================================================================= + // Start API Service + + logger.Infof("%s: Initializing API support", logPrefix) + + apiHost, err := url.ParseRequestURI(cfg.APIHost) + if err != nil { + return errors.Wrap(err, "parsing API Host URL") + } + + var isTLS bool + + switch apiHost.Scheme { + case "http": + isTLS = false + case "https": + isTLS = true + } + + api := fasthttp.Server{ + Handler: requestHandlers, + ReadTimeout: cfg.ReadTimeout, + WriteTimeout: cfg.WriteTimeout, + Logger: logger, + NoDefaultServerHeader: true, + } + + // Start the service listening for requests. + go func() { + logger.Infof("%s: API listening on %s", logPrefix, cfg.APIHost) + switch isTLS { + case false: + serverErrors <- api.ListenAndServe(apiHost.Host) + case true: + serverErrors <- api.ListenAndServeTLS(apiHost.Host, path.Join(cfg.TLS.CertsPath, cfg.TLS.CertFile), + path.Join(cfg.TLS.CertsPath, cfg.TLS.CertKey)) + } + }() + + // ========================================================================= + // Shutdown + + // Blocking main and waiting for shutdown. + select { + case err := <-serverErrors: + return errors.Wrap(err, "server error") + + case sig := <-shutdown: + logger.Infof("%s: %v: Start shutdown", logPrefix, sig) + + // Asking listener to shutdown and shed load. + if err := api.Shutdown(); err != nil { + return errors.Wrap(err, "could not stop server gracefully") + } + logger.Infof("%s: %v: Completed shutdown", logPrefix, sig) + } + + return nil + +} + func runProxyMode(logger *logrus.Logger) error { // ========================================================================= // Configuration - var cfg config.APIFWConfiguration + var cfg config.ProxyMode cfg.Version.SVN = build cfg.Version.Desc = projectName @@ -347,7 +646,7 @@ func runProxyMode(logger *logrus.Logger) error { // ========================================================================= // Init Logger - if strings.ToLower(cfg.LogFormat) == "json" { + if strings.EqualFold(cfg.LogFormat, "json") { logger.SetFormatter(&logrus.JSONFormatter{}) } @@ -397,19 +696,19 @@ func runProxyMode(logger *logrus.Logger) error { var swagger *openapi3.T - apiSpecUrl, err := url.ParseRequestURI(cfg.APISpecs) + apiSpecURL, err := url.ParseRequestURI(cfg.APISpecs) if err != nil { logger.Debugf("%s: Trying to parse API Spec value as URL : %v\n", logPrefix, err.Error()) } - switch apiSpecUrl { + switch apiSpecURL { case nil: swagger, err = openapi3.NewLoader().LoadFromFile(cfg.APISpecs) if err != nil { return errors.Wrap(err, "loading swagwaf file") } default: - swagger, err = openapi3.NewLoader().LoadFromURI(apiSpecUrl) + swagger, err = openapi3.NewLoader().LoadFromURI(apiSpecURL) if err != nil { return errors.Wrap(err, "loading swagwaf url") } @@ -423,13 +722,13 @@ func runProxyMode(logger *logrus.Logger) error { // ========================================================================= // Init Proxy Client - serverUrl, err := url.ParseRequestURI(cfg.Server.URL) + serverURL, err := url.ParseRequestURI(cfg.Server.URL) if err != nil { return errors.Wrap(err, "parsing proxy URL") } - host := serverUrl.Host - if serverUrl.Port() == "" { - switch serverUrl.Scheme { + host := serverURL.Host + if serverURL.Port() == "" { + switch serverURL.Scheme { case "https": host += ":443" case "http": @@ -437,13 +736,23 @@ func runProxyMode(logger *logrus.Logger) error { } } - initialCap := 100 + initialCap := initialPoolCapacity - if cfg.Server.ClientPoolCapacity < 100 { + if cfg.Server.ClientPoolCapacity < initialPoolCapacity { initialCap = 1 } - pool, err := proxy.NewChanPool(initialCap, cfg.Server.ClientPoolCapacity, host, &cfg.Server) + options := proxy.Options{ + InitialPoolCapacity: initialCap, + ClientPoolCapacity: cfg.Server.ClientPoolCapacity, + InsecureConnection: cfg.Server.InsecureConnection, + RootCA: cfg.Server.RootCA, + MaxConnsPerHost: cfg.Server.MaxConnsPerHost, + ReadTimeout: cfg.Server.ReadTimeout, + WriteTimeout: cfg.Server.WriteTimeout, + DialTimeout: cfg.Server.DialTimeout, + } + pool, err := proxy.NewChanPool(host, &options) if err != nil { return errors.Wrap(err, "proxy pool init") } @@ -453,7 +762,7 @@ func runProxyMode(logger *logrus.Logger) error { logger.Infof("%s: Initializing Cache", logPrefix) - deniedTokens, err := denylist.New(&cfg, logger) + deniedTokens, err := denylist.New(&cfg.Denylist, logger) if err != nil { return errors.Wrap(err, "denylist init error") } @@ -468,7 +777,7 @@ func runProxyMode(logger *logrus.Logger) error { // ========================================================================= // Init Handlers - requestHandlers = handlersProxy.Handlers(&cfg, serverUrl, shutdown, logger, pool, swagRouter, deniedTokens) + requestHandlers = handlersProxy.Handlers(&cfg, serverURL, shutdown, logger, pool, swagRouter, deniedTokens) // ========================================================================= // Start Health API Service @@ -482,11 +791,11 @@ func runProxyMode(logger *logrus.Logger) error { // health service handler healthHandler := func(ctx *fasthttp.RequestCtx) { switch string(ctx.Path()) { - case "/v1/liveness": + case livenessEndpoint: if err := healthData.Liveness(ctx); err != nil { healthData.Logger.Errorf("%s: liveness: %s", logPrefix, err.Error()) } - case "/v1/readiness": + case readinessEndpoint: if err := healthData.Readiness(ctx); err != nil { healthData.Logger.Errorf("%s: readiness: %s", logPrefix, err.Error()) } @@ -495,7 +804,7 @@ func runProxyMode(logger *logrus.Logger) error { } } - healthApi := fasthttp.Server{ + healthAPI := fasthttp.Server{ Handler: healthHandler, ReadTimeout: cfg.ReadTimeout, WriteTimeout: cfg.WriteTimeout, @@ -506,7 +815,7 @@ func runProxyMode(logger *logrus.Logger) error { // Start the service listening for requests. go func() { logger.Infof("%s: Health API listening on %s", logPrefix, cfg.HealthAPIHost) - serverErrors <- healthApi.ListenAndServe(cfg.HealthAPIHost) + serverErrors <- healthAPI.ListenAndServe(cfg.HealthAPIHost) }() // ========================================================================= diff --git a/cmd/api-firewall/tests/main_api_mode_test.go b/cmd/api-firewall/tests/main_api_mode_test.go index 3dfd6af..55c3863 100644 --- a/cmd/api-firewall/tests/main_api_mode_test.go +++ b/cmd/api-firewall/tests/main_api_mode_test.go @@ -347,7 +347,7 @@ paths: content: {} ` -var cfg = config.APIFWConfigurationAPIMode{ +var cfg = config.APIMode{ APIFWMode: config.APIFWMode{Mode: web.APIMode}, SpecificationUpdatePeriod: 2 * time.Second, UnknownParametersDetection: true, @@ -2057,19 +2057,14 @@ func TestAPIModeMockedUpdater(t *testing.T) { handler := handlersAPI.Handlers(&lock, &cfg, shutdown, logger, dbSpecBeforeUpdate) api := fasthttp.Server{Handler: handler} - updSpecErrors := make(chan error, 1) updater := updater.NewController(&lock, logger, dbSpec, &cfg, &api, shutdown, &health) go func() { t.Logf("starting specification regular update process every %.0f seconds", cfg.SpecificationUpdatePeriod.Seconds()) - updSpecErrors <- updater.Start() + updater.Start() }() time.Sleep(3 * time.Second) - if err := updater.Shutdown(); err != nil { - t.Fatal(err) - } - req := fasthttp.AcquireRequest() req.SetRequestURI("/test/new") req.Header.SetMethod("GET") @@ -2091,4 +2086,8 @@ func TestAPIModeMockedUpdater(t *testing.T) { reqCtx.Response.StatusCode()) } + if err := updater.Shutdown(); err != nil { + t.Fatal(err) + } + } diff --git a/cmd/api-firewall/tests/main_graphql_test.go b/cmd/api-firewall/tests/main_graphql_test.go new file mode 100644 index 0000000..5a8159c --- /dev/null +++ b/cmd/api-firewall/tests/main_graphql_test.go @@ -0,0 +1,1413 @@ +package tests + +import ( + "bytes" + "encoding/json" + "net/http" + "net/url" + "os" + "os/signal" + "strings" + "syscall" + "testing" + "time" + + "github.com/fasthttp/websocket" + "github.com/golang/mock/gomock" + "github.com/sirupsen/logrus" + "github.com/stretchr/testify/assert" + "github.com/valyala/fasthttp" + graphqlHandler "github.com/wallarm/api-firewall/cmd/api-firewall/internal/handlers/graphql" + "github.com/wallarm/api-firewall/internal/config" + "github.com/wallarm/api-firewall/internal/platform/denylist" + "github.com/wallarm/api-firewall/internal/platform/proxy" + "github.com/wundergraph/graphql-go-tools/pkg/graphql" +) + +type ServiceGraphQLTests struct { + serverUrl *url.URL + shutdown chan os.Signal + logger *logrus.Logger + proxy *proxy.MockPool + client *proxy.MockHTTPClient + backendWSClient *proxy.MockWebSocketClient +} + +const ( + testSchema = ` +type Chatroom { + name: String! + messages: [Message!]! +} + +type Message { + id: ID! + text: String! + createdBy: String! + createdAt: Time! +} + +type Query { + room(name:String!): Chatroom +} + +type Mutation { + post(text: String!, username: String!, roomName: String!): Message! +} + +type Subscription { + messageAdded(roomName: String!): Message! +} + +scalar Time + +directive @user(username: String!) on SUBSCRIPTION + +` +) + +type Errors struct { + Message string `json:"message"` +} + +type Response struct { + Errors []Errors `json:"errors"` +} + +func TestGraphQLBasic(t *testing.T) { + + mockCtrl := gomock.NewController(t) + defer mockCtrl.Finish() + + logger := logrus.New() + logger.SetLevel(logrus.ErrorLevel) + + serverUrl, err := url.ParseRequestURI("http://127.0.0.1:80/query") + if err != nil { + t.Fatalf("parsing API Host URL: %s", err.Error()) + } + + pool := proxy.NewMockPool(mockCtrl) + client := proxy.NewMockHTTPClient(mockCtrl) + backendWSClient := proxy.NewMockWebSocketClient(mockCtrl) + + shutdown := make(chan os.Signal, 1) + signal.Notify(shutdown, os.Interrupt, syscall.SIGTERM) + + apifwTests := ServiceGraphQLTests{ + serverUrl: serverUrl, + shutdown: shutdown, + logger: logger, + proxy: pool, + client: client, + backendWSClient: backendWSClient, + } + + // basic test + t.Run("basicGraphQLQuerySuccess", apifwTests.testGQLSuccess) + t.Run("basicGraphQLGETQuerySuccess", apifwTests.testGQLGETSuccess) + t.Run("basicGraphQLGETQueryMutationFailed", apifwTests.testGQLGETMutationFailed) + t.Run("basicGraphQLQueryValidationFailed", apifwTests.testGQLValidationFailed) + t.Run("basicGraphQLInvalidQuerySyntax", apifwTests.testGQLInvalidQuerySyntax) + t.Run("basicGraphQLQueryInvalidMaxComplexity", apifwTests.testGQLInvalidMaxComplexity) + t.Run("basicGraphQLQueryInvalidMaxDepth", apifwTests.testGQLInvalidMaxDepth) + t.Run("basicGraphQLQueryInvalidNodeLimit", apifwTests.testGQLInvalidNodeLimit) + + t.Run("basicGraphQLQueryDenylistBlock", apifwTests.testGQLDenylistBlock) + + // the sequence of messages in the tests: hello -> invalid gql message (<- response from APIFW) -> valid gql message -> complete -> stop + t.Run("basicGraphQLQuerySubscription", apifwTests.testGQLSubscription) + t.Run("basicGraphQLQuerySubscriptionLogOnly", apifwTests.testGQLSubscriptionLogOnly) +} + +func (s *ServiceGraphQLTests) testGQLSuccess(t *testing.T) { + + gqlCfg := config.GraphQL{ + MaxQueryComplexity: 0, + MaxQueryDepth: 0, + NodeCountLimit: 0, + Playground: false, + Introspection: false, + Schema: "", + RequestValidation: "BLOCK", + } + var cfg = config.GraphQLMode{ + Graphql: gqlCfg, + } + + // parse the GraphQL schema + schema, err := graphql.NewSchemaFromString(testSchema) + if err != nil { + t.Fatalf("Loading GraphQL Schema error: %v", err) + } + + handler := graphqlHandler.Handlers(&cfg, schema, s.serverUrl, s.shutdown, s.logger, s.proxy, s.backendWSClient, nil) + + // Construct GraphQL request payload + query := ` + query { + room(name: "GeneralChat") { + name + messages { + id + text + createdBy + createdAt + } + } +} + ` + var requestBody = map[string]interface{}{ + "query": query, + } + + responseBody := `{ + "data": { + "room": { + "name": "GeneralChat", + "messages": [ + { + "id": "TrsXJcKa", + "text": "Hello, world!", + "createdBy": "TestUser", + "createdAt": "2023-01-01T00:00:00+00:00" + } + ] + } + } +}` + + jsonValue, _ := json.Marshal(requestBody) + + req := fasthttp.AcquireRequest() + req.SetRequestURI("/query") + req.Header.SetMethod("POST") + req.SetBodyStream(bytes.NewReader(jsonValue), -1) + req.Header.SetContentType("application/json") + + resp := fasthttp.AcquireResponse() + resp.SetStatusCode(fasthttp.StatusOK) + resp.Header.SetContentType("application/json") + resp.SetBody([]byte(responseBody)) + + reqCtx := fasthttp.RequestCtx{ + Request: *req, + } + + s.proxy.EXPECT().Get().Return(s.client, nil) + s.client.EXPECT().Do(gomock.Any(), gomock.Any()).SetArg(1, *resp) + s.proxy.EXPECT().Put(s.client).Return(nil) + + handler(&reqCtx) + + if reqCtx.Response.StatusCode() != 200 { + t.Errorf("Incorrect response status code. Expected: 200 and got %d", + reqCtx.Response.StatusCode()) + } + + recvBody := strings.TrimSpace(string(reqCtx.Response.Body())) + + if recvBody != responseBody { + t.Errorf("Incorrect response status code. Expected: %s and got %s", + responseBody, recvBody) + } + +} + +func (s *ServiceGraphQLTests) testGQLGETSuccess(t *testing.T) { + + gqlCfg := config.GraphQL{ + MaxQueryComplexity: 0, + MaxQueryDepth: 0, + NodeCountLimit: 0, + Playground: false, + Introspection: false, + Schema: "", + RequestValidation: "BLOCK", + } + var cfg = config.GraphQLMode{ + Graphql: gqlCfg, + } + + // parse the GraphQL schema + schema, err := graphql.NewSchemaFromString(testSchema) + if err != nil { + t.Fatalf("Loading GraphQL Schema error: %v", err) + } + + handler := graphqlHandler.Handlers(&cfg, schema, s.serverUrl, s.shutdown, s.logger, s.proxy, s.backendWSClient, nil) + + // Construct GraphQL request payload + query := ` + query { + room(name: "GeneralChat") { + name + messages { + id + text + createdBy + createdAt + } + } +} + ` + + responseBody := `{ + "data": { + "room": { + "name": "GeneralChat", + "messages": [ + { + "id": "TrsXJcKa", + "text": "Hello, world!", + "createdBy": "TestUser", + "createdAt": "2023-01-01T00:00:00+00:00" + } + ] + } + } +}` + + req := fasthttp.AcquireRequest() + req.SetRequestURI("/query") + req.Header.SetMethod("GET") + //req.SetBodyStream(bytes.NewReader(jsonValue), -1) + req.URI().QueryArgs().Add("query", url.QueryEscape(query)) + + testqqq, _ := url.QueryUnescape(url.QueryEscape(query)) + t.Log(url.QueryEscape(query)) + t.Log(testqqq) + //req.Header.SetContentType("application/json") + + resp := fasthttp.AcquireResponse() + resp.SetStatusCode(fasthttp.StatusOK) + resp.Header.SetContentType("application/json") + resp.SetBody([]byte(responseBody)) + + reqCtx := fasthttp.RequestCtx{ + Request: *req, + } + + s.proxy.EXPECT().Get().Return(s.client, nil) + s.client.EXPECT().Do(gomock.Any(), gomock.Any()).SetArg(1, *resp) + s.proxy.EXPECT().Put(s.client).Return(nil) + + handler(&reqCtx) + + if reqCtx.Response.StatusCode() != 200 { + t.Errorf("Incorrect response status code. Expected: 200 and got %d", + reqCtx.Response.StatusCode()) + } + + recvBody := strings.TrimSpace(string(reqCtx.Response.Body())) + + if recvBody != responseBody { + t.Errorf("Incorrect response status code. Expected: %s and got %s", + responseBody, recvBody) + } + +} + +func (s *ServiceGraphQLTests) testGQLGETMutationFailed(t *testing.T) { + + gqlCfg := config.GraphQL{ + MaxQueryComplexity: 0, + MaxQueryDepth: 0, + NodeCountLimit: 0, + Playground: false, + Introspection: false, + Schema: "", + RequestValidation: "BLOCK", + } + var cfg = config.GraphQLMode{ + Graphql: gqlCfg, + } + + // parse the GraphQL schema + schema, err := graphql.NewSchemaFromString(testSchema) + if err != nil { + t.Fatalf("Loading GraphQL Schema error: %v", err) + } + + handler := graphqlHandler.Handlers(&cfg, schema, s.serverUrl, s.shutdown, s.logger, s.proxy, s.backendWSClient, nil) + + // Construct GraphQL request payload + query := ` + mutation TestMut { + post(text: "hi", username: "test", roomName: "GeneralChat") { + id + text + createdBy + createdAt + } +} + ` + + responseBody := `{ + "data": { + "room": { + "name": "GeneralChat", + "messages": [ + { + "id": "TrsXJcKa", + "text": "Hello, world!", + "createdBy": "TestUser", + "createdAt": "2023-01-01T00:00:00+00:00" + } + ] + } + } +}` + + req := fasthttp.AcquireRequest() + req.SetRequestURI("/query") + req.Header.SetMethod("GET") + //req.SetBodyStream(bytes.NewReader(jsonValue), -1) + req.URI().QueryArgs().Add("query", url.QueryEscape(query)) + + testqqq, _ := url.QueryUnescape(url.QueryEscape(query)) + t.Log(url.QueryEscape(query)) + t.Log(testqqq) + //req.Header.SetContentType("application/json") + + resp := fasthttp.AcquireResponse() + resp.SetStatusCode(fasthttp.StatusOK) + resp.Header.SetContentType("application/json") + resp.SetBody([]byte(responseBody)) + + reqCtx := fasthttp.RequestCtx{ + Request: *req, + } + + //s.proxy.EXPECT().Get().Return(s.client, nil) + //s.client.EXPECT().Do(gomock.Any(), gomock.Any()).SetArg(1, *resp) + //s.proxy.EXPECT().Put(s.client).Return(nil) + + handler(&reqCtx) + + if reqCtx.Response.StatusCode() != 200 { + t.Errorf("Incorrect response status code. Expected: 200 and got %d", + reqCtx.Response.StatusCode()) + } + + gqlResp := new(Response) + + if err := json.Unmarshal(reqCtx.Response.Body(), gqlResp); err != nil { + t.Error(err) + } + + if len(gqlResp.Errors) != 1 { + t.Errorf("Incorrect number of errors in the response. Expected: 1 and got %d", + len(gqlResp.Errors)) + } + + expectedErrMsg := "wrong GraphQL query type in GET request" + if gqlResp.Errors[0].Message != expectedErrMsg { + t.Errorf("Incorrect error message in the response. Expected: %s and got %s", + expectedErrMsg, gqlResp.Errors[0].Message) + } + +} + +func (s *ServiceGraphQLTests) testGQLValidationFailed(t *testing.T) { + + gqlCfg := config.GraphQL{ + MaxQueryComplexity: 0, + MaxQueryDepth: 0, + NodeCountLimit: 0, + Playground: false, + Introspection: false, + Schema: "", + RequestValidation: "BLOCK", + } + var cfg = config.GraphQLMode{ + Graphql: gqlCfg, + } + + // parse the GraphQL schema + schema, err := graphql.NewSchemaFromString(testSchema) + if err != nil { + t.Fatalf("Loading GraphQL Schema error: %v", err) + } + + handler := graphqlHandler.Handlers(&cfg, schema, s.serverUrl, s.shutdown, s.logger, s.proxy, s.backendWSClient, nil) + + // Construct GraphQL request payload + query := ` + query { + room(name: "GeneralChat") { + name + messages { + id + wrongParameter + text + createdBy + createdAt + } + } +} + ` + var requestBody = map[string]interface{}{ + "query": query, + } + + jsonValue, _ := json.Marshal(requestBody) + + req := fasthttp.AcquireRequest() + req.SetRequestURI("/query") + req.Header.SetMethod("POST") + req.SetBodyStream(bytes.NewReader(jsonValue), -1) + req.Header.SetContentType("application/json") + + reqCtx := fasthttp.RequestCtx{ + Request: *req, + } + + handler(&reqCtx) + + if reqCtx.Response.StatusCode() != 200 { + t.Errorf("Incorrect response status code. Expected: 200 and got %d", + reqCtx.Response.StatusCode()) + } + + gqlResp := new(Response) + + if err := json.Unmarshal(reqCtx.Response.Body(), gqlResp); err != nil { + t.Error(err) + } + + if len(gqlResp.Errors) != 1 { + t.Errorf("Incorrect number of errors in the response. Expected: 1 and got %d", + len(gqlResp.Errors)) + } + + expectedErrMsg := "field: wrongParameter not defined on type: Message" + if gqlResp.Errors[0].Message != expectedErrMsg { + t.Errorf("Incorrect error message in the response. Expected: %s and got %s", + expectedErrMsg, gqlResp.Errors[0].Message) + } + +} + +func (s *ServiceGraphQLTests) testGQLInvalidQuerySyntax(t *testing.T) { + + gqlCfg := config.GraphQL{ + MaxQueryComplexity: 0, + MaxQueryDepth: 0, + NodeCountLimit: 0, + Playground: false, + Introspection: false, + Schema: "", + RequestValidation: "BLOCK", + } + var cfg = config.GraphQLMode{ + Graphql: gqlCfg, + } + + // parse the GraphQL schema + schema, err := graphql.NewSchemaFromString(testSchema) + if err != nil { + t.Fatalf("Loading GraphQL Schema error: %v", err) + } + + handler := graphqlHandler.Handlers(&cfg, schema, s.serverUrl, s.shutdown, s.logger, s.proxy, s.backendWSClient, nil) + + // Construct GraphQL request payload + query := ` + query { + room(name: "GeneralChat") { + name + messages { + { + id + text + createdBy + createdAt + } + } +}; + ` + var requestBody = map[string]interface{}{ + "query": query, + } + + jsonValue, _ := json.Marshal(requestBody) + + req := fasthttp.AcquireRequest() + req.SetRequestURI("/query") + req.Header.SetMethod("POST") + req.SetBodyStream(bytes.NewReader(jsonValue), -1) + req.Header.SetContentType("application/json") + + reqCtx := fasthttp.RequestCtx{ + Request: *req, + } + + handler(&reqCtx) + + if reqCtx.Response.StatusCode() != 200 { + t.Errorf("Incorrect response status code. Expected: 200 and got %d", + reqCtx.Response.StatusCode()) + } + + gqlResp := new(Response) + + if err := json.Unmarshal(reqCtx.Response.Body(), gqlResp); err != nil { + t.Error(err) + } + + if len(gqlResp.Errors) != 1 { + t.Errorf("Incorrect number of errors in the response. Expected: 1 and got %d", + len(gqlResp.Errors)) + } + + expectedErrMsg := "unexpected token - got: LBRACE want one of: [RBRACE IDENT SPREAD]" + if gqlResp.Errors[0].Message != expectedErrMsg { + t.Errorf("Incorrect error message in the response. Expected: %s and got %s", + expectedErrMsg, gqlResp.Errors[0].Message) + } + +} + +func (s *ServiceGraphQLTests) testGQLInvalidMaxComplexity(t *testing.T) { + + gqlCfg := config.GraphQL{ + MaxQueryComplexity: 1, + MaxQueryDepth: 0, + NodeCountLimit: 0, + Playground: false, + Introspection: false, + Schema: "", + RequestValidation: "BLOCK", + } + var cfg = config.GraphQLMode{ + Graphql: gqlCfg, + } + + // parse the GraphQL schema + schema, err := graphql.NewSchemaFromString(testSchema) + if err != nil { + t.Fatalf("Loading GraphQL Schema error: %v", err) + } + + handler := graphqlHandler.Handlers(&cfg, schema, s.serverUrl, s.shutdown, s.logger, s.proxy, s.backendWSClient, nil) + + // Construct GraphQL request payload + query := ` + query { + room(name: "GeneralChat") { + name + messages { + id + text + createdBy + createdAt + } + } +} + ` + var requestBody = map[string]interface{}{ + "query": query, + } + + responseBody := `{ + "data": { + "room": { + "name": "GeneralChat", + "messages": [ + { + "id": "TrsXJcKa", + "text": "Hello, world!", + "createdBy": "TestUser", + "createdAt": "2023-01-01T00:00:00+00:00" + } + ] + } + } +}` + + jsonValue, _ := json.Marshal(requestBody) + + req := fasthttp.AcquireRequest() + req.SetRequestURI("/query") + req.Header.SetMethod("POST") + req.SetBodyStream(bytes.NewReader(jsonValue), -1) + req.Header.SetContentType("application/json") + + resp := fasthttp.AcquireResponse() + resp.SetStatusCode(fasthttp.StatusOK) + resp.Header.SetContentType("application/json") + resp.SetBody([]byte(responseBody)) + + reqCtx := fasthttp.RequestCtx{ + Request: *req, + } + + handler(&reqCtx) + + if reqCtx.Response.StatusCode() != 200 { + t.Errorf("Incorrect response status code. Expected: 200 and got %d", + reqCtx.Response.StatusCode()) + } + + gqlResp := new(Response) + + if err := json.Unmarshal(reqCtx.Response.Body(), &gqlResp); err != nil { + t.Fatal(err) + } + + if len(gqlResp.Errors) != 1 { + t.Errorf("Incorrect amount of errors in the response. Expected: 1 and got %d", + len(gqlResp.Errors)) + } + + expectedErrMsg := "the maximum query complexity value has been exceeded. The maximum query complexity value is 1. The current query complexity is 2" + + if gqlResp.Errors[0].Message != expectedErrMsg { + t.Errorf("Incorrect error message. Expected: \"%s\" and got \"%s\"", + expectedErrMsg, gqlResp.Errors[0].Message) + } + +} + +func (s *ServiceGraphQLTests) testGQLInvalidMaxDepth(t *testing.T) { + + gqlCfg := config.GraphQL{ + MaxQueryComplexity: 0, + MaxQueryDepth: 1, + NodeCountLimit: 0, + Playground: false, + Introspection: false, + Schema: "", + RequestValidation: "BLOCK", + } + var cfg = config.GraphQLMode{ + Graphql: gqlCfg, + } + + // parse the GraphQL schema + schema, err := graphql.NewSchemaFromString(testSchema) + if err != nil { + t.Fatalf("Loading GraphQL Schema error: %v", err) + } + + handler := graphqlHandler.Handlers(&cfg, schema, s.serverUrl, s.shutdown, s.logger, s.proxy, s.backendWSClient, nil) + + // Construct GraphQL request payload + query := ` + query { + room(name: "GeneralChat") { + name + messages { + id + text + createdBy + createdAt + } + } +} + ` + var requestBody = map[string]interface{}{ + "query": query, + } + + responseBody := `{ + "data": { + "room": { + "name": "GeneralChat", + "messages": [ + { + "id": "TrsXJcKa", + "text": "Hello, world!", + "createdBy": "TestUser", + "createdAt": "2023-01-01T00:00:00+00:00" + } + ] + } + } +}` + + jsonValue, _ := json.Marshal(requestBody) + + req := fasthttp.AcquireRequest() + req.SetRequestURI("/query") + req.Header.SetMethod("POST") + req.SetBodyStream(bytes.NewReader(jsonValue), -1) + req.Header.SetContentType("application/json") + + resp := fasthttp.AcquireResponse() + resp.SetStatusCode(fasthttp.StatusOK) + resp.Header.SetContentType("application/json") + resp.SetBody([]byte(responseBody)) + + reqCtx := fasthttp.RequestCtx{ + Request: *req, + } + + handler(&reqCtx) + + if reqCtx.Response.StatusCode() != 200 { + t.Errorf("Incorrect response status code. Expected: 200 and got %d", + reqCtx.Response.StatusCode()) + } + + gqlResp := new(Response) + + if err := json.Unmarshal(reqCtx.Response.Body(), &gqlResp); err != nil { + t.Fatal(err) + } + + if len(gqlResp.Errors) != 1 { + t.Errorf("Incorrect amount of errors in the response. Expected: 1 and got %d", + len(gqlResp.Errors)) + } + + expectedErrMsg := "the maximum query depth value has been exceeded. The maximum query depth value is 1. The current query depth is 3" + + if gqlResp.Errors[0].Message != expectedErrMsg { + t.Errorf("Incorrect error message. Expected: \"%s\" and got \"%s\"", + expectedErrMsg, gqlResp.Errors[0].Message) + } + +} + +func (s *ServiceGraphQLTests) testGQLInvalidNodeLimit(t *testing.T) { + + gqlCfg := config.GraphQL{ + MaxQueryComplexity: 0, + MaxQueryDepth: 0, + NodeCountLimit: 1, + Playground: false, + Introspection: false, + Schema: "", + RequestValidation: "BLOCK", + } + var cfg = config.GraphQLMode{ + Graphql: gqlCfg, + } + + // parse the GraphQL schema + schema, err := graphql.NewSchemaFromString(testSchema) + if err != nil { + t.Fatalf("Loading GraphQL Schema error: %v", err) + } + + handler := graphqlHandler.Handlers(&cfg, schema, s.serverUrl, s.shutdown, s.logger, s.proxy, s.backendWSClient, nil) + + // Construct GraphQL request payload + query := ` + query { + room(name: "GeneralChat") { + name + messages { + id + text + createdBy + createdAt + } + } +} + ` + + var requestBody = map[string]interface{}{ + "query": query, + } + + responseBody := `{ + "data": { + "room": { + "name": "GeneralChat", + "messages": [ + { + "id": "TrsXJcKa", + "text": "Hello, world!", + "createdBy": "TestUser", + "createdAt": "2023-01-01T00:00:00+00:00" + } + ] + } + } +}` + + jsonValue, _ := json.Marshal(requestBody) + + req := fasthttp.AcquireRequest() + req.SetRequestURI("/query") + req.Header.SetMethod("POST") + req.SetBodyStream(bytes.NewReader(jsonValue), -1) + req.Header.SetContentType("application/json") + + resp := fasthttp.AcquireResponse() + resp.SetStatusCode(fasthttp.StatusOK) + resp.Header.SetContentType("application/json") + resp.SetBody([]byte(responseBody)) + + reqCtx := fasthttp.RequestCtx{ + Request: *req, + } + + handler(&reqCtx) + + if reqCtx.Response.StatusCode() != 200 { + t.Errorf("Incorrect response status code. Expected: 200 and got %d", + reqCtx.Response.StatusCode()) + } + + gqlResp := new(Response) + + if err := json.Unmarshal(reqCtx.Response.Body(), &gqlResp); err != nil { + t.Fatal(err) + } + + if len(gqlResp.Errors) != 1 { + t.Errorf("Incorrect amount of errors in the response. Expected: 1 and got %d", + len(gqlResp.Errors)) + } + + expectedErrMsg := "the query node limit has been exceeded. The query node count limit is 1. The current query node count value is 2" + + if gqlResp.Errors[0].Message != expectedErrMsg { + t.Errorf("Incorrect error message. Expected: \"%s\" and got \"%s\"", + expectedErrMsg, gqlResp.Errors[0].Message) + } + +} + +func (s *ServiceGraphQLTests) testGQLDenylistBlock(t *testing.T) { + + gqlCfg := config.GraphQL{ + MaxQueryComplexity: 0, + MaxQueryDepth: 0, + NodeCountLimit: 0, + Playground: false, + Introspection: false, + Schema: "", + RequestValidation: "BLOCK", + } + + tokensCfg := config.Token{ + CookieName: testDeniedCookieName, + HeaderName: "", + File: "../../../resources/test/tokens/test.db", + } + + var cfg = config.GraphQLMode{ + Graphql: gqlCfg, + Denylist: config.Denylist{Tokens: tokensCfg}, + } + + // parse the GraphQL schema + schema, err := graphql.NewSchemaFromString(testSchema) + if err != nil { + t.Fatalf("Loading GraphQL Schema error: %v", err) + } + + deniedTokens, err := denylist.New(&cfg.Denylist, s.logger) + if err != nil { + t.Fatal(err) + } + + handler := graphqlHandler.Handlers(&cfg, schema, s.serverUrl, s.shutdown, s.logger, s.proxy, s.backendWSClient, deniedTokens) + + // Construct GraphQL request payload + query := ` + query { + room(name: "GeneralChat") { + name + messages { + id + text + createdBy + createdAt + } + } +} + ` + var requestBody = map[string]interface{}{ + "query": query, + } + + jsonValue, _ := json.Marshal(requestBody) + + req := fasthttp.AcquireRequest() + req.SetRequestURI("/query") + req.Header.SetMethod("POST") + req.SetBodyStream(bytes.NewReader(jsonValue), -1) + req.Header.SetContentType("application/json") + + // add denied token to the Cookie header of the successful HTTP request (200) + req.Header.SetCookie(testDeniedCookieName, testDeniedToken) + + reqCtx := fasthttp.RequestCtx{ + Request: *req, + } + + handler(&reqCtx) + + if reqCtx.Response.StatusCode() != 401 { + t.Errorf("Incorrect response status code. Expected: 401 and got %d", + reqCtx.Response.StatusCode()) + } + + gqlResp := new(Response) + + if err := json.Unmarshal(reqCtx.Response.Body(), gqlResp); err != nil { + t.Error(err) + } + + if len(gqlResp.Errors) != 1 { + t.Errorf("Incorrect number of errors in the response. Expected: 1 and got %d", + len(gqlResp.Errors)) + } + + expectedErrMsg := "access denied" + if gqlResp.Errors[0].Message != expectedErrMsg { + t.Errorf("Incorrect error message in the response. Expected: %s and got %s", + expectedErrMsg, gqlResp.Errors[0].Message) + } + +} + +var ( + msg0cWrongMessage = []byte("wrongMessage") + + msg0c = []byte("{\"type\":\"connection_init\",\"payload\":{}}") + msg1c = []byte("{\"id\":\"1\",\"type\":\"start\",\"payload\":{\"variables\":{},\"extensions\":{},\"operationName\":\"NewMessageInGeneralChat\",\"query\":\"subscription NewMessageInGeneralChat {\\n messageAdded(roomName: \\\"GeneralChat\\\") {\\n id\\n text\\n createdBy\\n createdAt\\n }\\n}\\n\"}}") + msg2c = []byte("{\"id\":\"1\",\"type\":\"stop\"}") + msg0s = []byte("{\"type\":\"connection_ack\"}") + msg1s = []byte("{\"payload\":{\"data\":{\"messageAdded\":{\"id\":\"gjmnSpbt\",\"text\":\"You've joined the room\",\"createdBy\":\"system\",\"createdAt\":\"2023-00-00T00:00:00.000000+03:00\"}}},\"id\":\"1\",\"type\":\"data\"}") + msg2s = []byte("{\"id\":\"1\",\"type\":\"complete\"}") + + msg1cInvalid = []byte("{\"id\":\"1\",\"type\":\"start\",\"payload\":{\"variables\":{},\"extensions\":{},\"operationName\":\"NewMessageInGeneralChat\",\"query\":\"subscription NewMessageInGeneralChat {\\n messageAdded(roomName: \\\"GeneralChat\\\"WRONGSYNTAX\\n id\\n text\\n WrongParameter\\n createdBy\\n createdAt\\n }\\n}\\n\"}}") + msg1cInvalidResp = []byte("{\"id\":\"1\",\"type\":\"error\",\"payload\":[{\"message\":\"invalid graphql request\"}]}") + + msg1cWrong = []byte("{\"id\":\"1\",\"type\":\"start\",\"payload\":{\"variables\":{},\"extensions\":{},\"operationName\":\"NewMessageInGeneralChat\",\"query\":\"subscription NewMessageInGeneralChat {\\n messageAdded(roomName: \\\"GeneralChat\\\") {\\n id\\n text\\n WrongParameter\\n createdBy\\n createdAt\\n }\\n}\\n\"}}") + msg1sWrong = []byte("{\"id\":\"1\",\"type\":\"error\",\"payload\":[{\"message\":\"field: WrongParameter not defined on type: Message\",\"path\":[\"subscription\",\"messageAdded\",\"WrongParameter\"]}]}") + msg1sWrongFromBackend = []byte("{\"id\":\"1\",\"type\":\"error\",\"payload\":[{\"message\":\"field: WrongParameter not defined on type: Message\"}]}") +) + +func startWSBackendServer(t *testing.T, addr string) *fasthttp.Server { + upgrader := websocket.FastHTTPUpgrader{ + Subprotocols: []string{"graphql-ws"}, + } + + gqlHandler := func(ctx *fasthttp.RequestCtx) { + t.Logf("recv headers: %v\n", string(ctx.Request.Header.Header())) + + err := upgrader.Upgrade(ctx, func(ws *websocket.Conn) { + defer ws.Close() + for { + mt, message, err := ws.ReadMessage() + assert.Nil(t, err) + if err != nil { + t.Error(err) + break + } + + assert.Equal(t, websocket.TextMessage, mt) + assert.Equal(t, message, msg0c) + + err = ws.WriteMessage(websocket.TextMessage, msg0s) + assert.Nil(t, err) + if err != nil { + t.Error(err) + break + } + + // read again + mt, message, err = ws.ReadMessage() + assert.Nil(t, err) + if err != nil { + t.Error(err) + break + } + + if bytes.Equal(message, msg1cWrong) { + err = ws.WriteMessage(websocket.TextMessage, msg1sWrongFromBackend) + assert.Nil(t, err) + if err != nil { + t.Error(err) + break + } + + mt, message, err = ws.ReadMessage() + assert.Nil(t, err) + if err != nil { + t.Error(err) + break + } + } + + assert.Equal(t, websocket.TextMessage, mt) + assert.Equal(t, message, msg1c) + + err = ws.WriteMessage(websocket.TextMessage, msg1s) + assert.Nil(t, err) + if err != nil { + t.Error(err) + break + } + + mt, message, err = ws.ReadMessage() + assert.Nil(t, err) + if err != nil { + t.Error(err) + break + } + + assert.Equal(t, websocket.TextMessage, mt) + assert.Equal(t, message, msg2c) + + err = ws.WriteMessage(websocket.TextMessage, msg2s) + assert.Nil(t, err) + if err != nil { + t.Error(err) + break + } + + mt, message, err = ws.ReadMessage() + if websocket.IsCloseError(err, websocket.CloseNormalClosure) { + break + } + } + }) + + if err != nil { + if _, ok := err.(websocket.HandshakeError); ok { + assert.Errorf(t, err, "websocket handshake") + } + return + } + } + + // backend server initializing + server := fasthttp.Server{ + Handler: func(ctx *fasthttp.RequestCtx) { + switch string(ctx.Path()) { + case "/graphql": + gqlHandler(ctx) + } + }, + } + + go func() { + // backend websocket server + if err := server.ListenAndServe(addr); err != nil { + assert.Errorf(t, err, "websocket backend server `ListenAndServe` quit") + } + }() + + return &server +} + +func (s *ServiceGraphQLTests) testGQLSubscription(t *testing.T) { + + gqlCfg := config.GraphQL{ + MaxQueryComplexity: 0, + MaxQueryDepth: 0, + NodeCountLimit: 0, + Playground: false, + Introspection: false, + Schema: "", + WSCheckOrigin: true, + WSOrigin: []string{"http://localhost:19091"}, + RequestValidation: "BLOCK", + } + var cfg = config.GraphQLMode{ + Graphql: gqlCfg, + Server: config.Backend{ + URL: "http://localhost:19090/graphql", + }, + } + + // start backend + server := startWSBackendServer(t, "localhost:19090") + defer server.Shutdown() + + // parse the GraphQL schema + schema, err := graphql.NewSchemaFromString(testSchema) + if err != nil { + t.Fatalf("Loading GraphQL Schema error: %v", err) + } + + serverUrl, err := url.ParseRequestURI(cfg.Server.URL) + assert.Nil(t, err) + + handler := graphqlHandler.Handlers(&cfg, schema, serverUrl, s.shutdown, s.logger, s.proxy, s.backendWSClient, nil) + + // connection to the backend + headers := http.Header{} + headers.Set("Sec-WebSocket-Protocol", "graphql-ws") + headers.Set("Origin", "http://localhost:19090") + + wsBackendConn, wsResp, err := websocket.DefaultDialer.Dial("ws://localhost:19090/graphql", headers) + if err != nil { + t.Fatal(err) + } + + assert.Equal(t, http.StatusSwitchingProtocols, wsResp.StatusCode) + t.Logf("got resp from backend server (ws connection): %+v", wsResp) + + newConn := proxy.FastHTTPWebSocketConn{ + Conn: wsBackendConn, + } + + s.backendWSClient.EXPECT().GetConn(gomock.Any()).Times(1).Return(&newConn, nil) + + srv := fasthttp.Server{ + Handler: handler, + } + + go func() { + if err := srv.ListenAndServe("localhost:19091"); err != nil { + t.Errorf("websocket proxy server `ListenAndServe` quit, err=%v\n", err) + } + }() + + defer srv.Shutdown() + + time.Sleep(1 * time.Second) + + headers = http.Header{} + headers.Set("Sec-WebSocket-Protocol", "graphql-ws") + headers.Set("Origin", "http://localhost:19091") + + wsClientConn, wsClientResp, err := websocket.DefaultDialer.Dial("ws://localhost:19091/graphql", headers) + if err != nil { + t.Fatal(err) + } + + assert.Equal(t, http.StatusSwitchingProtocols, wsClientResp.StatusCode) + t.Logf("got resp from APIFW (ws connection): %+v", wsClientResp) + + // client send wrong graphql-ws message and it will be dropped + err = wsClientConn.WriteMessage(websocket.TextMessage, msg0cWrongMessage) + assert.Nil(t, err) + t.Log("sent (will be dropped):", string(msg0c)) + + // client send + err = wsClientConn.WriteMessage(websocket.TextMessage, msg0c) + assert.Nil(t, err) + t.Log("sent:", string(msg0c)) + + messageType, p, err := wsClientConn.ReadMessage() + assert.Nil(t, err) + assert.NotNil(t, p) + assert.Equal(t, websocket.TextMessage, messageType) + assert.NotZero(t, p) + assert.Equal(t, msg0s, p) + + // client send wrong request + err = wsClientConn.WriteMessage(websocket.TextMessage, msg1cWrong) + assert.Nil(t, err) + t.Log("sent:", string(msg1cWrong)) + + messageType, p, err = wsClientConn.ReadMessage() + assert.Nil(t, err) + assert.NotNil(t, p) + assert.Equal(t, websocket.TextMessage, messageType) + assert.NotZero(t, p) + assert.Equal(t, msg1sWrong, p) + + messageType, p, err = wsClientConn.ReadMessage() + assert.Nil(t, err) + assert.NotNil(t, p) + assert.Equal(t, websocket.TextMessage, messageType) + assert.NotZero(t, p) + assert.Equal(t, msg2s, p) + + // client send wrong request + err = wsClientConn.WriteMessage(websocket.TextMessage, msg1cInvalid) + assert.Nil(t, err) + t.Log("sent:", string(msg1cInvalid)) + + messageType, p, err = wsClientConn.ReadMessage() + assert.Nil(t, err) + assert.NotNil(t, p) + assert.Equal(t, websocket.TextMessage, messageType) + assert.NotZero(t, p) + assert.Equal(t, msg1cInvalidResp, p) + + messageType, p, err = wsClientConn.ReadMessage() + assert.Nil(t, err) + assert.NotNil(t, p) + assert.Equal(t, websocket.TextMessage, messageType) + assert.NotZero(t, p) + assert.Equal(t, msg2s, p) + + // client send + err = wsClientConn.WriteMessage(websocket.TextMessage, msg1c) + assert.Nil(t, err) + t.Log("sent:", string(msg1c)) + + messageType, p, err = wsClientConn.ReadMessage() + assert.Nil(t, err) + assert.NotNil(t, p) + assert.Equal(t, websocket.TextMessage, messageType) + assert.NotZero(t, p) + assert.Equal(t, msg1s, p) + + // client send + err = wsClientConn.WriteMessage(websocket.TextMessage, msg2c) + assert.Nil(t, err) + t.Log("sent:", string(msg2c)) + + // client read + messageType, p, err = wsClientConn.ReadMessage() + assert.Nil(t, err) + assert.NotNil(t, p) + assert.Equal(t, websocket.TextMessage, messageType) + assert.NotZero(t, p) + assert.Equal(t, msg2s, p) + + err = wsClientConn.WriteMessage(websocket.CloseMessage, websocket.FormatCloseMessage(websocket.CloseNormalClosure, "")) + assert.Nil(t, err) + + wsClientConn.Close() + + s.backendWSClient.EXPECT().GetConn(gomock.Any()).Times(1).Return(&newConn, nil) + + // check ws connection with wrong origin + headers.Set("Origin", "http://wrongOrigin.com") + _, wsClientRespE, err := websocket.DefaultDialer.Dial("ws://localhost:19091/graphql", headers) + assert.NotNil(t, err) + + assert.Equal(t, fasthttp.StatusForbidden, wsClientRespE.StatusCode) + t.Logf("got resp from APIFW (ws connection): %+v", wsClientRespE) +} + +func (s *ServiceGraphQLTests) testGQLSubscriptionLogOnly(t *testing.T) { + + gqlCfg := config.GraphQL{ + MaxQueryComplexity: 0, + MaxQueryDepth: 0, + NodeCountLimit: 0, + Playground: false, + Introspection: false, + Schema: "", + RequestValidation: "LOG_ONLY", + } + var cfg = config.GraphQLMode{ + Graphql: gqlCfg, + Server: config.Backend{ + URL: "http://localhost:19092/graphql", + }, + } + + // start backend + server := startWSBackendServer(t, "localhost:19092") + defer server.Shutdown() + + // parse the GraphQL schema + schema, err := graphql.NewSchemaFromString(testSchema) + if err != nil { + t.Fatalf("Loading GraphQL Schema error: %v", err) + } + + serverUrl, err := url.ParseRequestURI(cfg.Server.URL) + assert.Nil(t, err) + + handler := graphqlHandler.Handlers(&cfg, schema, serverUrl, s.shutdown, s.logger, s.proxy, s.backendWSClient, nil) + + // connection to the backend + headers := http.Header{} + headers.Set("Sec-WebSocket-Protocol", "graphql-ws") + headers.Set("Origin", "http://localhost:19092") + + wsBackendConn, wsResp, err := websocket.DefaultDialer.Dial("ws://localhost:19092/graphql", headers) + if err != nil { + t.Fatal(err) + } + + assert.Equal(t, http.StatusSwitchingProtocols, wsResp.StatusCode) + t.Logf("got resp: %+v", wsResp) + + newConn := proxy.FastHTTPWebSocketConn{ + Conn: wsBackendConn, + } + + s.backendWSClient.EXPECT().GetConn(gomock.Any()).Times(1).Return(&newConn, nil) + + srv := fasthttp.Server{ + Handler: handler, + } + + go func() { + if err := srv.ListenAndServe("localhost:19093"); err != nil { + t.Errorf("websocket proxy server `ListenAndServe` quit, err=%v\n", err) + } + }() + + defer srv.Shutdown() + + time.Sleep(1 * time.Second) + + headers = http.Header{} + headers.Set("Sec-WebSocket-Protocol", "graphql-ws") + headers.Set("Origin", "http://localhost:19093") + + wsClientConn, wsClientResp, err := websocket.DefaultDialer.Dial("ws://localhost:19093/graphql", headers) + if err != nil { + t.Fatal(err) + } + + assert.Equal(t, http.StatusSwitchingProtocols, wsClientResp.StatusCode) + t.Logf("got resp: %+v", wsClientResp) + + // client send + err = wsClientConn.WriteMessage(websocket.TextMessage, msg0c) + assert.Nil(t, err) + t.Log("sent:", string(msg0c)) + + messageType, p, err := wsClientConn.ReadMessage() + assert.Nil(t, err) + assert.NotNil(t, p) + assert.Equal(t, websocket.TextMessage, messageType) + assert.NotZero(t, p) + assert.Equal(t, msg0s, p) + + // client send wrong request + err = wsClientConn.WriteMessage(websocket.TextMessage, msg1cWrong) + assert.Nil(t, err) + t.Log("sent:", string(msg1cWrong)) + + messageType, p, err = wsClientConn.ReadMessage() + assert.Nil(t, err) + assert.NotNil(t, p) + assert.Equal(t, websocket.TextMessage, messageType) + assert.NotZero(t, p) + assert.Equal(t, msg1sWrongFromBackend, p) + + // client send + err = wsClientConn.WriteMessage(websocket.TextMessage, msg1c) + assert.Nil(t, err) + t.Log("sent:", string(msg1c)) + + messageType, p, err = wsClientConn.ReadMessage() + assert.Nil(t, err) + assert.NotNil(t, p) + assert.Equal(t, websocket.TextMessage, messageType) + assert.NotZero(t, p) + assert.Equal(t, msg1s, p) + + // client send + err = wsClientConn.WriteMessage(websocket.TextMessage, msg2c) + assert.Nil(t, err) + t.Log("sent:", string(msg2c)) + + // client read + messageType, p, err = wsClientConn.ReadMessage() + assert.Nil(t, err) + assert.NotNil(t, p) + assert.Equal(t, websocket.TextMessage, messageType) + assert.NotZero(t, p) + assert.Equal(t, msg2s, p) + + err = wsClientConn.WriteMessage(websocket.CloseMessage, websocket.FormatCloseMessage(websocket.CloseNormalClosure, "")) + assert.Nil(t, err) + + wsClientConn.Close() + + time.Sleep(1 * time.Second) + +} diff --git a/cmd/api-firewall/tests/main_json_test.go b/cmd/api-firewall/tests/main_json_test.go index 825c3bb..364617a 100644 --- a/cmd/api-firewall/tests/main_json_test.go +++ b/cmd/api-firewall/tests/main_json_test.go @@ -73,7 +73,7 @@ components: var ( // basic APIFW configuration - apifwCfg = config.APIFWConfiguration{ + apifwCfg = config.ProxyMode{ RequestValidation: "BLOCK", ResponseValidation: "BLOCK", CustomBlockStatusCode: 403, diff --git a/cmd/api-firewall/tests/main_test.go b/cmd/api-firewall/tests/main_test.go index 2eaa257..5ff8142 100644 --- a/cmd/api-firewall/tests/main_test.go +++ b/cmd/api-firewall/tests/main_test.go @@ -443,7 +443,7 @@ func TestBasic(t *testing.T) { func (s *ServiceTests) testBlockMode(t *testing.T) { - var cfg = config.APIFWConfiguration{ + var cfg = config.ProxyMode{ RequestValidation: "BLOCK", ResponseValidation: "BLOCK", CustomBlockStatusCode: 403, @@ -525,7 +525,7 @@ func (s *ServiceTests) testDenylist(t *testing.T) { File: "../../../resources/test/tokens/test.db", } - var cfg = config.APIFWConfiguration{ + var cfg = config.ProxyMode{ RequestValidation: "BLOCK", ResponseValidation: "BLOCK", CustomBlockStatusCode: 403, @@ -538,7 +538,7 @@ func (s *ServiceTests) testDenylist(t *testing.T) { }{Tokens: tokensCfg}, } - deniedTokens, err := denylist.New(&cfg, s.logger) + deniedTokens, err := denylist.New(&cfg.Denylist, s.logger) if err != nil { t.Fatal(err) } @@ -607,7 +607,7 @@ func (s *ServiceTests) testShadowAPI(t *testing.T) { File: "", } - var cfg = config.APIFWConfiguration{ + var cfg = config.ProxyMode{ RequestValidation: "LOG_ONLY", ResponseValidation: "LOG_ONLY", CustomBlockStatusCode: 403, @@ -620,7 +620,7 @@ func (s *ServiceTests) testShadowAPI(t *testing.T) { }{Tokens: tokensCfg}, } - deniedTokens, err := denylist.New(&cfg, s.logger) + deniedTokens, err := denylist.New(&cfg.Denylist, s.logger) if err != nil { t.Fatal(err) } @@ -668,7 +668,7 @@ func (s *ServiceTests) testShadowAPI(t *testing.T) { } func (s *ServiceTests) testLogOnlyMode(t *testing.T) { - var cfg = config.APIFWConfiguration{ + var cfg = config.ProxyMode{ RequestValidation: "LOG_ONLY", ResponseValidation: "LOG_ONLY", CustomBlockStatusCode: 403, @@ -722,7 +722,7 @@ func (s *ServiceTests) testLogOnlyMode(t *testing.T) { func (s *ServiceTests) testDisableMode(t *testing.T) { - var cfg = config.APIFWConfiguration{ + var cfg = config.ProxyMode{ RequestValidation: "DISABLE", ResponseValidation: "DISABLE", CustomBlockStatusCode: 403, @@ -773,7 +773,7 @@ func (s *ServiceTests) testDisableMode(t *testing.T) { func (s *ServiceTests) testBlockLogOnlyMode(t *testing.T) { - var cfg = config.APIFWConfiguration{ + var cfg = config.ProxyMode{ RequestValidation: "BLOCK", ResponseValidation: "LOG_ONLY", CustomBlockStatusCode: 403, @@ -825,7 +825,7 @@ func (s *ServiceTests) testBlockLogOnlyMode(t *testing.T) { func (s *ServiceTests) testLogOnlyBlockMode(t *testing.T) { - var cfg = config.APIFWConfiguration{ + var cfg = config.ProxyMode{ RequestValidation: "LOG_ONLY", ResponseValidation: "BLOCK", CustomBlockStatusCode: 403, @@ -878,7 +878,7 @@ func (s *ServiceTests) testLogOnlyBlockMode(t *testing.T) { func (s *ServiceTests) testCommonParameters(t *testing.T) { - var cfg = config.APIFWConfiguration{ + var cfg = config.ProxyMode{ RequestValidation: "BLOCK", ResponseValidation: "BLOCK", CustomBlockStatusCode: 403, @@ -1000,18 +1000,20 @@ func (s *ServiceTests) testOauthIntrospectionReadSuccess(t *testing.T) { } serverConf := config.Server{ - URL: "", - ClientPoolCapacity: 1000, - InsecureConnection: false, - RootCA: "", - MaxConnsPerHost: 512, - ReadTimeout: time.Second * 5, - WriteTimeout: time.Second * 5, - DialTimeout: time.Second * 5, - Oauth: oauthConf, - } - - var cfg = config.APIFWConfiguration{ + Backend: config.Backend{ + URL: "", + ClientPoolCapacity: 1000, + InsecureConnection: false, + RootCA: "", + MaxConnsPerHost: 512, + ReadTimeout: time.Second * 5, + WriteTimeout: time.Second * 5, + DialTimeout: time.Second * 5, + }, + Oauth: oauthConf, + } + + var cfg = config.ProxyMode{ RequestValidation: "BLOCK", ResponseValidation: "BLOCK", CustomBlockStatusCode: 403, @@ -1085,18 +1087,20 @@ func (s *ServiceTests) testOauthIntrospectionReadUnsuccessful(t *testing.T) { } serverConf := config.Server{ - URL: "", - ClientPoolCapacity: 1000, - InsecureConnection: false, - RootCA: "", - MaxConnsPerHost: 512, - ReadTimeout: time.Second * 5, - WriteTimeout: time.Second * 5, - DialTimeout: time.Second * 5, - Oauth: oauthConf, - } - - var cfg = config.APIFWConfiguration{ + Backend: config.Backend{ + URL: "", + ClientPoolCapacity: 1000, + InsecureConnection: false, + RootCA: "", + MaxConnsPerHost: 512, + ReadTimeout: time.Second * 5, + WriteTimeout: time.Second * 5, + DialTimeout: time.Second * 5, + }, + Oauth: oauthConf, + } + + var cfg = config.ProxyMode{ RequestValidation: "BLOCK", ResponseValidation: "BLOCK", CustomBlockStatusCode: 403, @@ -1149,18 +1153,20 @@ func (s *ServiceTests) testOauthIntrospectionInvalidResponse(t *testing.T) { } serverConf := config.Server{ - URL: "", - ClientPoolCapacity: 1000, - InsecureConnection: false, - RootCA: "", - MaxConnsPerHost: 512, - ReadTimeout: time.Second * 5, - WriteTimeout: time.Second * 5, - DialTimeout: time.Second * 5, - Oauth: oauthConf, - } - - var cfg = config.APIFWConfiguration{ + Backend: config.Backend{ + URL: "", + ClientPoolCapacity: 1000, + InsecureConnection: false, + RootCA: "", + MaxConnsPerHost: 512, + ReadTimeout: time.Second * 5, + WriteTimeout: time.Second * 5, + DialTimeout: time.Second * 5, + }, + Oauth: oauthConf, + } + + var cfg = config.ProxyMode{ RequestValidation: "BLOCK", ResponseValidation: "BLOCK", CustomBlockStatusCode: 403, @@ -1213,18 +1219,20 @@ func (s *ServiceTests) testOauthIntrospectionReadWriteSuccess(t *testing.T) { } serverConf := config.Server{ - URL: "", - ClientPoolCapacity: 1000, - InsecureConnection: false, - RootCA: "", - MaxConnsPerHost: 512, - ReadTimeout: time.Second * 5, - WriteTimeout: time.Second * 5, - DialTimeout: time.Second * 5, - Oauth: oauthConf, - } - - var cfg = config.APIFWConfiguration{ + Backend: config.Backend{ + URL: "", + ClientPoolCapacity: 1000, + InsecureConnection: false, + RootCA: "", + MaxConnsPerHost: 512, + ReadTimeout: time.Second * 5, + WriteTimeout: time.Second * 5, + DialTimeout: time.Second * 5, + }, + Oauth: oauthConf, + } + + var cfg = config.ProxyMode{ RequestValidation: "BLOCK", ResponseValidation: "BLOCK", CustomBlockStatusCode: 403, @@ -1282,18 +1290,20 @@ func (s *ServiceTests) testOauthIntrospectionContentTypeRequest(t *testing.T) { } serverConf := config.Server{ - URL: "", - ClientPoolCapacity: 1000, - InsecureConnection: false, - RootCA: "", - MaxConnsPerHost: 512, - ReadTimeout: time.Second * 5, - WriteTimeout: time.Second * 5, - DialTimeout: time.Second * 5, - Oauth: oauthConf, - } - - var cfg = config.APIFWConfiguration{ + Backend: config.Backend{ + URL: "", + ClientPoolCapacity: 1000, + InsecureConnection: false, + RootCA: "", + MaxConnsPerHost: 512, + ReadTimeout: time.Second * 5, + WriteTimeout: time.Second * 5, + DialTimeout: time.Second * 5, + }, + Oauth: oauthConf, + } + + var cfg = config.ProxyMode{ RequestValidation: "BLOCK", ResponseValidation: "BLOCK", CustomBlockStatusCode: 403, @@ -1343,18 +1353,20 @@ func (s *ServiceTests) testOauthJWTRS256(t *testing.T) { } serverConf := config.Server{ - URL: "", - ClientPoolCapacity: 1000, - InsecureConnection: false, - RootCA: "", - MaxConnsPerHost: 512, - ReadTimeout: time.Second * 5, - WriteTimeout: time.Second * 5, - DialTimeout: time.Second * 5, - Oauth: oauthConf, - } - - var cfg = config.APIFWConfiguration{ + Backend: config.Backend{ + URL: "", + ClientPoolCapacity: 1000, + InsecureConnection: false, + RootCA: "", + MaxConnsPerHost: 512, + ReadTimeout: time.Second * 5, + WriteTimeout: time.Second * 5, + DialTimeout: time.Second * 5, + }, + Oauth: oauthConf, + } + + var cfg = config.ProxyMode{ RequestValidation: "BLOCK", ResponseValidation: "BLOCK", CustomBlockStatusCode: 403, @@ -1418,18 +1430,20 @@ func (s *ServiceTests) testOauthJWTHS256(t *testing.T) { } serverConf := config.Server{ - URL: "", - ClientPoolCapacity: 1000, - InsecureConnection: false, - RootCA: "", - MaxConnsPerHost: 512, - ReadTimeout: time.Second * 5, - WriteTimeout: time.Second * 5, - DialTimeout: time.Second * 5, - Oauth: oauthConf, - } - - var cfg = config.APIFWConfiguration{ + Backend: config.Backend{ + URL: "", + ClientPoolCapacity: 1000, + InsecureConnection: false, + RootCA: "", + MaxConnsPerHost: 512, + ReadTimeout: time.Second * 5, + WriteTimeout: time.Second * 5, + DialTimeout: time.Second * 5, + }, + Oauth: oauthConf, + } + + var cfg = config.ProxyMode{ RequestValidation: "BLOCK", ResponseValidation: "BLOCK", CustomBlockStatusCode: 403, @@ -1478,7 +1492,7 @@ func (s *ServiceTests) testOauthJWTHS256(t *testing.T) { func (s *ServiceTests) testRequestHeaders(t *testing.T) { - var cfg = config.APIFWConfiguration{ + var cfg = config.ProxyMode{ RequestValidation: "BLOCK", ResponseValidation: "BLOCK", CustomBlockStatusCode: 403, @@ -1535,7 +1549,7 @@ func (s *ServiceTests) testRequestHeaders(t *testing.T) { func (s *ServiceTests) testResponseHeaders(t *testing.T) { - var cfg = config.APIFWConfiguration{ + var cfg = config.ProxyMode{ RequestValidation: "BLOCK", ResponseValidation: "BLOCK", CustomBlockStatusCode: 403, @@ -1596,7 +1610,7 @@ func (s *ServiceTests) testResponseHeaders(t *testing.T) { func (s *ServiceTests) testRequestBodyCompression(t *testing.T) { - var cfg = config.APIFWConfiguration{ + var cfg = config.ProxyMode{ RequestValidation: "BLOCK", ResponseValidation: "BLOCK", CustomBlockStatusCode: 403, @@ -1703,7 +1717,7 @@ func (s *ServiceTests) testRequestBodyCompression(t *testing.T) { func (s *ServiceTests) testResponseBodyCompression(t *testing.T) { - var cfg = config.APIFWConfiguration{ + var cfg = config.ProxyMode{ RequestValidation: "BLOCK", ResponseValidation: "BLOCK", CustomBlockStatusCode: 403, @@ -1792,7 +1806,7 @@ func (s *ServiceTests) testResponseBodyCompression(t *testing.T) { func (s *ServiceTests) requestOptionalCookies(t *testing.T) { - var cfg = config.APIFWConfiguration{ + var cfg = config.ProxyMode{ RequestValidation: "BLOCK", ResponseValidation: "BLOCK", CustomBlockStatusCode: 403, @@ -1880,7 +1894,7 @@ func (s *ServiceTests) requestOptionalCookies(t *testing.T) { func (s *ServiceTests) requestOptionalMinMaxCookies(t *testing.T) { - var cfg = config.APIFWConfiguration{ + var cfg = config.ProxyMode{ RequestValidation: "BLOCK", ResponseValidation: "BLOCK", CustomBlockStatusCode: 403, @@ -1982,7 +1996,7 @@ func (s *ServiceTests) requestOptionalMinMaxCookies(t *testing.T) { func (s *ServiceTests) unknownParamQuery(t *testing.T) { - var cfg = config.APIFWConfiguration{ + var cfg = config.ProxyMode{ RequestValidation: "BLOCK", ResponseValidation: "BLOCK", CustomBlockStatusCode: 403, @@ -2036,7 +2050,7 @@ func (s *ServiceTests) unknownParamQuery(t *testing.T) { func (s *ServiceTests) unknownParamPostBody(t *testing.T) { - var cfg = config.APIFWConfiguration{ + var cfg = config.ProxyMode{ RequestValidation: "BLOCK", ResponseValidation: "BLOCK", CustomBlockStatusCode: 403, @@ -2092,7 +2106,7 @@ func (s *ServiceTests) unknownParamPostBody(t *testing.T) { func (s *ServiceTests) unknownParamJSONParam(t *testing.T) { - var cfg = config.APIFWConfiguration{ + var cfg = config.ProxyMode{ RequestValidation: "BLOCK", ResponseValidation: "BLOCK", CustomBlockStatusCode: 403, @@ -2173,7 +2187,7 @@ func (s *ServiceTests) unknownParamJSONParam(t *testing.T) { func (s *ServiceTests) unknownParamUnsupportedMimeType(t *testing.T) { - var cfg = config.APIFWConfiguration{ + var cfg = config.ProxyMode{ RequestValidation: "BLOCK", ResponseValidation: "BLOCK", CustomBlockStatusCode: 403, diff --git a/demo/docker-compose/docker-compose-api-mode.yml b/demo/docker-compose/docker-compose-api-mode.yml index 0dbf49a..b8fd6c2 100644 --- a/demo/docker-compose/docker-compose-api-mode.yml +++ b/demo/docker-compose/docker-compose-api-mode.yml @@ -2,7 +2,7 @@ version: '3.8' services: api-firewall: container_name: api-firewall - image: wallarm/api-firewall:v0.6.12 + image: wallarm/api-firewall:v0.6.13 restart: on-failure environment: APIFW_MODE: "api" diff --git a/demo/docker-compose/docker-compose.yml b/demo/docker-compose/docker-compose.yml index 02bb32e..d9b3a23 100644 --- a/demo/docker-compose/docker-compose.yml +++ b/demo/docker-compose/docker-compose.yml @@ -2,7 +2,7 @@ version: '3.8' services: api-firewall: container_name: api-firewall - image: wallarm/api-firewall:v0.6.12 + image: wallarm/api-firewall:v0.6.13 restart: on-failure environment: APIFW_URL: "http://0.0.0.0:8080" diff --git a/docs.Dockerfile b/docs.Dockerfile new file mode 100644 index 0000000..69304fc --- /dev/null +++ b/docs.Dockerfile @@ -0,0 +1,15 @@ +FROM python:3.8.0 + +WORKDIR /tmp + +COPY docs/requirements.txt ./ +RUN pip install --no-cache-dir -r requirements.txt + +# Set working directory +WORKDIR /docs +VOLUME /docs +RUN rm -rf docs + +EXPOSE 8000 +ENTRYPOINT ["mkdocs"] +CMD ["serve", "--dev-addr=0.0.0.0:8000", "--config-file=mkdocs.yml"] \ No newline at end of file diff --git a/docs/configuration-guides/denylist-leaked-tokens.md b/docs/configuration-guides/denylist-leaked-tokens.md new file mode 100644 index 0000000..8bdb9e9 --- /dev/null +++ b/docs/configuration-guides/denylist-leaked-tokens.md @@ -0,0 +1,34 @@ +# Blocking Requests with Compromised Tokens + +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). + +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/). + +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. + +To enable the denylist feature: + +1. Draft a .txt file with the compromised tokens. Each token should be on a new line. Here is an example: + + ```txt + eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJzb21lIjoicGF5bG9hZDk5OTk5ODIifQ.CUq8iJ_LUzQMfDTvArpz6jUyK0Qyn7jZ9WCqE0xKTCA + eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJzb21lIjoicGF5bG9hZDk5OTk5ODMifQ.BinZ4AcJp_SQz-iFfgKOKPz_jWjEgiVTb9cS8PP4BI0 + eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJzb21lIjoicGF5bG9hZDk5OTk5ODQifQ.j5Iea7KGm7GqjMGBuEZc2akTIoByUaQc5SSX7w_qjY8 + ``` +1. Mount the denylist file to the firewall Docker container. For example, in your `docker-compose.yaml`, make the following modification: + + ```diff + ... + volumes: + - : + + - : + ... + ``` +1. Input the following environment variables when initiating the Docker container: + +| Environment variable | Description | +| -------------------- | ----------- | +| `APIFW_DENYLIST_TOKENS_FILE` | Path in the container to the mounted denylist file. Example: `/auth-data/tokens-denylist.txt`. | +| `APIFW_DENYLIST_TOKENS_COOKIE_NAME` | Name of the Cookie that carries the authentication token. | +| `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. | +| `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). | diff --git a/docs/configuration-guides/ssl-tls.md b/docs/configuration-guides/ssl-tls.md new file mode 100644 index 0000000..954aa67 --- /dev/null +++ b/docs/configuration-guides/ssl-tls.md @@ -0,0 +1,51 @@ +# SSL/TLS Configuration + +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). + +## Secure SSL/TLS connection between API Firewall and the application + +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: + +1. Mount the custom CA certificate to the API Firewall container. For example, in your `docker-compose.yaml`, make the following modification: + + ```diff + ... + volumes: + - : + + - : + ... + ``` +1. Provide the mounted file path using the following environment variables: + +| Environment variable | Description | +| -------------------- | ----------- | +| `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. | + +## Insecure connection between API Firewall and the application + +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: + +| Environment variable | Description | +| -------------------- | ----------- | +| `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`. | + +## SSL/TLS for the API Firewall server + +To ensure the server running the API Firewall accepts HTTPS connections, follow the steps below: + +1. Mount the certificate and private key directory to the API Firewall container. For example, in your `docker-compose.yaml`, make the following modification: + + ```diff + ... + volumes: + - : + + - : + ... + ``` +1. Provide mounted file paths using the following environment variables: + +| Environment variable | Description | +| -------------------- | ----------- | +| `APIFW_TLS_CERTS_PATH` | Path in the container to the directory where the certificate and private key for the API Firewall are mounted. | +| `APIFW_TLS_CERT_FILE` | Filename of the SSL/TLS certificate for the API Firewall, located within the `APIFW_TLS_CERTS_PATH` directory. | +| `APIFW_TLS_CERT_KEY` | Filename of the SSL/TLS private key for the API Firewall, found in the `APIFW_TLS_CERTS_PATH` directory. | diff --git a/docs/configuration-guides/system-settings.md b/docs/configuration-guides/system-settings.md new file mode 100644 index 0000000..09171fe --- /dev/null +++ b/docs/configuration-guides/system-settings.md @@ -0,0 +1,15 @@ +# System Settings + +To fine-tune system API Firewall settings, use the following optional environment variables: + +| Environment variable | Description | +| -------------------- | ----------- | +| `APIFW_READ_TIMEOUT` | The timeout for API Firewall to read the full request (including the body) sent to the application URL. The default value is `5s`. | +| `APIFW_WRITE_TIMEOUT` | The timeout for API Firewall to return the response to the request sent to the application URL. The default value is `5s`. | +| `APIFW_SERVER_MAX_CONNS_PER_HOST` | The maximum number of connections that API Firewall can handle simultaneously. The default value is `512`. | +| `APIFW_SERVER_READ_TIMEOUT` | The timeout for API Firewall to read the full response (including the body) returned to the request by the application. The default value is `5s`. | +| `APIFW_SERVER_WRITE_TIMEOUT` | The timeout for API Firewall to write the full request (including the body) to the application. The default value is `5s`. | +| `APIFW_SERVER_DIAL_TIMEOUT` | The timeout for API Firewall to connect to the application. The default value is `200ms`. | +| `APIFW_SERVER_CLIENT_POOL_CAPACITY` | Maximum number of the fasthttp clients. The default value is `1000`. | +| `APIFW_HEALTH_HOST` | The host of the health check service. The default value is `0.0.0.0:9667`. The liveness probe service path is `/v1/liveness` and the readiness service path is `/v1/readiness`. | + diff --git a/docs/configuration-guides/validate-tokens.md b/docs/configuration-guides/validate-tokens.md new file mode 100644 index 0000000..d928acc --- /dev/null +++ b/docs/configuration-guides/validate-tokens.md @@ -0,0 +1,23 @@ +# Validating Request Authentication Tokens + +When leveraging OAuth 2.0 for authentication, the API Firewall can be set up to validate access tokens before directing requests to your application server. The Firewall expects the access token in the `Authorization: Bearer` request header. + +API Firewall considers the token to be valid if the scopes defined in the [specification](https://swagger.io/docs/specification/authentication/oauth2/) and in the token meta information are the same. If the value of `APIFW_REQUEST_VALIDATION` is `BLOCK`, API Firewall blocks requests with invalid tokens. In the `LOG_ONLY` mode, requests with invalid tokens are only logged. + +!!! info "Feature availability" + This feature is available only when running API Firewall for [REST API](../installation-guides/docker-container.md) request filtering. + +To configure the OAuth 2.0 token validation flow, use the following environment variables: + +| Environment variable | Description | +| -------------------- | ----------- | +| `APIFW_SERVER_OAUTH_VALIDATION_TYPE` | The type of authentication token validation:
  • `JWT` if using JWT for request authentication. Perform further configuration via the `APIFW_SERVER_OAUTH_JWT_*` variables.
  • `INTROSPECTION` if using other token types that can be validated by the particular token introspection service. Perform further configuration via the `APIFW_SERVER_OAUTH_INTROSPECTION_*` variables.
| +| `APIFW_SERVER_OAUTH_JWT_SIGNATURE_ALGORITHM` | The algorithm being used to sign JWTs: `RS256`, `RS384`, `RS512`, `HS256`, `HS384` or `HS512`.

JWTs signed using the `ECDSA` algorithm cannot be validated by API Firewall. | +| `APIFW_SERVER_OAUTH_JWT_PUB_CERT_FILE` | If JWTs are signed using the RS256, RS384 or RS512 algorithm, the path to the file with the RSA public key (`*.pem`). This file must be mounted to the API Firewall Docker container. | +| `APIFW_SERVER_OAUTH_JWT_SECRET_KEY` | If JWTs are signed using the HS256, HS384 or HS512 algorithm, the secret key value being used to sign JWTs. | +| `APIFW_SERVER_OAUTH_INTROSPECTION_ENDPOINT` | [Token introspection endpoint](https://www.oauth.com/oauth2-servers/token-introspection-endpoint/). Endpoint examples:
  • `https://www.googleapis.com/oauth2/v1/tokeninfo` if using Google OAuth
  • `http://sample.com/restv1/introspection` for Gluu OAuth 2.0 tokens
| +| `APIFW_SERVER_OAUTH_INTROSPECTION_ENDPOINT_METHOD` | The method of the requests to the token introspection endpoint. Can be `GET` or `POST`.

The default value is `GET`. | +| `APIFW_SERVER_OAUTH_INTROSPECTION_TOKEN_PARAM_NAME` | The name of the parameter with the token value in the requests to the introspection endpoint. Depending on the `APIFW_SERVER_OAUTH_INTROSPECTION_ENDPOINT_METHOD` value, API Firewall automatically considers the parameter to be either the query or body parameter. | +| `APIFW_SERVER_OAUTH_INTROSPECTION_CLIENT_AUTH_BEARER_TOKEN` | The Bearer token value to authenticate the requests to the introspection endpoint. | +| `APIFW_SERVER_OAUTH_INTROSPECTION_CONTENT_TYPE` | The value of the `Content-Type` header indicating the media type of the token introspection service. The default value is `application/octet-stream`. | +| `APIFW_SERVER_OAUTH_INTROSPECTION_REFRESH_INTERVAL` | Time-to-live of cached token metadata. API Firewall caches token metadata and if getting requests with the same tokens, gets its metadata from the cache.

The interval can be set in hours (`h`), minutes (`m`), seconds (`s`) or in the combined format (e.g. `1h10m50s`).

The default value is `10m` (10 minutes). | diff --git a/docs/installation-guides/api-mode.md b/docs/installation-guides/api-mode.md new file mode 100644 index 0000000..cfc9a2d --- /dev/null +++ b/docs/installation-guides/api-mode.md @@ -0,0 +1,31 @@ +# Validating Individual Requests Without Proxying + +If you need to validate individual API requests based on a given OpenAPI specification without further proxying, you can utilize Wallarm API Firewall in a non-proxy mode. In this case, the solution does not validate responses. + +!!! info "Feature availability" + This feature is available for the API Firewall versions 0.6.12 and later, and it is tailored for REST API. + +To do so: + +1. Instead of [mounting the OpenAPI specification](../installation-guides/docker-container.md) file to the container, mount the [SQLite database](https://www.sqlite.org/index.html) containing one or more OpenAPI 3.0 specifications to `/var/lib/wallarm-api/1/wallarm_api.db`. The database should adhere to the following schema: + + * `schema_id`, integer (auto-increment) - ID of the specification. + * `schema_version`, string - Specification version. You can assign any preferred version. When this field changes, API Firewall assumes the specification itself has changed and updates it accordingly. + * `schema_format`, string - The specification format, can be `json` or `yaml`. + * `schema_content`, string - The specification content. +1. Run the container with the environment variable `APIFW_MODE=API` and if needed, with other variables that specifically designed for this mode: + + | Environment variable | Description | + | -------------------- | ----------- | + | `APIFW_MODE` | Sets the general API Firewall mode. Possible values are [`PROXY`](docker-container.md) (default), [`graphql`](graphql/docker-container.md), and `API`. | + | `APIFW_SPECIFICATION_UPDATE_PERIOD` | Determines the frequency of specification updates. If set to `0`, the specification update is disabled. The default value is `1m` (1 minute). | + | `APIFW_API_MODE_UNKNOWN_PARAMETERS_DETECTION` | Specifies whether to return an error code if the request parameters do not match those defined in the the specification. The default value is `true`. | + | `APIFW_PASS_OPTIONS` | When set to `true`, the API Firewall allows `OPTIONS` requests to endpoints in the specification, even if the `OPTIONS` method is not described. The default value is `false`. | + +1. When evaluating whether requests align with the mounted specifications, include the header `X-Wallarm-Schema-ID: ` to indicate to API Firewall which specification should be used for validation. + +API Firewall validates requests as follows: + +* If a request matches the specification, an empty response with a 200 status code is returned. +* If a request does not match the specification, the response will provide a 403 status code and a JSON document explaining the reasons for the mismatch. +* If it is unable to handle or validate a request, an empty response with a 500 status code is returned. diff --git a/docs/installation-guides/docker-container.md b/docs/installation-guides/docker-container.md index 42a7bdb..0920a1f 100644 --- a/docs/installation-guides/docker-container.md +++ b/docs/installation-guides/docker-container.md @@ -1,6 +1,6 @@ -# Running API Firewall on Docker +# Running API Firewall on Docker for REST API -This guide walks through downloading, installing, and starting [Wallarm API Firewall](../index.md) on Docker. +This guide walks through downloading, installing, and starting [Wallarm API Firewall](../index.md) on Docker for REST API request validation. ## Requirements @@ -27,7 +27,7 @@ networks: services: api-firewall: container_name: api-firewall - image: wallarm/api-firewall:v0.6.12 + image: wallarm/api-firewall:v0.6.13 restart: on-failure volumes: - : @@ -93,11 +93,9 @@ Pass API Firewall configuration in **docker-compose.yml** → `services.api-fire | `APIFW_SERVER_DELETE_ACCEPT_ENCODING` | If it is set to `true`, the `Accept-Encoding` header is deleted from proxied requests. The default value is `false`. | No | | `APIFW_LOG_FORMAT` | The format of API Firewall logs. The value can be `TEXT` or `JSON`. The default value is `TEXT`. | No | | `APIFW_SHADOW_API_EXCLUDE_LIST`
(only if API Firewall is operating in the `LOG_ONLY` mode for both the requests and responses) | [HTTP response status codes](https://en.wikipedia.org/wiki/List_of_HTTP_status_codes) indicating that the requested API endpoint that is not included in the specification is NOT a shadow one. You can specify several status codes separated by a semicolon (e.g. `404;401`). The default value is `404`.

By default, API Firewall operating in the `LOG_ONLY` mode for both the requests and responses marks all endpoints that are not included in the specification and are returning the code different from `404` as the shadow ones. | No -| `APIFW_MODE` | Sets the general API Firewall mode. Possible values are `PROXY` (default) and [`API`](#validating-individual-requests-without-proxying-for-v0612-and-above). | No | +| `APIFW_MODE` | Sets the general API Firewall mode. Possible values are `PROXY` (default), [`graphql`](graphql/docker-container.md) and [`API`](api-mode.md). | No | | `APIFW_PASS_OPTIONS` | When set to `true`, the API Firewall allows `OPTIONS` requests to endpoints in the specification, even if the `OPTIONS` method is not described. The default value is `false`. | No | -| `APIFW_SHADOW_API_UNKNOWN_PARAMETERS_DETECTION` | This specifies whether requests are identified as non-matching the specification if their parameters do not align with those defined in the OpenAPI specification. The default value is `true`.

If running API Firewall in the [`API` mode](#validating-individual-requests-without-proxying-for-v0612-and-above), this variable takes on a different name `APIFW_API_MODE_UNKNOWN_PARAMETERS_DETECTION`. | No | - -More API Firewall configuration options are described within the [link](#api-firewall-fine-tuning-options). +| `APIFW_SHADOW_API_UNKNOWN_PARAMETERS_DETECTION` | This specifies whether requests are identified as non-matching the specification if their parameters do not align with those defined in the OpenAPI specification. The default value is `true`.

If running API Firewall in the [`API` mode](api-mode.md), this variable takes on a different name `APIFW_API_MODE_UNKNOWN_PARAMETERS_DETECTION`. | No | **With `services.api-firewall.ports` and `services.api-firewall.networks`**, set the API Firewall container port and connect the container to the created network. The provided **docker-compose.yml** instructs Docker to start API Firewall connected to the `api-firewall-network` [network](https://docs.docker.com/network/) on the port 8088. @@ -125,116 +123,6 @@ If the request does not match the provided API schema, the appropriate ERROR mes To finalize the API Firewall configuration, please enable incoming traffic on API Firewall by updating your application deployment scheme configuration. For example, this would require updating the Ingress, NGINX, or load balancer settings. -## API Firewall fine-tuning options - -To address more business issues by API Firewall, you can fine-tune the tool operation. Supported fine-tuning options are listed below. Please pass them as environment variables when [configuring the API Firewall Docker container](#step-4-configure-api-firewall). - -### Validation of request authentication tokens - -If using OAuth 2.0 protocol-based authentication, you can configure API Firewall to validate the access tokens before proxying requests to the application's server. API Firewall expects the access token to be passed in the `Authorization: Bearer` request header. - -API Firewall considers the token to be valid if the scopes defined in the [specification](https://swagger.io/docs/specification/authentication/oauth2/) and in the token meta information are the same. If the value of `APIFW_REQUEST_VALIDATION` is `BLOCK`, API Firewall blocks requests with invalid tokens. In the `LOG_ONLY` mode, requests with invalid tokens are only logged. - -To configure the OAuth 2.0 token validation flow, use the following optional environment variables: - -| Environment variable | Description | -| -------------------- | ----------- | -| `APIFW_SERVER_OAUTH_VALIDATION_TYPE` | The type of authentication token validation:
  • `JWT` if using JWT for request authentication. Perform further configuration via the `APIFW_SERVER_OAUTH_JWT_*` variables.
  • `INTROSPECTION` if using other token types that can be validated by the particular token introspection service. Perform further configuration via the `APIFW_SERVER_OAUTH_INTROSPECTION_*` variables.
| -| `APIFW_SERVER_OAUTH_JWT_SIGNATURE_ALGORITHM` | The algorithm being used to sign JWTs: `RS256`, `RS384`, `RS512`, `HS256`, `HS384` or `HS512`.

JWTs signed using the `ECDSA` algorithm cannot be validated by API Firewall. | -| `APIFW_SERVER_OAUTH_JWT_PUB_CERT_FILE` | If JWTs are signed using the RS256, RS384 or RS512 algorithm, the path to the file with the RSA public key (`*.pem`). This file must be mounted to the API Firewall Docker container. | -| `APIFW_SERVER_OAUTH_JWT_SECRET_KEY` | If JWTs are signed using the HS256, HS384 or HS512 algorithm, the secret key value being used to sign JWTs. | -| `APIFW_SERVER_OAUTH_INTROSPECTION_ENDPOINT` | [Token introspection endpoint](https://www.oauth.com/oauth2-servers/token-introspection-endpoint/). Endpoint examples:
  • `https://www.googleapis.com/oauth2/v1/tokeninfo` if using Google OAuth
  • `http://sample.com/restv1/introspection` for Gluu OAuth 2.0 tokens
| -| `APIFW_SERVER_OAUTH_INTROSPECTION_ENDPOINT_METHOD` | The method of the requests to the token introspection endpoint. Can be `GET` or `POST`.

The default value is `GET`. | -| `APIFW_SERVER_OAUTH_INTROSPECTION_TOKEN_PARAM_NAME` | The name of the parameter with the token value in the requests to the introspection endpoint. Depending on the `APIFW_SERVER_OAUTH_INTROSPECTION_ENDPOINT_METHOD` value, API Firewall automatically considers the parameter to be either the query or body parameter. | -| `APIFW_SERVER_OAUTH_INTROSPECTION_CLIENT_AUTH_BEARER_TOKEN` | The Bearer token value to authenticate the requests to the introspection endpoint. | -| `APIFW_SERVER_OAUTH_INTROSPECTION_CONTENT_TYPE` | The value of the `Content-Type` header indicating the media type of the token introspection service. The default value is `application/octet-stream`. | -| `APIFW_SERVER_OAUTH_INTROSPECTION_REFRESH_INTERVAL` | Time-to-live of cached token metadata. API Firewall caches token metadata and if getting requests with the same tokens, gets its metadata from the cache.

The interval can be set in hours (`h`), minutes (`m`), seconds (`s`) or in the combined format (e.g. `1h10m50s`).

The default value is `10m` (10 minutes). | - -### Blocking requests with compromised authentication tokens - -If an API leak is detected, the Wallarm API Firewall is able to [stop the compromised authentication tokens from being used](https://lab.wallarm.com/oss-api-firewall-unveils-new-feature-blacklist-for-compromised-api-tokens-and-cookies/). If the request contains compromised tokens, API Firewall responses to this request with the code configured via [`APIFW_CUSTOM_BLOCK_STATUS_CODE`](#apifw-custom-block-status-code). - -To enable the denylist feature: - -1. Mount the denylist file with compromised tokens into the Docker container. The denylist text file may look as follows: - - ```txt - eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJzb21lIjoicGF5bG9hZDk5OTk5ODIifQ.CUq8iJ_LUzQMfDTvArpz6jUyK0Qyn7jZ9WCqE0xKTCA - eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJzb21lIjoicGF5bG9hZDk5OTk5ODMifQ.BinZ4AcJp_SQz-iFfgKOKPz_jWjEgiVTb9cS8PP4BI0 - eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJzb21lIjoicGF5bG9hZDk5OTk5ODQifQ.j5Iea7KGm7GqjMGBuEZc2akTIoByUaQc5SSX7w_qjY8 - ``` -2. Configure the denylist feature passing the following variables to the Docker container: - - | Environment variable | Description | - | -------------------- | ----------- | - | `APIFW_DENYLIST_TOKENS_FILE` | The path to the text denylist file mounted to the container. The tokens in the file must be separated by newlines. Example value: `/api-firewall/resources/tokens-denylist.txt`. | - | `APIFW_DENYLIST_TOKENS_COOKIE_NAME` | The name of the Cookie used to pass the authentication token. | - | `APIFW_DENYLIST_TOKENS_HEADER_NAME` | The name of the Header used to pass the authentication token. If both the `APIFW_DENYLIST_TOKENS_COOKIE_NAME` and `APIFW_DENYLIST_TOKENS_HEADER_NAME` variables are specified, API Firewall sequentially checks its values. | - | `APIFW_DENYLIST_TOKENS_TRIM_BEARER_PREFIX` | Whether to trim the `Bearer` prefix from the authentication header when comparing its value with the denylist contents. If the `Bearer` prefix is passed in the authentication header and tokens in the denylist do not contain this prefix, tokens will not be validated reliably.
The value can be `true` or `false`. The default value is `false`. | - -### Protected application SSL/TLS settings - -To facilitate the connection between API Firewall and the protected application's server signed with the custom CA certificates or insecure connection, use the following optional environment variables: - -| Environment variable | Description | -| -------------------- | ----------- | -| `APIFW_SERVER_INSECURE_CONNECTION` | Whether to disable validation of the SSL/TLS certificate of the protected application server. The server address is specified in the variable `APIFW_SERVER_URL`.

The default value is `false` - all connections to the application are attempted to be made secure by using the CA certificate installed by default or the one specified in `APIFW_SERVER_ROOT_CA`. | -| `APIFW_SERVER_ROOT_CA`
(only if the `APIFW_SERVER_INSECURE_CONNECTION` value is `false`) | The path to the protected application server's CA certificate in the Docker container. The CA certificate must be mounted to the API Firewall Docker container first. | - -### API Firewall SSL/TLS settings - -To set up SSL/TLS for the server with the running API Firewall, use the following optional environment variables: - -| Environment variable | Description | -| -------------------- | ----------- | -| `APIFW_TLS_CERTS_PATH` | The path to the container directory with the mounted certificate and private key generated for API Firewall. | -| `APIFW_TLS_CERT_FILE` | The name of the file with the SSL/TLS certificate generated for API Firewall and located in the directory specified in `APIFW_TLS_CERTS_PATH`. | -| `APIFW_TLS_CERT_KEY` | The name of the file with the SSL/TLS private key generated for API Firewall and located in the directory specified in `APIFW_TLS_CERTS_PATH`. | - -### Validating individual requests without proxying (for v0.6.12 and above) - -If you need to validate individual API requests based on a given OpenAPI specification without further proxying, you can utilize Wallarm API Firewall in a non-proxy mode. In this case, the solution does not validate responses. - -To do so: - -1. Instead of mounting the specification file to the container, mount the [SQLite database](https://www.sqlite.org/index.html) containing one or more OpenAPI 3.0 specifications to `/var/lib/wallarm-api/1/wallarm_api.db`. The database should adhere to the following schema: - - * `schema_id`, integer (auto-increment) - ID of the specification. - * `schema_version`, string - Specification version. You can assign any preferred version. When this field changes, API Firewall assumes the specification itself has changed and updates it accordingly. - * `schema_format`, string - The specification format, can be `json` or `yaml`. - * `schema_content`, string - The specification content. -1. Run the container with the environment variable `APIFW_MODE=API` and if needed, with other variables that specifically designed for this mode: - - | Environment variable | Description | - | -------------------- | ----------- | - | `APIFW_MODE` | Sets the general API Firewall mode. Possible values are `PROXY` (default) and `API`. | - | `APIFW_SPECIFICATION_UPDATE_PERIOD` | Determines the frequency of specification updates. If set to `0`, the specification update is disabled. The default value is `1m` (1 minute). | - | `APIFW_API_MODE_UNKNOWN_PARAMETERS_DETECTION` | Specifies whether to return an error code if the request parameters do not match those defined in the the specification. The default value is `true`. | - | `APIFW_PASS_OPTIONS` | When set to `true`, the API Firewall allows `OPTIONS` requests to endpoints in the specification, even if the `OPTIONS` method is not described. The default value is `false`. | - -1. When evaluating whether requests align with the mounted specifications, include the header `X-Wallarm-Schema-ID: ` to indicate to API Firewall which specification should be used for validation. - -API Firewall validates requests as follows: - -* If a request matches the specification, an empty response with a 200 status code is returned. -* If a request does not match the specification, the response will provide a 403 status code and a JSON document explaining the reasons for the mismatch. -* If it is unable to handle or validate a request, an empty response with a 500 status code is returned. - -### System settings - -To fine-tune system API Firewall settings, use the following optional environment variables: - -| Environment variable | Description | -| -------------------- | ----------- | -| `APIFW_READ_TIMEOUT` | The timeout for API Firewall to read the full request (including the body) sent to the application URL. The default value is `5s`. | -| `APIFW_WRITE_TIMEOUT` | The timeout for API Firewall to return the response to the request sent to the application URL. The default value is `5s`. | -| `APIFW_SERVER_MAX_CONNS_PER_HOST` | The maximum number of connections that API Firewall can handle simultaneously. The default value is `512`. | -| `APIFW_SERVER_READ_TIMEOUT` | The timeout for API Firewall to read the full response (including the body) returned to the request by the application. The default value is `5s`. | -| `APIFW_SERVER_WRITE_TIMEOUT` | The timeout for API Firewall to write the full request (including the body) to the application. The default value is `5s`. | -| `APIFW_SERVER_DIAL_TIMEOUT` | The timeout for API Firewall to connect to the application. The default value is `200ms`. | -| `APIFW_SERVER_CLIENT_POOL_CAPACITY` | Maximum number of the fasthttp clients. The default value is `1000`. | -| `APIFW_HEALTH_HOST` | The host of the health check service. The default value is `0.0.0.0:9667`. The liveness probe service path is `/v1/liveness` and the readiness service path is `/v1/readiness`. | - ## Stopping the deployed environment To stop the environment deployed using Docker Compose, run the following command: @@ -265,6 +153,6 @@ To start API Firewall on Docker, you can also use regular Docker commands as in -v : -e APIFW_API_SPECS= \ -e APIFW_URL= -e APIFW_SERVER_URL= \ -e APIFW_REQUEST_VALIDATION= -e APIFW_RESPONSE_VALIDATION= \ - -p 8088:8088 wallarm/api-firewall:v0.6.12 + -p 8088:8088 wallarm/api-firewall:v0.6.13 ``` 4. When the environment is started, test it and enable traffic on API Firewall following steps 6 and 7. diff --git a/docs/installation-guides/graphql/docker-container.md b/docs/installation-guides/graphql/docker-container.md new file mode 100644 index 0000000..ea2e694 --- /dev/null +++ b/docs/installation-guides/graphql/docker-container.md @@ -0,0 +1,187 @@ +# Running API Firewall on Docker for GraphQL API + +This guide walks through downloading, installing, and starting [Wallarm API Firewall](../../index.md) on Docker for GraphQL API request validation. In GraphQL mode, the API Firewall acts as a proxy, forwarding GraphQL requests from users to the backend server using either HTTP or the WebSocket (`graphql-ws`) protocols. Before the backend execution, the firewall checks the query complexity, depth, and node count of the GraphQL query. + +The API Firewall does not validate GraphQL query responses. + +## Requirements + +* [Installed and configured Docker](https://docs.docker.com/get-docker/) +* [GraphQL specification](http://spec.graphql.org/October2021/) developed for the GraphQL API of the application that should be protected with Wallarm API Firewall + +## Methods to run API Firewall on Docker + +The fastest method to deploy API Firewall on Docker is [Docker Compose](https://docs.docker.com/compose/). The steps below rely on using this method. + +If required, you can also use `docker run`. We have provided proper `docker run` commands to deploy the same environment in [this section](#using-docker-run-to-start-api-firewall). + +## Step 1. Create the `docker-compose.yml` file + +To deploy API Firewall and proper environment using Docker Compose, create the **docker-compose.yml** with the following content first. In the further steps, you will change this template. + +```yml +version: '3.8' + +networks: + api-firewall-network: + name: api-firewall-network + +services: + api-firewall: + container_name: api-firewall + image: wallarm/api-firewall:v0.6.13 + restart: on-failure + volumes: + - : + environment: + APIFW_MODE: graphql + APIFW_GRAPHQL_SCHEMA: + APIFW_URL: + APIFW_SERVER_URL: + APIFW_GRAPHQL_REQUEST_VALIDATION: + APIFW_GRAPHQL_MAX_QUERY_COMPLEXITY: + APIFW_GRAPHQL_MAX_QUERY_DEPTH: + APIFW_GRAPHQL_NODE_COUNT_LIMIT: + APIFW_GRAPHQL_INTROSPECTION: + ports: + - "8088:8088" + stop_grace_period: 1s + networks: + - api-firewall-network + backend: + container_name: api-firewall-backend + image: + restart: on-failure + ports: + - : + stop_grace_period: 1s + networks: + - api-firewall-network +``` + +## Step 2. Configure the Docker network + +If required, change the [Docker network](https://docs.docker.com/network/) configuration defined in **docker-compose.yml** → `networks`. + +The provided **docker-compose.yml** instructs Docker to create the network `api-firewall-network` and link the application and API Firewall containers to it. + +It is recommended to use a separate Docker network for protected contanerized application and API Firewall to allow their communication without manual linking. + +## Step 3. Configure the application to be protected with API Firewall + +Change the configuration of the containerized application to be protected with API Firewall. This configuration is defined in **docker-compose.yml** → `services.backend`. + +The template instructs Docker to boot the specified application Docker container, connecting it to the `api-firewall-network` and designating the `backend` [network alias](https://docs.docker.com/config/containers/container-networking/#ip-address-and-hostname). You can define the port as per your requirements. + +When setting up your application, include only the necessary settings for a successful container launch. No special API Firewall configuration is required. + +## Step 4. Configure API Firewall + +Pass API Firewall configuration in **docker-compose.yml** → `services.api-firewall` as follows: + +**With `services.api-firewall.volumes`**, mount the [GraphQL specification](http://spec.graphql.org/October2021/) to the API Firewall container directory: + +* ``: the path to the GraphQL specification for your API located on the host machine. The file format does not matter but usually it is `.graphql` or `gql`. For example: `/opt/my-api/graphql/schema.graphql`. +* ``: the path to the container directory to mount the GraphQL specification to. For example: `/api-firewall/resources/schema.graphql`. + +**With `services.api-firewall.environment`**, please set the general API Firewall configuration through the following environment variables: + +| Environment variable | Description | Required? | +| -------------------- | ----------- | --------- | +| `APIFW_MODE` | Sets the general API Firewall mode. Possible values are [`PROXY`](../docker-container.md) (default), `graphql` and [`API`](../api-mode.md). | No | +| `APIFW_GRAPHQL_SCHEMA` | Path to the GraphQL specification file mounted to the container, for example: `/api-firewall/resources/schema.graphql`. | Yes | +| `APIFW_URL` | URL for API Firewall. For example: `http://0.0.0.0:8088/`. The port value should correspond to the container port published to the host.

If API Firewall listens to the HTTPS protocol, please mount the generated SSL/TLS certificate and private key to the container, and pass to the container the **API Firewall SSL/TLS settings** described below. | Yes | +| `APIFW_SERVER_URL` | URL of the application described in the mounted specification that should be protected with API Firewall. For example: `http://backend:80`. | Yes | +| `APIFW_GRAPHQL_REQUEST_VALIDATION` | API Firewall mode when validating requests sent to the application URL:
  • `BLOCK` blocks and logs requests not matching the mounted GraphQL schema, returning a `403 Forbidden`. Logs are sent to the [`STDOUT` and `STDERR` Docker services](https://docs.docker.com/config/containers/logging/).
  • `LOG_ONLY` logs (but does not block) mismatched requests.
  • `DISABLE` turns off request validation.
This variable impacts all other parameters, except [`APIFW_GRAPHQL_WS_CHECK_ORIGIN`](websocket-origin-check.md). For instance, if `APIFW_GRAPHQL_INTROSPECTION` is `false` and the mode is `LOG_ONLY`, introspection requests reach the backend server, but API Firewall generates a corresponding error log. | Yes | +| `APIFW_GRAPHQL_MAX_QUERY_COMPLEXITY` | [Defines](limit-compliance.md) the maximum number of Node requests that might be needed to execute the query. Setting it to `0` disables the complexity check. The default value is `0`. | Yes | +| `APIFW_GRAPHQL_MAX_QUERY_DEPTH` | [Specifies](limit-compliance.md) the maximum permitted depth of a GraphQL query. A value of `0` means the query depth check is skipped. | Yes | +| `APIFW_GRAPHQL_NODE_COUNT_LIMIT` | [Sets](limit-compliance.md) the upper limit for the node count in a query. When set to `0`, the node count limit check is skipped. | Yes | +| `APIFW_GRAPHQL_INTROSPECTION` | Allows introspection queries, which disclose the layout of your GraphQL schema. When set to `true`, these queries are permitted. | Yes | +| `APIFW_LOG_LEVEL` | API Firewall logging level. Possible values:
  • `DEBUG` to log events of any type (INFO, ERROR, WARNING, and DEBUG).
  • `INFO` to log events of the INFO, WARNING, and ERROR types.
  • `WARNING` to log events of the WARNING and ERROR types.
  • `ERROR` to log events of only the ERROR type.
  • `TRACE` to log incoming requests and API Firewall responses, including their content.
The default value is `DEBUG`. Logs on requests and responses that do not match the provided schema have the ERROR type. | No | +| `APIFW_SERVER_DELETE_ACCEPT_ENCODING` | If it is set to `true`, the `Accept-Encoding` header is deleted from proxied requests. The default value is `false`. | No | +| `APIFW_LOG_FORMAT` | The format of API Firewall logs. The value can be `TEXT` or `JSON`. The default value is `TEXT`. | No | + +**With `services.api-firewall.ports` and `services.api-firewall.networks`**, set the API Firewall container port and connect the container to the created network. + +## Step 5. Deploy the configured environment + +To build and start the configured environment, run the following command: + +```bash +docker-compose up -d --force-recreate +``` + +To check the log output: + +```bash +docker-compose logs -f +``` + +## Step 6. Test API Firewall operation + +To test API Firewall operation, send the request that does not match the mounted GraphQL specification to the API Firewall Docker container address. + +With `APIFW_GRAPHQL_REQUEST_VALIDATION` set to `BLOCK`, the firewall works as follows: + +* If the API Firewall allows the request, it proxies the request to the backend server. +* If the API Firewall cannot parse the request, it responds with the GraphQL error with a 500 status code. +* If the validation fails by the API Firewall, it does not proxy the request to the backend server but responds to the client with 200 status code and GraphQL error in response. + +If the request does not match the provided API schema, the appropriate ERROR message will be added to the API Firewall Docker container logs, e.g. in the JSON format: + +```json +{ + "errors": [ + { + "message": "field: name not defined on type: Query", + "path": [ + "query", + "name" + ] + } + ] +} +``` + +In scenarios where multiple fields in the request are invalid, only a singular error message will be generated. + +## Step 7. Enable traffic on API Firewall + +To finalize the API Firewall configuration, please enable incoming traffic on API Firewall by updating your application deployment scheme configuration. For example, this would require updating the Ingress, NGINX, or load balancer settings. + +## Stopping the deployed environment + +To stop the environment deployed using Docker Compose, run the following command: + +```bash +docker-compose down +``` + +## Using `docker run` to start API Firewall + +To start API Firewall on Docker, you can also use regular Docker commands as in the examples below: + +1. [To create a separate Docker network](#step-2-configure-the-docker-network) to allow the containerized application and API Firewall communication without manual linking: + + ```bash + docker network create api-firewall-network + ``` +2. [Start the containerized application](#step-3-configure-the-application-to-be-protected-with-api-firewall) to be protected with API Firewall: + + ```bash + docker run --rm -it --network api-firewall-network \ + --network-alias backend -p : + ``` +3. [To start API Firewall](#step-4-configure-api-firewall): + + ```bash + docker run --rm -it --network api-firewall-network --network-alias api-firewall \ + -v : -e APIFW_MODE=graphql \ + -e APIFW_GRAPHQL_SCHEMA= -e APIFW_URL= \ + -e APIFW_SERVER_URL= -e APIFW_GRAPHQL_REQUEST_VALIDATION= \ + -e APIFW_GRAPHQL_MAX_QUERY_COMPLEXITY= \ + -e APIFW_GRAPHQL_MAX_QUERY_DEPTH= -e APIFW_GRAPHQL_NODE_COUNT_LIMIT= \ + -e APIFW_GRAPHQL_INTROSPECTION= \ + -p 8088:8088 wallarm/api-firewall:v0.6.13 + ``` +4. When the environment is started, test it and enable traffic on API Firewall following steps 6 and 7. diff --git a/docs/installation-guides/graphql/limit-compliance.md b/docs/installation-guides/graphql/limit-compliance.md new file mode 100644 index 0000000..88460ef --- /dev/null +++ b/docs/installation-guides/graphql/limit-compliance.md @@ -0,0 +1,288 @@ +# GraphQL Limits Compliance + +You can configure the API Firewall to validate incoming GraphQL queries against predefined query constraints. By adhering to these limits, you can shield your GraphQL API from malicious queries, including potential DoS attacks. This guide explains how the firewall calculates query attributes like node requests, query depth, and complexity before aligning them with your set parameters. + +When [running](docker-container.md) the API Firewall Docker container for a GraphQL API, you set limits using the following environment variables: + +| Environment variable | Description | +| -------------------- | ----------- | +| `APIFW_GRAPHQL_MAX_QUERY_COMPLEXITY` | Defines the maximum number of Node requests that might be needed to execute the query. Setting it to `0` disables the complexity check. | +| `APIFW_GRAPHQL_MAX_QUERY_DEPTH` | Specifies the maximum permitted depth of a GraphQL query. A value of `0` means the query depth check is skipped. | +| `APIFW_GRAPHQL_NODE_COUNT_LIMIT` | Sets the upper limit for the node count in a query. When set to `0`, the node count limit check is skipped. | + +## How limit calculation works + +API Firewall leverages the [wundergraph/graphql-go-tools](https://github.com/wundergraph/graphql-go-tools) library, which adopts algorithms similar to those used by GitHub for calculating GraphQL query complexity. Central to this is the `OperationComplexityEstimator` function, which processes a schema definition and a query, iteratively examining the query to get both its complexity and depth. + +You can fine-tune this calculation by integrating integer arguments on fields that signify the number of Nodes a field returns: + +* `directive @nodeCountMultiply on ARGUMENT_DEFINITION` + + Indicates that the Int value the directive is applied on should be used as a Node multiplier. +* `directive @nodeCountSkip on FIELD` + Indicates that the algorithm should skip this Node. This is useful to whitelist certain query paths, e.g. for introspection. + +For documents with multiple queries, the calculated complexity, depth, and node count apply to the whole document, not just the single query being run. + +## Calculation examples + +Below there are a few examples which will grant a clearer perspective on the calculations. They are based on the following GraphQL schema: + +``` +type User { + name: String! + messages(first: Int! @nodeCountMultiply): [Message] +} + +type Message { + id: ID! + text: String! + createdBy: String! + createdAt: Time! +} + +type Query { + __schema: __Schema! @nodeCountSkip + users(first: Int! @nodeCountMultiply): [User] + messages(first: Int! @nodeCountMultiply): [Message] +} + +type Mutation { + post(text: String!, username: String!, roomName: String!): Message! +} + +type Subscription { + messageAdded(roomName: String!): Message! +} + +scalar Time + +directive @nodeCountMultiply on ARGUMENT_DEFINITION +directive @nodeCountSkip on FIELD +``` + +The depth always represents the nesting levels of fields. For instance, the query below has a depth of 3: + +``` +{ + a { + b { + c + } + } +} +``` + +### Example 1 + +``` +query { + users(first: 10) { + name + messages(first:100) { + id + text + } + } +} +``` + +* NodeCount = {int} 1010 + + ``` + Node count = 10 [users(first: 10)] + 10*100 [messages(first:100)] = 1010 + ``` + +* Complexity = {int} 11 + + ``` + Complexity = 1 [users(first: 10)] + 10 [messages(first:100)] = 11 + ``` + +* Depth = {int} 3 + +### Example 2 + +``` +query { + users(first: 10) { + name + } +} +``` + +* NodeCount = {int} 10 + + ``` + Node count = 10 [users(first: 10)] = 10 + ``` + +* Complexity = {int} 1 + + ``` + Complexity = 1 [users(first: 10)] = 1 + ``` +* Depth = {int} 2 + +### Example 3 + +``` +query { + message(id:1) { + id + text + } +} +``` + +* NodeCount = {int} 1 + + ``` + Node count = 1 [message(fid:1)] = 1 + ``` + +* Complexity = {int} 1 + + ``` + Complexity = 1 [messages(first:1)] = 1 + ``` + +* Depth = {int} 2 + +### Example 4 + +``` +query { + users(first: 10) { + name + messages(first:1) { + id + text + } + } +} +``` + +* NodeCount = {int} 20 + + ``` + Node count = 10 [users(first: 10)] + 10*1 [messages(first:1)] = 20 + ``` + +* Complexity = {int} 11 + + ``` + Complexity = 1 [users(first: 10)] + 10 [messages(first:1)] = 11 + ``` + +* Depth = {int} 3 + +### Example 5 (introspection query) + +``` +query IntrospectionQuery { + __schema { + queryType { + name + } + mutationType { + name + } + subscriptionType { + name + } + types { + ...FullType + } + directives { + name + description + locations + args { + ...InputValue + } + } + } +} + +fragment FullType on __Type { + kind + name + description + fields(includeDeprecated: true) { + name + description + args { + ...InputValue + } + type { + ...TypeRef + } + isDeprecated + deprecationReason + } + inputFields { + ...InputValue + } + interfaces { + ...TypeRef + } + enumValues(includeDeprecated: true) { + name + description + isDeprecated + deprecationReason + } + possibleTypes { + ...TypeRef + } +} + +fragment InputValue on __InputValue { + name + description + type { + ...TypeRef + } + defaultValue +} + +fragment TypeRef on __Type { + kind + name + ofType { + kind + name + ofType { + kind + name + ofType { + kind + name + ofType { + kind + name + ofType { + kind + name + ofType { + kind + name + ofType { + kind + name + } + } + } + } + } + } + } +} +``` + +* NodeCount = {int} 0 +* Complexity = {int} 0 +* Depth = {int} 0 + +Since the `__schema: __Schema! @nodeCountSkip` directive is present in the schema, the calculated NodeCount, Complexity, and Depth are all 0. diff --git a/docs/installation-guides/graphql/playground.md b/docs/installation-guides/graphql/playground.md new file mode 100644 index 0000000..ef93820 --- /dev/null +++ b/docs/installation-guides/graphql/playground.md @@ -0,0 +1,19 @@ +# GraphQL Playground in API Firewall + +Wallarm API Firewall equips developers with the [GraphQL Playground](https://github.com/graphql/graphql-playground). This guide explains how to run the playground. + +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. + +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. + +To activate the Playground within the API Firewall, you need to use the following environment variables: + +| Environment variable | Description | +| -------------------- | ----------- | +| `APIFW_GRAPHQL_INTROSPECTION` | Allows introspection queries, which disclose the layout of your GraphQL schema. Ensure this variable is set to `true`. | +| `APIFW_GRAPHQL_PLAYGROUND` | Toggles the playground feature. By default, it is set to `false`. To enable, change to `true`. | +| `APIFW_GRAPHQL_PLAYGROUND_PATH` | Designates the path where the playground will be accessible. By default, it is the root path `/`. | + +Once set up, you can access the playground interface from the designated path in your browser: + +![Playground](https://github.com/wallarm/api-firewall/blob/main/images/graphql-playground.png?raw=true) diff --git a/docs/installation-guides/graphql/websocket-origin-check.md b/docs/installation-guides/graphql/websocket-origin-check.md new file mode 100644 index 0000000..b05b5d9 --- /dev/null +++ b/docs/installation-guides/graphql/websocket-origin-check.md @@ -0,0 +1,12 @@ +# WebSocket Origin Validation + +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). + +By default, the WebSocket Origin validation feature is disabled. To activate it, configure the following environment variables: + +| Environment variable | Description | +| -------------------- | ----------- | +| `APIFW_GRAPHQL_WS_CHECK_ORIGIN` | Enables the validation of the `Origin` header during the WebSocket upgrade phase. Default: `false`. | +| `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 `;`. | + +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. diff --git a/docs/release-notes.md b/docs/release-notes.md index df02156..6796c9c 100644 --- a/docs/release-notes.md +++ b/docs/release-notes.md @@ -1,10 +1,14 @@ -# API Firewall changelog +# API Firewall Changelog This page describes new releases of Wallarm API Firewall. +## v0.6.13 (2023-09-08) + +* [Support for GraphQL API requests validation](installation-guides/graphql/docker-container.md) + ## v0.6.12 (2023-08-04) -* Ability to set the general API Firewall mode using the `APIFW_MODE` environment variable. The default value is `PROXY`. When set to API, you can [validate individual API requests based on a provided OpenAPI specification without further proxying](installation-guides/docker-container.md#validating-individual-requests-without-proxying-for-v0612-and-above). +* Ability to set the general API Firewall mode using the `APIFW_MODE` environment variable. The default value is `PROXY`. When set to API, you can [validate individual API requests based on a provided OpenAPI specification without further proxying](installation-guides/api-mode.md). * Introduced the ability to allow `OPTIONS` requests for endpoints specified in the OpenAPI, even if the `OPTIONS` method is not explicitly defined. This can be achieved using the `APIFW_PASS_OPTIONS` variable. The default value is `false`. * Introduced a feature that allows control over whether requests should be identified as non-matching the specification if their parameters do not align with those outlined in the OpenAPI specification. It is set to `true` by default. @@ -39,8 +43,8 @@ This page describes new releases of Wallarm API Firewall. ### New features * Ability to specify the URL address of the OpenAPI 3.0 specification instead of mounting the specification file into the Docker container (via the environment variable [`APIFW_API_SPECS`](installation-guides/docker-container.md#apifw-api-specs)). -* Ability to use the custom `Content-Type` header when sending requests to the token introspection service (via the environment variable [`APIFW_SERVER_OAUTH_INTROSPECTION_CONTENT_TYPE`](installation-guides/docker-container.md#apifw-server-oauth-introspection-content-type)). -* [Support for the authentication token denylists](installation-guides/docker-container.md#blocking-requests-with-compromised-authentication-tokens). +* Ability to use the custom `Content-Type` header when sending requests to the token introspection service (via the environment variable [`APIFW_SERVER_OAUTH_INTROSPECTION_CONTENT_TYPE`](configuration-guides/validate-tokens.md)). +* [Support for the authentication token denylists](configuration-guides/denylist-leaked-tokens.md). ## v0.6.7 (2022-01-25) @@ -53,8 +57,8 @@ Wallarm API Firewall is now open source. There are the following related changes ### New features -* Support for [OAuth 2.0 token validation](installation-guides/docker-container.md#validation-of-request-authentication-tokens). -* [Connection](installation-guides/docker-container.md#protected-application-ssltls-settings) to the servers signed with the custom CA certificates and support for insecure connection flag. +* Support for [OAuth 2.0 token validation](configuration-guides/validate-tokens.md). +* [Connection](configuration-guides/ssl-tls.md) to the servers signed with the custom CA certificates and support for insecure connection flag. ### Bug fixes diff --git a/go.mod b/go.mod index 5fae9a8..682827e 100644 --- a/go.mod +++ b/go.mod @@ -8,6 +8,7 @@ require ( github.com/clbanning/mxj/v2 v2.7.0 github.com/dgraph-io/ristretto v0.1.1 github.com/fasthttp/router v1.4.20 + github.com/fasthttp/websocket v1.5.4 github.com/gabriel-vasile/mimetype v1.4.2 github.com/getkin/kin-openapi v0.118.0 github.com/go-playground/validator v9.31.0+incompatible @@ -23,33 +24,76 @@ require ( github.com/stretchr/testify v1.8.4 github.com/valyala/fasthttp v1.48.0 github.com/valyala/fastjson v1.6.4 + github.com/wundergraph/graphql-go-tools v1.66.2 golang.org/x/exp v0.0.0-20230807204917-050eac23e9de ) require ( + github.com/99designs/gqlgen v0.17.36 // indirect + github.com/Masterminds/goutils v1.1.1 // indirect + github.com/Masterminds/semver v1.5.0 // indirect + github.com/Masterminds/sprig v2.22.0+incompatible // indirect + github.com/buger/jsonparser v1.1.1 // indirect github.com/cespare/xxhash/v2 v2.2.0 // indirect github.com/davecgh/go-spew v1.1.1 // indirect + github.com/dgryski/go-farm v0.0.0-20200201041132-a6ae2369ad13 // indirect github.com/dustin/go-humanize v1.0.1 // indirect + github.com/eclipse/paho.mqtt.golang v1.2.0 // indirect github.com/go-openapi/jsonpointer v0.20.0 // indirect github.com/go-openapi/swag v0.22.4 // indirect github.com/go-playground/locales v0.14.1 // indirect github.com/golang/glog v1.1.1 // indirect + github.com/golang/protobuf v1.5.3 // indirect + github.com/google/go-cmp v0.5.9 // indirect github.com/gorilla/mux v1.8.0 // indirect + github.com/hashicorp/golang-lru v0.5.4 // indirect + github.com/huandu/xstrings v1.3.3 // indirect + github.com/imdario/mergo v0.3.12 // indirect github.com/invopop/yaml v0.2.0 // indirect + github.com/jensneuse/abstractlogger v0.0.4 // indirect + github.com/jensneuse/byte-template v0.0.0-20200214152254-4f3cf06e5c68 // indirect + github.com/jensneuse/pipeline v0.0.0-20200117120358-9fb4de085cd6 // indirect github.com/josharian/intern v1.0.0 // indirect + github.com/json-iterator/go v1.1.12 // indirect github.com/mailru/easyjson v0.7.7 // indirect + github.com/mattn/go-isatty v0.0.19 // indirect + github.com/minio/highwayhash v1.0.2 // indirect + github.com/mitchellh/copystructure v1.2.0 // indirect + github.com/mitchellh/reflectwalk v1.0.2 // indirect + github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 // indirect + github.com/nats-io/jwt/v2 v2.4.1 // indirect + github.com/nats-io/nats.go v1.19.1 // indirect + github.com/nats-io/nkeys v0.4.4 // indirect + github.com/nats-io/nuid v1.0.1 // indirect github.com/perimeterx/marshmallow v1.1.5 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/r3labs/sse/v2 v2.8.1 // indirect github.com/rogpeppe/go-internal v1.11.0 // indirect + github.com/santhosh-tekuri/jsonschema/v5 v5.3.0 // indirect + github.com/tidwall/gjson v1.11.0 // indirect + github.com/tidwall/match v1.1.1 // indirect + github.com/tidwall/pretty v1.2.0 // indirect + github.com/tidwall/sjson v1.0.4 // indirect github.com/valyala/bytebufferpool v1.0.0 // indirect + github.com/vektah/gqlparser/v2 v2.5.8 // indirect + go.uber.org/atomic v1.11.0 // indirect + go.uber.org/goleak v1.2.1 // indirect + go.uber.org/multierr v1.11.0 // indirect + go.uber.org/zap v1.25.0 // indirect + golang.org/x/crypto v0.12.0 // indirect golang.org/x/net v0.14.0 // indirect golang.org/x/sys v0.11.0 // indirect + golang.org/x/time v0.1.0 // indirect + golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 // indirect + google.golang.org/protobuf v1.31.0 // indirect + gopkg.in/cenkalti/backoff.v1 v1.1.0 // indirect + gopkg.in/go-playground/assert.v1 v1.2.1 // indirect + nhooyr.io/websocket v1.8.7 // indirect ) require ( github.com/go-playground/universal-translator v0.18.1 // indirect github.com/leodido/go-urn v1.2.4 // indirect - gopkg.in/go-playground/assert.v1 v1.2.1 // indirect gopkg.in/yaml.v3 v3.0.1 ) diff --git a/go.sum b/go.sum index 80d028e..d63cfa6 100644 --- a/go.sum +++ b/go.sum @@ -1,7 +1,24 @@ +github.com/99designs/gqlgen v0.17.36 h1:u/o/rv2SZ9s5280dyUOOrkpIIkr/7kITMXYD3rkJ9go= +github.com/99designs/gqlgen v0.17.36/go.mod h1:6RdyY8puhCoWAQVr2qzF2OMVfudQzc8ACxzpzluoQm4= +github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= +github.com/Masterminds/goutils v1.1.0/go.mod h1:8cTjp+g8YejhMuvIA5y2vz3BpJxksy863GQaJW2MFNU= +github.com/Masterminds/goutils v1.1.1 h1:5nUrii3FMTL5diU80unEVvNevw1nH4+ZV4DSLVJLSYI= +github.com/Masterminds/goutils v1.1.1/go.mod h1:8cTjp+g8YejhMuvIA5y2vz3BpJxksy863GQaJW2MFNU= +github.com/Masterminds/semver v1.5.0 h1:H65muMkzWKEuNDnfl9d70GUjFniHKHRbFPGBuZ3QEww= +github.com/Masterminds/semver v1.5.0/go.mod h1:MB6lktGJrhw8PrUyiEoblNEGEQ+RzHPF078ddwwvV3Y= +github.com/Masterminds/sprig v2.22.0+incompatible h1:z4yfnGrZ7netVz+0EDJ0Wi+5VZCSYp4Z0m2dk6cEM60= +github.com/Masterminds/sprig v2.22.0+incompatible/go.mod h1:y6hNFY5UBTIWBxnzTeuNhlNS5hqE0NB0E6fgfo2Br3o= +github.com/agnivade/levenshtein v1.1.1 h1:QY8M92nrzkmr798gCo3kmMyqXFzdQVpxLlGPRBij0P8= +github.com/agnivade/levenshtein v1.1.1/go.mod h1:veldBMzWxcCG2ZvUTKD2kJNRdCk5hVbJomOvKkmgYbo= +github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883/go.mod h1:rCTlJbsFo29Kk6CurOXKm700vrz8f0KW0JNfpkRJY/8= github.com/andybalholm/brotli v1.0.5 h1:8uQZIdzKmjc/iuPu7O2ioW48L81FgatrcpfFmiq/cCs= github.com/andybalholm/brotli v1.0.5/go.mod h1:fO7iG3H7G2nSZ7m0zPUDn85XEX2GTukHGRSepvi9Eig= +github.com/arbovm/levenshtein v0.0.0-20160628152529-48b4e1c0c4d0/go.mod h1:t2tdKJDJF9BV14lnkjHmOQgcvEKgtqs5a1N3LNdJhGE= github.com/ardanlabs/conf v1.5.0 h1:5TwP6Wu9Xi07eLFEpiCUF3oQXh9UzHMDVnD3u/I5d5c= github.com/ardanlabs/conf v1.5.0/go.mod h1:ILsMo9dMqYzCxDjDXTiwMI0IgxOJd0MOiucbQY2wlJw= +github.com/benbjohnson/clock v1.3.0 h1:ip6w0uFQkncKQ979AypyG0ER7mqUSBdKLOgAle/AT8A= +github.com/buger/jsonparser v1.1.1 h1:2PnMjfWD7wBILjqQbt530v576A/cAbQvEW9gGIpYMUs= +github.com/buger/jsonparser v1.1.1/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0= github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44= github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= @@ -12,31 +29,53 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/dgraph-io/ristretto v0.1.1 h1:6CWw5tJNgpegArSHpNHJKldNeq03FQCwYvfMVWajOK8= github.com/dgraph-io/ristretto v0.1.1/go.mod h1:S1GPSBCYCIhmVNfcth17y2zZtQT6wzkzgwUve0VDWWA= -github.com/dgryski/go-farm v0.0.0-20190423205320-6a90982ecee2 h1:tdlZCpZ/P9DhczCTSixgIKmwPv6+wP5DGjqLYw5SUiA= github.com/dgryski/go-farm v0.0.0-20190423205320-6a90982ecee2/go.mod h1:SqUrOPUnsFjfmXRMNPybcSiG0BgUW2AuFH8PAnS2iTw= +github.com/dgryski/go-farm v0.0.0-20200201041132-a6ae2369ad13 h1:fAjc9m62+UWV/WAFKLNi6ZS0675eEUC9y3AlwSbQu1Y= +github.com/dgryski/go-farm v0.0.0-20200201041132-a6ae2369ad13/go.mod h1:SqUrOPUnsFjfmXRMNPybcSiG0BgUW2AuFH8PAnS2iTw= +github.com/dgryski/trifles v0.0.0-20200323201526-dd97f9abfb48/go.mod h1:if7Fbed8SFyPtHLHbg49SI7NAdJiC5WIA09pe59rfAA= github.com/dustin/go-humanize v1.0.0/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/eclipse/paho.mqtt.golang v1.2.0 h1:1F8mhG9+aO5/xpdtFkW4SxOJB67ukuDC3t2y2qayIX0= +github.com/eclipse/paho.mqtt.golang v1.2.0/go.mod h1:H9keYFcgq3Qr5OUJm/JZI/i6U7joQ8SYLhZwfeOo6Ts= +github.com/evanphx/json-patch/v5 v5.1.0 h1:B0aXl1o/1cP8NbviYiBMkcHBtUjIJ1/Ccg6b+SwCLQg= github.com/fasthttp/router v1.4.20 h1:yPeNxz5WxZGojzolKqiP15DTXnxZce9Drv577GBrDgU= github.com/fasthttp/router v1.4.20/go.mod h1:um867yNQKtERxBm+C+yzgWxjspTiQoA8z86Ec3fK/tc= +github.com/fasthttp/websocket v1.5.4 h1:Bq8HIcoiffh3pmwSKB8FqaNooluStLQQxnzQspMatgI= +github.com/fasthttp/websocket v1.5.4/go.mod h1:R2VXd4A6KBspb5mTrsWnZwn6ULkX56/Ktk8/0UNSJao= github.com/gabriel-vasile/mimetype v1.4.2 h1:w5qFW6JKBz9Y393Y4q372O9A7cUSequkh1Q7OhCmWKU= github.com/gabriel-vasile/mimetype v1.4.2/go.mod h1:zApsH/mKG4w07erKIaJPFiX0Tsq9BFQgN3qGY5GnNgA= github.com/getkin/kin-openapi v0.118.0 h1:z43njxPmJ7TaPpMSCQb7PN0dEYno4tyBPQcrFdHoLuM= github.com/getkin/kin-openapi v0.118.0/go.mod h1:l5e9PaFUo9fyLJCPGQeXI2ML8c3P8BHOEV2VaAVf/pc= +github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE= +github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI= +github.com/gin-gonic/gin v1.6.3 h1:ahKqKTFpO5KTPHxWZjEdPScmYaGtLo8Y4DMHoEsnp14= +github.com/gin-gonic/gin v1.6.3/go.mod h1:75u5sXoLsGZoRN5Sgbi1eraJ4GU3++wFwWzhwvtwp4M= github.com/go-openapi/jsonpointer v0.19.5/go.mod h1:Pl9vOtqEWErmShwVjC8pYs9cog34VGT37dQOVbmoatg= github.com/go-openapi/jsonpointer v0.20.0 h1:ESKJdU9ASRfaPNOPRx12IUyA1vn3R9GiE3KYD14BXdQ= github.com/go-openapi/jsonpointer v0.20.0/go.mod h1:6PGzBjjIIumbLYysB73Klnms1mwnU4G3YHOECG3CedA= github.com/go-openapi/swag v0.19.5/go.mod h1:POnQmlKehdgb5mhVOsnJFsivZCEZ/vjK9gh66Z9tfKk= github.com/go-openapi/swag v0.22.4 h1:QLMzNJnMGPRNDCbySlcj1x01tzU8/9LTTL9hZZZogBU= github.com/go-openapi/swag v0.22.4/go.mod h1:UzaqsxGiab7freDnrUUra0MwWfN/q7tE4j+VcZ0yl14= +github.com/go-playground/assert/v2 v2.0.1/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= +github.com/go-playground/locales v0.13.0/go.mod h1:taPMhCMXrRLJO55olJkUXHZBHCxTMfnGwq/HNwmWNS8= github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= +github.com/go-playground/universal-translator v0.17.0/go.mod h1:UkSxE5sNxxRwHyU+Scu5vgOQjsIJAF8j9muTVoKLVtA= github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= github.com/go-playground/validator v9.31.0+incompatible h1:UA72EPEogEnq76ehGdEDp4Mit+3FDh548oRqwVgNsHA= github.com/go-playground/validator v9.31.0+incompatible/go.mod h1:yrEkQXlcI+PugkyDjY2bRrL/UBU4f3rvrgkN3V8JEig= +github.com/go-playground/validator/v10 v10.2.0 h1:KgJ0snyC2R9VXYN2rneOtQcw5aHQB1Vv0sFl1UcHBOY= +github.com/go-playground/validator/v10 v10.2.0/go.mod h1:uOYAAleCW8F/7oMFd6aG0GOhaH6EGOAJShg8Id5JGkI= github.com/go-test/deep v1.0.8 h1:TDsG77qcSprGbC6vTN8OuXp5g+J+b5Pcguhf7Zt61VM= github.com/go-test/deep v1.0.8/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE= +github.com/gobwas/httphead v0.0.0-20180130184737-2c6c146eadee h1:s+21KNqlpePfkah2I+gwHF8xmJWRjooY+5248k6m4A0= +github.com/gobwas/httphead v0.0.0-20180130184737-2c6c146eadee/go.mod h1:L0fX3K22YWvt/FAX9NnzrNzcI4wNYi9Yku4O0LKYflo= +github.com/gobwas/pool v0.2.0 h1:QEmUOlnSjWtnpRGHF3SauEiOsy82Cup83Vf2LcMlnc8= +github.com/gobwas/pool v0.2.0/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6WezmKEw= +github.com/gobwas/ws v1.0.2/go.mod h1:szmBTxLgaFppYjEmNtny/v3w89xOydFnnZMcgRRu/EM= +github.com/gobwas/ws v1.0.4 h1:5eXU1CZhpQdq5kXbKb+sECH5Ia5KiO6CYzIzdlVx6Bs= github.com/golang-jwt/jwt v3.2.2+incompatible h1:IfV12K8xAKAnZqdXVzCZ+TOjboZ2keLg81eXfW3O+oY= github.com/golang-jwt/jwt v3.2.2+incompatible/go.mod h1:8pz2t5EyA70fFQQSrl6XZXzqecmYZeUEB8OUGHkxJ+I= github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= @@ -44,54 +83,132 @@ github.com/golang/glog v1.1.1 h1:jxpi2eWoU84wbX9iIEyAeeoac3FLuifZpY9tcNUD9kw= github.com/golang/glog v1.1.1/go.mod h1:zR+okUeTbrL6EL3xHUDxZuEtGv04p5shwip1+mL/rLQ= github.com/golang/mock v1.6.0 h1:ErTB+efbowRARo13NNdxyJji2egdxLGQhRaY+DUumQc= github.com/golang/mock v1.6.0/go.mod h1:p6yTPP+5HYm5mzsMV8JkE6ZKdX+/wYM6Hr+LicevLPs= +github.com/golang/protobuf v1.3.3/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw= +github.com/golang/protobuf v1.3.5/go.mod h1:6O5/vntMXwX2lRkT1hjjk0nAC1IDOTvTlVgjlRvqsdk= +github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= +github.com/golang/protobuf v1.5.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg= +github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= -github.com/google/go-cmp v0.5.8 h1:e6P7q2lk1O+qJJb4BtCQXlK8vWEO8V1ZeuEdJNOqZyg= +github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= +github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI= +github.com/google/uuid v1.1.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I= github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/gorilla/mux v1.8.0 h1:i40aqfkR1h2SlN9hojwV5ZA91wcXFOvkdNIeFDP5koI= github.com/gorilla/mux v1.8.0/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So= +github.com/gorilla/websocket v1.4.1/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWmnc= +github.com/hashicorp/golang-lru v0.5.4 h1:YDjusn29QI/Das2iO9M0BHnIbxPeyuCHsjMW+lJfyTc= +github.com/hashicorp/golang-lru v0.5.4/go.mod h1:iADmTwqILo4mZ8BN3D2Q6+9jd8WM5uGBxy+E8yxSoD4= +github.com/hashicorp/golang-lru/v2 v2.0.3 h1:kmRrRLlInXvng0SmLxmQpQkpbYAvcXm7NPDrgxJa9mE= +github.com/huandu/xstrings v1.2.1/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq4ovT0aE= +github.com/huandu/xstrings v1.3.3 h1:/Gcsuc1x8JVbJ9/rlye4xZnVAbEkGauT8lbebqcQws4= +github.com/huandu/xstrings v1.3.3/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq4ovT0aE= +github.com/imdario/mergo v0.3.8/go.mod h1:2EnlNZ0deacrJVfApfmtdGgDfMuh/nq6Ok1EcJh5FfA= +github.com/imdario/mergo v0.3.12 h1:b6R2BslTbIEToALKP7LxUvijTsNI9TAe80pLWN2g/HU= +github.com/imdario/mergo v0.3.12/go.mod h1:jmQim1M+e3UYxmgPu/WyfjB3N3VflVyUjjjwH0dnCYA= github.com/invopop/yaml v0.1.0/go.mod h1:2XuRLgs/ouIrW3XNzuNj7J3Nvu/Dig5MXvbCEdiBN3Q= github.com/invopop/yaml v0.2.0 h1:7zky/qH+O0DwAyoobXUqvVBwgBFRxKoQ/3FjcVpjTMY= github.com/invopop/yaml v0.2.0/go.mod h1:2XuRLgs/ouIrW3XNzuNj7J3Nvu/Dig5MXvbCEdiBN3Q= +github.com/jensneuse/abstractlogger v0.0.4 h1:sa4EH8fhWk3zlTDbSncaWKfwxYM8tYSlQ054ETLyyQY= +github.com/jensneuse/abstractlogger v0.0.4/go.mod h1:6WuamOHuykJk8zED/R0LNiLhWR6C7FIAo43ocUEB3mo= +github.com/jensneuse/byte-template v0.0.0-20200214152254-4f3cf06e5c68 h1:E80wOd3IFQcoBxLkAUpUQ3BoGrZ4DxhQdP21+HH1s6A= +github.com/jensneuse/byte-template v0.0.0-20200214152254-4f3cf06e5c68/go.mod h1:0D5r/VSW6D/o65rKLL9xk7sZxL2+oku2HvFPYeIMFr4= +github.com/jensneuse/diffview v1.0.0 h1:4b6FQJ7y3295JUHU3tRko6euyEboL825ZsXeZZM47Z4= +github.com/jensneuse/pipeline v0.0.0-20200117120358-9fb4de085cd6 h1:y8hvuqbuVGFNpEos+vB5I5X+QxWm0uyTk+5oeOinMjY= +github.com/jensneuse/pipeline v0.0.0-20200117120358-9fb4de085cd6/go.mod h1:UsfzaMt+keVOxa007GcCJMFeTHr6voRfBGMQEW7DkdM= github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= +github.com/json-iterator/go v1.1.9/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= +github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= +github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= github.com/karlseguin/ccache/v2 v2.0.8 h1:lT38cE//uyf6KcFok0rlgXtGFBWxkI6h/qg4tbFyDnA= github.com/karlseguin/ccache/v2 v2.0.8/go.mod h1:2BDThcfQMf/c0jnZowt16eW405XIqZPavt+HoYEtcxQ= github.com/karlseguin/expect v1.0.2-0.20190806010014-778a5f0c6003 h1:vJ0Snvo+SLMY72r5J4sEfkuE7AFbixEP2qRbEcum/wA= github.com/karlseguin/expect v1.0.2-0.20190806010014-778a5f0c6003/go.mod h1:zNBxMY8P21owkeogJELCLeHIt+voOSduHYTFUbwRAV8= +github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= +github.com/klauspost/compress v1.10.3/go.mod h1:aoV0uJVorq1K+umq18yTdKaF57EivdYsUV+/s2qKfXs= github.com/klauspost/compress v1.16.7 h1:2mk3MPGNzKyxErAw8YaohYh69+pa4sIQSC0fPGCFR9I= github.com/klauspost/compress v1.16.7/go.mod h1:ntbaceVETuRiXiv4DpjP66DpAtAGkEQskQzEyD//IeE= +github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/leodido/go-urn v1.2.0/go.mod h1:+8+nEpDfqqsY+g338gtMEUOtuK+4dEMhiQEgxpxOKII= github.com/leodido/go-urn v1.2.4 h1:XlAE/cm/ms7TE/VMVoduSpNBoyc2dOxHs5MZSwAN63Q= github.com/leodido/go-urn v1.2.4/go.mod h1:7ZrI8mTSeBSHl/UaRyKQW1qZeMgak41ANeCNaVckg+4= +github.com/logrusorgru/aurora/v3 v3.0.0 h1:R6zcoZZbvVcGMvDCKo45A9U/lzYyzl5NfYIvznmDfE4= github.com/mailru/easyjson v0.0.0-20190614124828-94de47d64c63/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc= github.com/mailru/easyjson v0.0.0-20190626092158-b2ccc519800e/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc= github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= +github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= +github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU= +github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA= +github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-sqlite3 v1.14.17 h1:mCRHCLDUBXgpKAqIKsaAaAsrAlbkeomtRFKXh2L6YIM= github.com/mattn/go-sqlite3 v1.14.17/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg= +github.com/minio/highwayhash v1.0.2 h1:Aak5U0nElisjDCfPSG79Tgzkn2gl66NxOMspRrKnA/g= +github.com/minio/highwayhash v1.0.2/go.mod h1:BQskDq+xkJ12lmlUUi7U0M5Swg3EWR+dLTk+kldvVxY= +github.com/mitchellh/copystructure v1.0.0/go.mod h1:SNtv71yrdKgLRyLFxmLdkAbkKEFWgYaq1OVrnRcwhnw= +github.com/mitchellh/copystructure v1.2.0 h1:vpKXTN4ewci03Vljg/q9QvCGUDttBOGBIa15WveJJGw= +github.com/mitchellh/copystructure v1.2.0/go.mod h1:qLl+cE2AmVv+CoeAwDPye/v+N2HKCj9FbZEVFJRxO9s= +github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= +github.com/mitchellh/reflectwalk v1.0.0/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw= +github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zxSIeXaQ= +github.com/mitchellh/reflectwalk v1.0.2/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= +github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= +github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 h1:RWengNIwukTxcDr9M+97sNutRR1RKhG96O6jWumTTnw= github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826/go.mod h1:TaXosZuwdSHYgviHp1DAtfrULt5eUgsSMsZf+YrPgl8= +github.com/nats-io/jwt/v2 v2.4.1 h1:Y35W1dgbbz2SQUYDPCaclXcuqleVmpbRa7646Jf2EX4= +github.com/nats-io/jwt/v2 v2.4.1/go.mod h1:24BeQtRwxRV8ruvC4CojXlx/WQ/VjuwlYiH+vu/+ibI= +github.com/nats-io/nats-server/v2 v2.8.2 h1:5m1VytMEbZx0YINvKY+X2gXdLNwP43uLXnFRwz8j8KE= +github.com/nats-io/nats.go v1.19.1 h1:pDQZthDfxRMSJ0ereExAM9ODf3JyS42Exk7iCMdbpec= +github.com/nats-io/nats.go v1.19.1/go.mod h1:tLqubohF7t4z3du1QDPYJIQQyhb4wl6DhjxEajSI7UA= +github.com/nats-io/nkeys v0.3.0/go.mod h1:gvUNGjVcM2IPr5rCsRsC6Wb3Hr2CQAm08dsxtV6A5y4= +github.com/nats-io/nkeys v0.4.4 h1:xvBJ8d69TznjcQl9t6//Q5xXuVhyYiSos6RPtvQNTwA= +github.com/nats-io/nkeys v0.4.4/go.mod h1:XUkxdLPTufzlihbamfzQ7mw/VGx6ObUs+0bN5sNvt64= +github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw= +github.com/nats-io/nuid v1.0.1/go.mod h1:19wcPz3Ph3q0Jbyiqsd0kePYG7A95tJPxeL+1OSON2c= github.com/perimeterx/marshmallow v1.1.4/go.mod h1:dsXbUu8CRzfYP5a87xpp0xq9S3u0Vchtcl8we9tYaXw= github.com/perimeterx/marshmallow v1.1.5 h1:a2LALqQ1BlHM8PZblsDdidgv1mWi1DgC2UmX50IvK2s= github.com/perimeterx/marshmallow v1.1.5/go.mod h1:dsXbUu8CRzfYP5a87xpp0xq9S3u0Vchtcl8we9tYaXw= +github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/r3labs/sse/v2 v2.8.1 h1:lZH+W4XOLIq88U5MIHOsLec7+R62uhz3bIi2yn0Sg8o= +github.com/r3labs/sse/v2 v2.8.1/go.mod h1:Igau6Whc+F17QUgML1fYe1VPZzTV6EMCnYktEmkNJ7I= +github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= github.com/rogpeppe/go-internal v1.11.0 h1:cWPaGQEPrBb5/AsnsZesgZZ9yb1OQ+GOISoDNXVBh4M= github.com/rogpeppe/go-internal v1.11.0/go.mod h1:ddIwULY96R17DhadqLgMfk9H9tvdUzkipdSkR5nkCZA= +github.com/santhosh-tekuri/jsonschema/v5 v5.3.0 h1:uIkTLo0AGRc8l7h5l9r+GcYi9qfVPt6lD4/bhmzfiKo= +github.com/santhosh-tekuri/jsonschema/v5 v5.3.0/go.mod h1:FKdcjfQW6rpZSnxxUvEA5H/cDPdvJ/SZJQLWWXWGrZ0= github.com/savsgio/gotils v0.0.0-20230208104028-c358bd845dee h1:8Iv5m6xEo1NR1AvpV+7XmhI4r39LGNzwUL4YpMuL5vk= github.com/savsgio/gotils v0.0.0-20230208104028-c358bd845dee/go.mod h1:qwtSXrKuJh/zsFQ12yEE89xfCrGKK63Rr7ctU/uCo4g= +github.com/sebdah/goldie/v2 v2.5.3 h1:9ES/mNN+HNUbNWpVAlrzuZ7jE+Nrczbj8uFRjM7624Y= +github.com/sergi/go-diff v1.3.1 h1:xkr+Oxo4BOQKmkn/B9eMK0g5Kg/983T9DqqPHwYqD+8= +github.com/sergi/go-diff v1.3.1/go.mod h1:aMJSSKb2lpPvRNec0+w3fl7LP9IOFzdc9Pa4NFbPK1I= +github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE= github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= @@ -101,8 +218,18 @@ github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/tidwall/gjson v1.11.0 h1:C16pk7tQNiH6VlCrtIXL1w8GaOsi1X3W8KDkE1BuYd4= +github.com/tidwall/gjson v1.11.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= +github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA= +github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM= +github.com/tidwall/pretty v1.2.0 h1:RWIZEg2iJ8/g6fDDYzMpobmaoGh5OLl4AXtGUGPcqCs= +github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= +github.com/tidwall/sjson v1.0.4 h1:UcdIRXff12Lpnu3OLtZvnc03g4vH2suXDXhBwBqmzYg= +github.com/tidwall/sjson v1.0.4/go.mod h1:bURseu1nuBkFpIES5cz6zBtjmYeOQmEESshn7VpF15Y= +github.com/ugorji/go v1.1.7/go.mod h1:kZn38zHttfInRq0xu/PH0az30d+z6vm202qpg1oXVMw= github.com/ugorji/go v1.2.7 h1:qYhyWUUd6WbiM+C6JZAUkIJt/1WrjzNHY9+KCIjVqTo= github.com/ugorji/go v1.2.7/go.mod h1:nF9osbDWLy6bDVv/Rtoh6QgnvNDpmCalQV5urGCCS6M= +github.com/ugorji/go/codec v1.1.7/go.mod h1:Ax+UKWsSmolVDwsd+7N3ZtXu+yMGCf907BLYF3GoBXY= github.com/ugorji/go/codec v1.2.7 h1:YPXUKf7fYbp/y8xloBqZOw2qaVggbfwMlI8WM3wZUJ0= github.com/ugorji/go/codec v1.2.7/go.mod h1:WGN1fab3R1fzQlVQTkfxVtIBhWDRqOviHU95kRgeqEY= github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= @@ -111,47 +238,101 @@ github.com/valyala/fasthttp v1.48.0 h1:oJWvHb9BIZToTQS3MuQ2R3bJZiNSa2KiNdeI8A+79 github.com/valyala/fasthttp v1.48.0/go.mod h1:k2zXd82h/7UZc3VOdJ2WaUqt1uZ/XpXAfE9i+HBC3lA= github.com/valyala/fastjson v1.6.4 h1:uAUNq9Z6ymTgGhcm0UynUAB6tlbakBrz6CQFax3BXVQ= github.com/valyala/fastjson v1.6.4/go.mod h1:CLCAqky6SMuOcxStkYQvblddUtoRxhYMGLrsQns1aXY= +github.com/vektah/gqlparser/v2 v2.5.8 h1:pm6WOnGdzFOCfcQo9L3+xzW51mKrlwTEg4Wr7AH1JW4= +github.com/vektah/gqlparser/v2 v2.5.8/go.mod h1:z8xXUff237NntSuH8mLFijZ+1tjV1swDbpDqjJmk6ME= github.com/wsxiaoys/terminal v0.0.0-20160513160801-0940f3fc43a0 h1:3UeQBvD0TFrlVjOeLOBz+CPAI8dnbqNSVwUwRrkp7vQ= github.com/wsxiaoys/terminal v0.0.0-20160513160801-0940f3fc43a0/go.mod h1:IXCdmsXIht47RaVFLEdVnh1t+pgYtTAhQGj73kz+2DM= +github.com/wundergraph/graphql-go-tools v1.66.2 h1:wevIAl2iBmSVNyprHTZ7cE9TSvrYWpUqXGSCFKqkm1s= +github.com/wundergraph/graphql-go-tools v1.66.2/go.mod h1:FM8q4EUCc50RGAeKSAKuGs3UfQXIYtC9c5sRTEC0udk= github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= +go.uber.org/atomic v1.5.0/go.mod h1:sABNBOSYdrvTF6hTgEIbc7YasKWGhgEQZyfxyTvoXHQ= +go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE= +go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0= +go.uber.org/goleak v1.2.1 h1:NBol2c7O1ZokfZ0LEU9K6Whx/KnwvepVetCUhtKja4A= +go.uber.org/goleak v1.2.1/go.mod h1:qlT2yGI9QafXHhZZLxlSuNsMw3FFLxBr+tBRlmO1xH4= +go.uber.org/multierr v1.3.0/go.mod h1:VgVr7evmIr6uPjLBxg28wmKNXyqE9akIJ5XnfpiKl+4= +go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= +go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= +go.uber.org/tools v0.0.0-20190618225709-2cfd321de3ee/go.mod h1:vJERXedbb3MVM5f9Ejo0C68/HhF8uaILCdgjnY+goOA= +go.uber.org/zap v1.13.0/go.mod h1:zwrFLgMcdUuIBviXEYEH1YKNaOBnKXsx2IPda5bBwHM= +go.uber.org/zap v1.25.0 h1:4Hvk6GtkucQ790dqmj7l1eEnRdKm3k3ZUrUMS2d5+5c= +go.uber.org/zap v1.25.0/go.mod h1:JIAUzQIH94IC4fOJQm7gMmBJP5k7wQfdcnYdPoEXJYk= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20191227163750-53104e6ec876/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.0.0-20210314154223-e6e6c4f2bb5b/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4= +golang.org/x/crypto v0.12.0 h1:tFM/ta59kqch6LlvYnPa0yx5a83cL2nHflFhYKvv9Yk= +golang.org/x/crypto v0.12.0/go.mod h1:NF0Gs7EO5K4qLn+Ylc+fih8BSTeIjAP05siRnAh98yw= golang.org/x/exp v0.0.0-20230807204917-050eac23e9de h1:l5Za6utMv/HsBWWqzt4S8X17j+kt1uVETUX5UFhn2rE= golang.org/x/exp v0.0.0-20230807204917-050eac23e9de/go.mod h1:FXUEEKJgO7OQYeo8N01OfiKP8RXMtf6e8aTskBGqWdc= +golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= +golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc= golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20191116160921-f9c825593386/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM= golang.org/x/net v0.14.0 h1:BONx9s002vGdD9umnlX1Po8vOZmrgH34qlHcD1MfK14= golang.org/x/net v0.14.0/go.mod h1:PpSgVXXLK0OxS0F31C1/tv6XNguvCrnXIDrFMspZIUI= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sys v0.0.0-20190130150945-aca44879d564/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20221010170243-090e33056c14/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.11.0 h1:eG7RXZHdqOJ1i+0lgLgCpSXAp6M3LYlAo6osgSi0xOM= golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= +golang.org/x/time v0.1.0 h1:xYY+Bajn2a7VBmTM5GikTmnK8ZuX8YgnQCqZpbBNtmA= +golang.org/x/time v0.1.0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +golang.org/x/tools v0.0.0-20190621195816-6e04913cbbac/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= +golang.org/x/tools v0.0.0-20191029041327-9cc4af7d6b2c/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20191029190741-b9c20aec41a5/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.1.1/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 h1:go1bK/D/BFZV2I8cIQd1NKEZ+0owSTG1fDTci4IqFcE= golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= +google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= +google.golang.org/protobuf v1.31.0 h1:g0LDEJHgrBl9N9r17Ru3sqWhkIx2NB67okBHPwC7hs8= +google.golang.org/protobuf v1.31.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= +gopkg.in/cenkalti/backoff.v1 v1.1.0 h1:Arh75ttbsvlpVA7WtVpH4u9h6Zl46xuptxqLxPiSo4Y= +gopkg.in/cenkalti/backoff.v1 v1.1.0/go.mod h1:J6Vskwqd+OMVJl8C33mmtxTBs2gyzfv7UDAkHu8BrjI= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= gopkg.in/go-playground/assert.v1 v1.2.1 h1:xoYuJVE7KT85PYWrN730RguIQO0ePzVRfFMXadIrXTM= gopkg.in/go-playground/assert.v1 v1.2.1/go.mod h1:9RXL0bg/zibRAgZUYszZSwO/z8Y/a8bDuhia5mkpMnE= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.0/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg= +nhooyr.io/websocket v1.8.7 h1:usjR2uOr/zjjkVMy0lW+PPohFok7PCow5sDjLgX4P4g= +nhooyr.io/websocket v1.8.7/go.mod h1:B70DZP8IakI65RVQ51MsWP/8jndNma26DVA/nFSCgW0= diff --git a/images/graphql-playground.png b/images/graphql-playground.png new file mode 100644 index 0000000..be98c15 Binary files /dev/null and b/images/graphql-playground.png differ diff --git a/internal/config/config.go b/internal/config/config.go index 8029856..5f0cda6 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -6,6 +6,67 @@ import ( "github.com/ardanlabs/conf" ) +type APIFWMode struct { + Mode string `conf:"default:PROXY" validate:"oneof=PROXY API GRAPHQL"` +} + +type ProxyMode struct { + conf.Version + APIFWMode + TLS TLS + ShadowAPI ShadowAPI + Denylist Denylist + Server Server + + APIHost string `conf:"default:http://0.0.0.0:8282,env:URL" validate:"required,url"` + HealthAPIHost string `conf:"default:0.0.0.0:9667,env:HEALTH_HOST" validate:"required"` + ReadTimeout time.Duration `conf:"default:5s"` + WriteTimeout time.Duration `conf:"default:5s"` + LogLevel string `conf:"default:INFO" validate:"oneof=TRACE DEBUG INFO ERROR WARNING"` + LogFormat string `conf:"default:TEXT" validate:"oneof=TEXT JSON"` + + RequestValidation string `conf:"required" validate:"required,oneof=DISABLE BLOCK LOG_ONLY"` + ResponseValidation string `conf:"required" validate:"required,oneof=DISABLE BLOCK LOG_ONLY"` + CustomBlockStatusCode int `conf:"default:403" validate:"HttpStatusCodes"` + AddValidationStatusHeader bool `conf:"default:false"` + APISpecs string `conf:"required,env:API_SPECS" validate:"required"` + PassOptionsRequests bool `conf:"default:false,env:PASS_OPTIONS"` +} + +type APIMode struct { + conf.Version + APIFWMode + TLS TLS + + SpecificationUpdatePeriod time.Duration `conf:"default:1m,env:API_MODE_SPECIFICATION_UPDATE_PERIOD"` + PathToSpecDB string `conf:"env:API_MODE_DEBUG_PATH_DB"` + UnknownParametersDetection bool `conf:"default:true,env:API_MODE_UNKNOWN_PARAMETERS_DETECTION"` + + APIHost string `conf:"default:http://0.0.0.0:8282,env:URL" validate:"required,url"` + HealthAPIHost string `conf:"default:0.0.0.0:9667,env:HEALTH_HOST" validate:"required"` + ReadTimeout time.Duration `conf:"default:5s"` + WriteTimeout time.Duration `conf:"default:5s"` + LogLevel string `conf:"default:INFO" validate:"oneof=TRACE DEBUG INFO ERROR WARNING"` + LogFormat string `conf:"default:TEXT" validate:"oneof=TEXT JSON"` + PassOptionsRequests bool `conf:"default:false,env:PASS_OPTIONS"` +} + +type GraphQLMode struct { + conf.Version + APIFWMode + Graphql GraphQL + TLS TLS + Server Backend + Denylist Denylist + + APIHost string `conf:"default:http://0.0.0.0:8282,env:URL" validate:"required,url"` + HealthAPIHost string `conf:"default:0.0.0.0:9667,env:HEALTH_HOST" validate:"required"` + ReadTimeout time.Duration `conf:"default:5s"` + WriteTimeout time.Duration `conf:"default:5s"` + LogLevel string `conf:"default:INFO" validate:"oneof=TRACE DEBUG INFO ERROR WARNING"` + LogFormat string `conf:"default:TEXT" validate:"oneof=TEXT JSON"` +} + type TLS struct { CertsPath string `conf:"default:certs"` CertFile string `conf:"default:localhost.crt"` @@ -13,6 +74,11 @@ type TLS struct { } type Server struct { + Backend + Oauth Oauth +} + +type Backend struct { URL string `conf:"default:http://localhost:3000/v1/" validate:"required,url"` ClientPoolCapacity int `conf:"default:1000" validate:"gt=0"` InsecureConnection bool `conf:"default:false"` @@ -22,7 +88,6 @@ type Server struct { WriteTimeout time.Duration `conf:"default:5s"` DialTimeout time.Duration `conf:"default:200ms"` DeleteAcceptEncoding bool `conf:"default:false"` - Oauth Oauth } type JWT struct { @@ -63,47 +128,16 @@ type ShadowAPI struct { UnknownParametersDetection bool `conf:"default:true,env:SHADOW_API_UNKNOWN_PARAMETERS_DETECTION"` } -type APIFWConfiguration struct { - conf.Version - APIFWMode - TLS TLS - ShadowAPI ShadowAPI - Denylist Denylist - Server Server - - APIHost string `conf:"default:http://0.0.0.0:8282,env:URL" validate:"required,url"` - HealthAPIHost string `conf:"default:0.0.0.0:9667,env:HEALTH_HOST" validate:"required"` - ReadTimeout time.Duration `conf:"default:5s"` - WriteTimeout time.Duration `conf:"default:5s"` - LogLevel string `conf:"default:INFO" validate:"oneof=TRACE DEBUG INFO ERROR WARNING"` - LogFormat string `conf:"default:TEXT" validate:"oneof=TEXT JSON"` - - RequestValidation string `conf:"required" validate:"required,oneof=DISABLE BLOCK LOG_ONLY"` - ResponseValidation string `conf:"required" validate:"required,oneof=DISABLE BLOCK LOG_ONLY"` - CustomBlockStatusCode int `conf:"default:403" validate:"HttpStatusCodes"` - AddValidationStatusHeader bool `conf:"default:false"` - APISpecs string `conf:"default:swagger.json,env:API_SPECS"` - PassOptionsRequests bool `conf:"default:false,env:PASS_OPTIONS"` -} - -type APIFWConfigurationAPIMode struct { - conf.Version - APIFWMode - TLS TLS - - SpecificationUpdatePeriod time.Duration `conf:"default:1m,env:API_MODE_SPECIFICATION_UPDATE_PERIOD"` - PathToSpecDB string `conf:"env:API_MODE_DEBUG_PATH_DB"` - UnknownParametersDetection bool `conf:"default:true,env:API_MODE_UNKNOWN_PARAMETERS_DETECTION"` - - APIHost string `conf:"default:http://0.0.0.0:8282,env:URL" validate:"required,url"` - HealthAPIHost string `conf:"default:0.0.0.0:9667,env:HEALTH_HOST" validate:"required"` - ReadTimeout time.Duration `conf:"default:5s"` - WriteTimeout time.Duration `conf:"default:5s"` - LogLevel string `conf:"default:INFO" validate:"oneof=TRACE DEBUG INFO ERROR WARNING"` - LogFormat string `conf:"default:TEXT" validate:"oneof=TEXT JSON"` - PassOptionsRequests bool `conf:"default:false,env:PASS_OPTIONS"` -} - -type APIFWMode struct { - Mode string `conf:"default:PROXY" validate:"oneof=PROXY API"` +type GraphQL struct { + MaxQueryComplexity int `conf:"required" validate:"required"` + MaxQueryDepth int `conf:"required" validate:"required"` + NodeCountLimit int `conf:"required" validate:"required"` + Playground bool `conf:"default:false"` + PlaygroundPath string `conf:"default:/" validate:"path"` + Introspection bool `conf:"required" validate:"required"` + Schema string `conf:"required" validate:"required"` + WSCheckOrigin bool `conf:"default:false"` + WSOrigin []string `conf:"" validate:"url"` + + RequestValidation string `conf:"required" validate:"required,oneof=DISABLE BLOCK LOG_ONLY"` } diff --git a/internal/mid/denylist.go b/internal/mid/denylist.go index e30d462..0b0e8c6 100644 --- a/internal/mid/denylist.go +++ b/internal/mid/denylist.go @@ -1,6 +1,8 @@ package mid import ( + "errors" + "fmt" "strings" "github.com/sirupsen/logrus" @@ -10,8 +12,18 @@ import ( "github.com/wallarm/api-firewall/internal/platform/web" ) +type DenylistOptions struct { + Mode string + Config *config.Denylist + CustomBlockStatusCode int + DeniedTokens *denylist.DeniedTokens + Logger *logrus.Logger +} + +var errAccessDenied = errors.New("access denied") + // Denylist forbidden requests with tokens in the blacklist -func Denylist(cfg *config.APIFWConfiguration, deniedTokens *denylist.DeniedTokens, logger *logrus.Logger) web.Middleware { +func Denylist(options *DenylistOptions) web.Middleware { // This is the actual middleware function to be executed. m := func(before web.Handler) web.Handler { @@ -20,20 +32,36 @@ func Denylist(cfg *config.APIFWConfiguration, deniedTokens *denylist.DeniedToken h := func(ctx *fasthttp.RequestCtx) error { // check existence and emptiness of the cache - if deniedTokens != nil && deniedTokens.ElementsNum > 0 { - if cfg.Denylist.Tokens.CookieName != "" { - token := string(ctx.Request.Header.Cookie(cfg.Denylist.Tokens.CookieName)) - if _, found := deniedTokens.Cache.Get(token); found { - return web.RespondError(ctx, cfg.CustomBlockStatusCode, "") + if options.DeniedTokens != nil && options.DeniedTokens.ElementsNum > 0 { + if options.Config.Tokens.CookieName != "" { + token := string(ctx.Request.Header.Cookie(options.Config.Tokens.CookieName)) + if _, found := options.DeniedTokens.Cache.Get(token); found { + options.Logger.WithFields(logrus.Fields{ + "request_id": fmt.Sprintf("#%016X", ctx.ID()), + "token": token, + }).Info("the request with the API token has been blocked") + if strings.EqualFold(options.Mode, web.GraphQLMode) { + ctx.Response.SetStatusCode(options.CustomBlockStatusCode) + return web.RespondGraphQLErrors(&ctx.Response, errAccessDenied) + } + return web.RespondError(ctx, options.CustomBlockStatusCode, "") } } - if cfg.Denylist.Tokens.HeaderName != "" { - token := string(ctx.Request.Header.Peek(cfg.Denylist.Tokens.HeaderName)) - if cfg.Denylist.Tokens.TrimBearerPrefix { + if options.Config.Tokens.HeaderName != "" { + token := string(ctx.Request.Header.Peek(options.Config.Tokens.HeaderName)) + if options.Config.Tokens.TrimBearerPrefix { token = strings.TrimPrefix(token, "Bearer ") } - if _, found := deniedTokens.Cache.Get(token); found { - return web.RespondError(ctx, cfg.CustomBlockStatusCode, "") + if _, found := options.DeniedTokens.Cache.Get(token); found { + options.Logger.WithFields(logrus.Fields{ + "request_id": fmt.Sprintf("#%016X", ctx.ID()), + "token": token, + }).Info("the request with the API token has been blocked") + if strings.EqualFold(options.Mode, web.GraphQLMode) { + ctx.Response.SetStatusCode(options.CustomBlockStatusCode) + return web.RespondGraphQLErrors(&ctx.Response, errAccessDenied) + } + return web.RespondError(ctx, options.CustomBlockStatusCode, "") } } } diff --git a/internal/mid/proxy.go b/internal/mid/proxy.go index 3c06fd6..9eb7fae 100644 --- a/internal/mid/proxy.go +++ b/internal/mid/proxy.go @@ -5,10 +5,10 @@ import ( "fmt" "net/http" "net/url" + "strings" "github.com/savsgio/gotils/strconv" "github.com/valyala/fasthttp" - "github.com/wallarm/api-firewall/internal/config" "github.com/wallarm/api-firewall/internal/platform/web" ) @@ -34,8 +34,15 @@ var ( acHeader = http.CanonicalHeaderKey("Accept-Encoding") ) +type ProxyOptions struct { + Mode string + RequestValidation string + DeleteAcceptEncoding bool + ServerURL *url.URL +} + // Proxy changes request scheme before request -func Proxy(cfg *config.APIFWConfiguration, serverUrl *url.URL) web.Middleware { +func Proxy(options *ProxyOptions) web.Middleware { // This is the actual middleware function to be executed. m := func(before web.Handler) web.Handler { @@ -44,20 +51,30 @@ func Proxy(cfg *config.APIFWConfiguration, serverUrl *url.URL) web.Middleware { h := func(ctx *fasthttp.RequestCtx) error { for _, h := range hopHeaders { + + if options.Mode == web.GraphQLMode { + // skip (not delete) ws required headers + if h == "Connection" && ctx.Request.Header.ConnectionUpgrade() { //strings.EqualFold(string(ctx.Request.Header.Peek("Connection")), "upgrade") { + continue + } + if h == "Upgrade" && strings.EqualFold(string(ctx.Request.Header.Peek("Upgrade")), "websocket") { + continue + } + } ctx.Request.Header.Del(h) } - if cfg.RequestValidation == web.ValidationBlock { + if strings.EqualFold(options.RequestValidation, web.ValidationBlock) { // add apifw header to the request ctx.Request.Header.Add(apifwHeaderName, fmt.Sprintf("%016X", ctx.ID())) } - if !bytes.Equal([]byte(serverUrl.Scheme), ctx.Request.URI().Scheme()) { - ctx.Request.URI().SetSchemeBytes([]byte(serverUrl.Scheme)) + if !bytes.Equal([]byte(options.ServerURL.Scheme), ctx.Request.URI().Scheme()) { + ctx.Request.URI().SetSchemeBytes([]byte(options.ServerURL.Scheme)) } - if !bytes.Equal([]byte(serverUrl.Host), ctx.Request.URI().Host()) { - ctx.Request.URI().SetHostBytes([]byte(serverUrl.Host)) + if !bytes.Equal([]byte(options.ServerURL.Host), ctx.Request.URI().Host()) { + ctx.Request.URI().SetHostBytes([]byte(options.ServerURL.Host)) } // update or set x-forwarded-for header @@ -71,13 +88,23 @@ func Proxy(cfg *config.APIFWConfiguration, serverUrl *url.URL) web.Middleware { } // delete Accept-Encoding header - if cfg.Server.DeleteAcceptEncoding { + if options.DeleteAcceptEncoding { ctx.Request.Header.Del(acHeader) } err := before(ctx) for _, h := range hopHeaders { + + if options.Mode == web.GraphQLMode { + // skip (not delete) ws required headers + if h == "Connection" && ctx.Response.Header.ConnectionUpgrade() { + continue + } + if h == "Upgrade" && strings.EqualFold(string(ctx.Response.Header.Peek("Upgrade")), "websocket") { + continue + } + } ctx.Response.Header.Del(h) } diff --git a/internal/mid/shadowAPI.go b/internal/mid/shadowAPI.go index 8f2a73f..4e9eaf8 100644 --- a/internal/mid/shadowAPI.go +++ b/internal/mid/shadowAPI.go @@ -3,17 +3,16 @@ package mid import ( "fmt" - "golang.org/x/exp/slices" - "github.com/sirupsen/logrus" "github.com/valyala/fasthttp" "github.com/wallarm/api-firewall/internal/config" "github.com/wallarm/api-firewall/internal/platform/web" + "golang.org/x/exp/slices" ) // ShadowAPIMonitor check each request for the params, methods or paths that are not specified // in the OpenAPI specification and log each violation -func ShadowAPIMonitor(logger *logrus.Logger, config *config.ShadowAPI) web.Middleware { +func ShadowAPIMonitor(logger *logrus.Logger, cfg *config.ShadowAPI) web.Middleware { // This is the actual middleware function to be executed. m := func(before web.Handler) web.Handler { @@ -48,7 +47,7 @@ func ShadowAPIMonitor(logger *logrus.Logger, config *config.ShadowAPI) web.Middl // check response status code statusCode := ctx.Response.StatusCode() - idx := slices.IndexFunc(config.ExcludeList, func(c int) bool { return c == statusCode }) + idx := slices.IndexFunc(cfg.ExcludeList, func(c int) bool { return c == statusCode }) // if response status code not found in the OpenAPI spec AND the code not in the exclude list if isProxyStatusCodeNotFound && idx < 0 { logger.WithFields(logrus.Fields{ diff --git a/internal/platform/complexity/complexity.go b/internal/platform/complexity/complexity.go new file mode 100644 index 0000000..8b2ea55 --- /dev/null +++ b/internal/platform/complexity/complexity.go @@ -0,0 +1,35 @@ +package complexity + +import ( + "fmt" + + "github.com/wallarm/api-firewall/internal/config" + "github.com/wundergraph/graphql-go-tools/pkg/graphql" +) + +// ValidateQuery performs the query complexity checks +func ValidateQuery(cfg *config.GraphQL, s *graphql.Schema, r *graphql.Request) graphql.RequestErrors { + result, err := r.CalculateComplexity(graphql.DefaultComplexityCalculator, s) + if err != nil { + return graphql.RequestErrorsFromError(err) + } + + var requestErrors graphql.RequestErrors + + if cfg.MaxQueryComplexity > 0 && result.Complexity > cfg.MaxQueryComplexity { + requestErrors = append(requestErrors, + 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)}) + } + + if cfg.MaxQueryDepth > 0 && result.Depth > cfg.MaxQueryDepth { + requestErrors = append(requestErrors, + 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)}) + } + + if cfg.NodeCountLimit > 0 && result.NodeCount > cfg.NodeCountLimit { + requestErrors = append(requestErrors, + 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)}) + } + + return requestErrors +} diff --git a/internal/platform/complexity/complexity_test.go b/internal/platform/complexity/complexity_test.go new file mode 100644 index 0000000..c127209 --- /dev/null +++ b/internal/platform/complexity/complexity_test.go @@ -0,0 +1,126 @@ +package complexity + +import ( + "testing" + + "github.com/stretchr/testify/require" + "github.com/wallarm/api-firewall/internal/config" + "github.com/wundergraph/graphql-go-tools/pkg/graphql" +) + +const ( + testQuery = ` +query { + room(name: "TestChat") { + name + messages { + id + text + createdBy + createdAt + } + } +}` + testSchema = ` +type Chatroom { + name: String! + messages: [Message!]! +} + +type Message { + id: ID! + text: String! + createdBy: String! + createdAt: Time! +} + +type Query { + room(name:String!): Chatroom +} +` +) + +func TestComplexity(t *testing.T) { + testCases := map[string]struct { + cfgGraphQL *config.GraphQL + expectedErrorCount int + }{ + "disabled_all": { + cfgGraphQL: &config.GraphQL{}, + }, + "invalid_query": { + cfgGraphQL: &config.GraphQL{ + NodeCountLimit: 1, + MaxQueryDepth: 1, + MaxQueryComplexity: 1, + }, + expectedErrorCount: 3, + }, + "invalid_query_node_count_limit": { + cfgGraphQL: &config.GraphQL{ + NodeCountLimit: 1, + }, + expectedErrorCount: 1, + }, + "invalid_query_max_depth": { + cfgGraphQL: &config.GraphQL{ + MaxQueryDepth: 1, + }, + expectedErrorCount: 1, + }, + "invalid_query_max_complexity": { + cfgGraphQL: &config.GraphQL{ + MaxQueryComplexity: 1, + }, + expectedErrorCount: 1, + }, + "valid_complexity": { + cfgGraphQL: &config.GraphQL{ + MaxQueryComplexity: 2, + }, + expectedErrorCount: 0, + }, + "valid_max_depth": { + cfgGraphQL: &config.GraphQL{ + MaxQueryDepth: 3, + }, + expectedErrorCount: 0, + }, + "valid_node_count_limit": { + cfgGraphQL: &config.GraphQL{ + NodeCountLimit: 2, + }, + expectedErrorCount: 0, + }, + "valid_query_limits": { + cfgGraphQL: &config.GraphQL{ + MaxQueryComplexity: 2, + MaxQueryDepth: 3, + NodeCountLimit: 2, + }, + expectedErrorCount: 0, + }, + } + + s, err := graphql.NewSchemaFromString(testSchema) + if err != nil { + t.Fatal(err) + } + + gqlRequest := &graphql.Request{ + Query: testQuery, + } + + if _, err := s.Normalize(); err != nil { + t.Fatal(err) + } + + if _, err := gqlRequest.Normalize(s); err != nil { + t.Fatal(err) + } + + for name, testCase := range testCases { + requestErrors := ValidateQuery(testCase.cfgGraphQL, s, gqlRequest) + require.Equalf(t, testCase.expectedErrorCount, requestErrors.Count(), "case %s: unexpected error count", name) + } +} diff --git a/internal/platform/database/database.go b/internal/platform/database/database.go index 3af0b14..d0bf567 100644 --- a/internal/platform/database/database.go +++ b/internal/platform/database/database.go @@ -100,7 +100,7 @@ func (s *SQLLite) Load(dbStoragePath string) error { entries[entry.SchemaID] = &entry } - if err = rows.Err(); err != nil { + if err := rows.Err(); err != nil { return err } diff --git a/internal/platform/denylist/denylist.go b/internal/platform/denylist/denylist.go index d0546bd..d13c1d0 100644 --- a/internal/platform/denylist/denylist.go +++ b/internal/platform/denylist/denylist.go @@ -21,9 +21,9 @@ type DeniedTokens struct { ElementsNum int64 } -func New(cfg *config.APIFWConfiguration, logger *logrus.Logger) (*DeniedTokens, error) { +func New(cfg *config.Denylist, logger *logrus.Logger) (*DeniedTokens, error) { - if cfg.Denylist.Tokens.File == "" { + if cfg.Tokens.File == "" { return nil, nil } @@ -31,7 +31,7 @@ func New(cfg *config.APIFWConfiguration, logger *logrus.Logger) (*DeniedTokens, var totalCacheCapacity int64 // open tokens storage - f, err := os.Open(cfg.Denylist.Tokens.File) + f, err := os.Open(cfg.Tokens.File) if err != nil { return nil, err } @@ -73,6 +73,10 @@ func New(cfg *config.APIFWConfiguration, logger *logrus.Logger) (*DeniedTokens, var numOfElements int64 totalEntries10P := totalEntries / 10 + if totalEntries10P == 0 { + totalEntries10P = 1 + } + // 10% counter counter10P := 0 diff --git a/internal/platform/oauth2/introspection.go b/internal/platform/oauth2/introspection.go index eebe014..178dce5 100644 --- a/internal/platform/oauth2/introspection.go +++ b/internal/platform/oauth2/introspection.go @@ -77,7 +77,7 @@ func (i *Introspection) getTokenMetaInfo(token string) (map[string]interface{}, req := fasthttp.AcquireRequest() req.Header.SetMethod(i.Cfg.Introspection.EndpointMethod) - parsedEndpointUrl, err := url.Parse(i.Cfg.Introspection.Endpoint) + parsedEndpointURL, err := url.Parse(i.Cfg.Introspection.Endpoint) if err != nil { return nil, fmt.Errorf("failed to parse introspection endpoint url: %v", err) } @@ -95,18 +95,18 @@ func (i *Introspection) getTokenMetaInfo(token string) (map[string]interface{}, } case "get": if i.Cfg.Introspection.EndpointParams != "" { - parsedEndpointUrl.RawQuery = i.Cfg.Introspection.EndpointParams + parsedEndpointURL.RawQuery = i.Cfg.Introspection.EndpointParams } if i.Cfg.Introspection.TokenParamName != "" { - reqQuery := parsedEndpointUrl.Query() + reqQuery := parsedEndpointURL.Query() reqQuery.Add(i.Cfg.Introspection.TokenParamName, token) - parsedEndpointUrl.RawQuery = reqQuery.Encode() + parsedEndpointURL.RawQuery = reqQuery.Encode() } } - t := parsedEndpointUrl.String() + t := parsedEndpointURL.String() req.SetRequestURI(t) if i.Cfg.Introspection.ClientAuthBearerToken == "" { diff --git a/internal/platform/oauth2/jwt.go b/internal/platform/oauth2/jwt.go index 279fdd9..30d56a7 100644 --- a/internal/platform/oauth2/jwt.go +++ b/internal/platform/oauth2/jwt.go @@ -9,7 +9,6 @@ import ( "github.com/golang-jwt/jwt" "github.com/pkg/errors" "github.com/sirupsen/logrus" - "github.com/wallarm/api-firewall/internal/config" ) @@ -63,7 +62,7 @@ func (j *JWT) Validate(ctx context.Context, tokenWithBearer string, scopes []str for _, scope := range scopes { scopeFound := false for _, scopeInToken := range scopesInToken { - if strings.ToLower(scope) == scopeInToken { + if strings.EqualFold(scope, scopeInToken) { scopeFound = true break } diff --git a/internal/platform/proxy/chainpool.go b/internal/platform/proxy/chainpool.go index 54f6c34..3f5b29f 100644 --- a/internal/platform/proxy/chainpool.go +++ b/internal/platform/proxy/chainpool.go @@ -12,13 +12,12 @@ import ( "net" "os" "sync" + "time" "github.com/valyala/fasthttp" - "github.com/wallarm/api-firewall/internal/config" ) var ( - errFactoryNotHelp = errors.New("factory is not able to fill the pool") errInvalidCapacitySetting = errors.New("invalid capacity settings") errClosed = errors.New("err: chan closed") ) @@ -27,18 +26,18 @@ type HTTPClient interface { Do(req *fasthttp.Request, resp *fasthttp.Response) error } -func factory(hostAddr string, server *config.Server, tlsConfig *tls.Config) (HTTPClient, error) { +func factory(hostAddr string, options *Options, tlsConfig *tls.Config) HTTPClient { - var proxyClient = &fasthttp.Client{ + proxyClient := fasthttp.Client{ Dial: func(addr string) (net.Conn, error) { - return fasthttp.DialTimeout(hostAddr, server.DialTimeout) + return fasthttp.DialTimeout(hostAddr, options.DialTimeout) }, TLSConfig: tlsConfig, - MaxConnsPerHost: server.MaxConnsPerHost, - ReadTimeout: server.ReadTimeout, - WriteTimeout: server.WriteTimeout, + MaxConnsPerHost: options.MaxConnsPerHost, + ReadTimeout: options.ReadTimeout, + WriteTimeout: options.WriteTimeout, } - return proxyClient, nil + return &proxyClient } type Pool interface { @@ -69,15 +68,26 @@ type chanPool struct { // factory is factory method to generate ReverseProxy // this can be customized // factory Factory - server *config.Server - host string + options *Options + host string tlsConfig *tls.Config } +type Options struct { + InitialPoolCapacity int + ClientPoolCapacity int + InsecureConnection bool + RootCA string + MaxConnsPerHost int + ReadTimeout time.Duration + WriteTimeout time.Duration + DialTimeout time.Duration +} + // NewChanPool to new a pool with some params -func NewChanPool(initialCap, maxCap int, hostAddr string, server *config.Server) (Pool, error) { - if initialCap < 0 || maxCap <= 0 || initialCap > maxCap { +func NewChanPool(hostAddr string, options *Options) (Pool, error) { + if options.InitialPoolCapacity < 0 || options.ClientPoolCapacity <= 0 || options.InitialPoolCapacity > options.ClientPoolCapacity { return nil, errInvalidCapacitySetting } @@ -87,12 +97,11 @@ func NewChanPool(initialCap, maxCap int, hostAddr string, server *config.Server) return nil, err } - if server.RootCA != "" { - + if options.RootCA != "" { // Read in the cert file - certs, err := os.ReadFile(server.RootCA) + certs, err := os.ReadFile(options.RootCA) if err != nil { - return nil, fmt.Errorf("failed to append %q to RootCAs: %v", server.RootCA, err) + return nil, fmt.Errorf("failed to append %q to RootCAs: %v", options.RootCA, err) } // Append our cert to the system pool @@ -102,26 +111,23 @@ func NewChanPool(initialCap, maxCap int, hostAddr string, server *config.Server) } tlsConfig := &tls.Config{ - InsecureSkipVerify: server.InsecureConnection, + InsecureSkipVerify: options.InsecureConnection, RootCAs: rootCAs, } // initialize the chanPool pool := &chanPool{ mutex: sync.RWMutex{}, - reverseProxyChan: make(chan HTTPClient, maxCap), - server: server, + reverseProxyChan: make(chan HTTPClient, options.ClientPoolCapacity), + options: options, host: hostAddr, tlsConfig: tlsConfig, } // create initial connections, if something goes wrong, // just close the pool error out. - for i := 0; i < initialCap; i++ { - proxy, err := factory(hostAddr, server, tlsConfig) - if err != nil { - return nil, errFactoryNotHelp - } + for i := 0; i < options.InitialPoolCapacity; i++ { + proxy := factory(hostAddr, options, tlsConfig) pool.reverseProxyChan <- proxy } @@ -153,7 +159,6 @@ func (p *chanPool) Close() { // Get a *ReverseProxy from pool, it will get an error while // reverseProxyChan is nil or pool has been closed func (p *chanPool) Get() (HTTPClient, error) { - if p.reverseProxyChan == nil { return nil, errClosed } @@ -167,10 +172,7 @@ func (p *chanPool) Get() (HTTPClient, error) { } return proxy, nil default: - proxy, err := factory(p.host, p.server, p.tlsConfig) - if err != nil { - return nil, err - } + proxy := factory(p.host, p.options, p.tlsConfig) return proxy, nil } } diff --git a/internal/platform/proxy/proxy.go b/internal/platform/proxy/proxy.go new file mode 100644 index 0000000..b4d61e0 --- /dev/null +++ b/internal/platform/proxy/proxy.go @@ -0,0 +1,41 @@ +package proxy + +import ( + "github.com/valyala/fasthttp" + "github.com/wallarm/api-firewall/internal/platform/web" +) + +// Perform function proxies the request to the backend server +func Perform(ctx *fasthttp.RequestCtx, proxyPool Pool) error { + + client, err := proxyPool.Get() + if err != nil { + return err + } + defer proxyPool.Put(client) + + if err := client.Do(&ctx.Request, &ctx.Response); err != nil { + // request proxy has been failed + ctx.SetUserValue(web.RequestProxyFailed, true) + + switch err { + case fasthttp.ErrDialTimeout: + if err := web.RespondError(ctx, fasthttp.StatusGatewayTimeout, ""); err != nil { + return err + } + case fasthttp.ErrNoFreeConns: + if err := web.RespondError(ctx, fasthttp.StatusServiceUnavailable, ""); err != nil { + return err + } + default: + if err := web.RespondError(ctx, fasthttp.StatusBadGateway, ""); err != nil { + return err + } + } + + // The error has been handled so we can stop propagating it + return err + } + + return nil +} diff --git a/internal/platform/proxy/ws.go b/internal/platform/proxy/ws.go new file mode 100644 index 0000000..e4bcd55 --- /dev/null +++ b/internal/platform/proxy/ws.go @@ -0,0 +1,112 @@ +package proxy + +import ( + "encoding/json" + "fmt" + "sync" + + "github.com/fasthttp/websocket" + "github.com/savsgio/gotils/strconv" + "github.com/sirupsen/logrus" + "github.com/valyala/fasthttp" + "github.com/wundergraph/graphql-go-tools/pkg/graphql" +) + +var _ WebSocketConn = (*FastHTTPWebSocketConn)(nil) + +// WebSocketConn defines the interface for WebSocket connections +type WebSocketConn interface { + ReadMessage() (messageType int, p []byte, err error) + WriteMessage(messageType int, data []byte) error + SendError(messageType int, msgID string, requestErrors error) error + SendComplete(messageType int, id string) error + SendCloseConnection(closeType int) error + Close() error +} + +// FastHTTPWebSocketConn implements the WebSocketConn interface +type FastHTTPWebSocketConn struct { + Conn *websocket.Conn + Logger *logrus.Logger + Ctx *fasthttp.RequestCtx + mu sync.Mutex +} + +type GqlWSErrorMessage struct { + ID string `json:"id"` + Type string `json:"type"` + Payload graphql.RequestErrors `json:"payload,omitempty"` +} + +func (f *FastHTTPWebSocketConn) ReadMessage() (messageType int, p []byte, err error) { + if f.Logger != nil && f.Ctx != nil && f.Logger.Level == logrus.TraceLevel { + f.Logger.WithFields(logrus.Fields{ + "protocol": "websocket", + "local_addr": f.Conn.LocalAddr().String(), + "remote_addr": f.Conn.RemoteAddr().String(), + "message": strconv.B2S(p), + "message_type": messageType, + "request_id": fmt.Sprintf("#%016X", f.Ctx.ID()), + }).Trace("read message") + } + return f.Conn.ReadMessage() +} + +func (f *FastHTTPWebSocketConn) WriteMessage(messageType int, data []byte) error { + if f.Logger != nil && f.Ctx != nil && f.Logger.Level == logrus.TraceLevel { + f.Logger.WithFields(logrus.Fields{ + "protocol": "websocket", + "local_addr": f.Conn.LocalAddr().String(), + "remote_addr": f.Conn.RemoteAddr().String(), + "message": strconv.B2S(data), + "message_type": messageType, + "request_id": fmt.Sprintf("#%016X", f.Ctx.ID()), + }).Trace("write message") + } + f.mu.Lock() + defer f.mu.Unlock() + return f.Conn.WriteMessage(messageType, data) +} + +func (f *FastHTTPWebSocketConn) Close() error { + return f.Conn.Close() +} + +func (f *FastHTTPWebSocketConn) SendError(messageType int, msgID string, requestErrors error) error { + + wsMsg := GqlWSErrorMessage{ + ID: msgID, + Type: "error", + Payload: graphql.RequestErrorsFromError(requestErrors), + } + + msg, err := json.Marshal(wsMsg) + if err != nil { + return err + } + + if err := f.WriteMessage(messageType, msg); err != nil { + return err + } + + return nil +} + +func (f *FastHTTPWebSocketConn) SendComplete(messageType int, id string) error { + + completeMsg := []byte(fmt.Sprintf("{\"id\":%q,\"type\":\"complete\"}", id)) + if err := f.WriteMessage(messageType, completeMsg); err != nil { + return err + } + + return nil +} + +func (f *FastHTTPWebSocketConn) SendCloseConnection(closeType int) error { + errMsg := websocket.FormatCloseMessage(closeType, "") + if err := f.WriteMessage(websocket.CloseMessage, errMsg); err != nil { + return err + } + + return nil +} diff --git a/internal/platform/proxy/wsClient.go b/internal/platform/proxy/wsClient.go new file mode 100644 index 0000000..435b978 --- /dev/null +++ b/internal/platform/proxy/wsClient.go @@ -0,0 +1,144 @@ +package proxy + +import ( + "bytes" + "crypto/tls" + "crypto/x509" + "fmt" + "io" + "net/http" + "os" + "path" + "sync" + "time" + + "github.com/fasthttp/websocket" + "github.com/pkg/errors" + "github.com/savsgio/gotils/strconv" + "github.com/sirupsen/logrus" + "github.com/valyala/fasthttp" +) + +var _ WebSocketClient = (*FastHTTPWebSocketClient)(nil) + +type WSClientOptions struct { + Scheme string + Host string + Path string + InsecureConnection bool + RootCA string + DialTimeout time.Duration +} + +// WebSocketClient defines the interface for WebSocket connections Pool +type WebSocketClient interface { + GetConn(ctx *fasthttp.RequestCtx) (*FastHTTPWebSocketConn, error) +} + +// FastHTTPWebSocketClient implements the WebSocketClient interface +type FastHTTPWebSocketClient struct { + Dialer *websocket.Dialer + ConnStr string + Logger *logrus.Logger +} + +var bufferPool = sync.Pool{ + New: func() any { + return new(bytes.Buffer) + }, +} + +// wsCopyResponse copies WS first response header from the backend server +func wsCopyResponse(dst *fasthttp.Response, src *http.Response) error { + for k, vv := range src.Header { + for _, v := range vv { + dst.Header.Add(k, v) + } + } + + dst.SetStatusCode(src.StatusCode) + defer src.Body.Close() + + buf := bufferPool.Get().(*bytes.Buffer) + if _, err := io.Copy(buf, src.Body); err != nil { + return err + } + dst.SetBody(buf.Bytes()) + + buf.Reset() + bufferPool.Put(buf) + + return nil +} + +// builtinForwardHeaderHandler built in handler for dealing forward request headers +func builtinForwardHeaderHandler(ctx *fasthttp.RequestCtx) (forwardHeader http.Header) { + forwardHeader = make(http.Header, 2) + + // Pass headers from the incoming request to the dialer to forward them to + // the final destinations + origin := strconv.B2S(ctx.Request.Header.Peek("Origin")) + if origin != "" { + forwardHeader.Add("Origin", origin) + } + + cookie := strconv.B2S(ctx.Request.Header.Peek("Cookie")) + if cookie != "" { + forwardHeader.Add("Cookie", cookie) + } + + return +} + +func NewWSClient(logger *logrus.Logger, options *WSClientOptions) (WebSocketClient, error) { + + // get the SystemCertPool, continue with an empty pool on error + rootCAs, err := x509.SystemCertPool() + if err != nil { + return nil, err + } + + if options.RootCA != "" { + // read in the cert file + certs, err := os.ReadFile(options.RootCA) + if err != nil { + return nil, fmt.Errorf("failed to append %q to RootCAs: %v", options.RootCA, err) + } + + // append our cert to the system pool + if ok := rootCAs.AppendCertsFromPEM(certs); !ok { + return nil, errors.New("no certs appended, using system certs only") + } + } + + tlsConfig := &tls.Config{ + InsecureSkipVerify: options.InsecureConnection, + RootCAs: rootCAs, + } + + dialer := websocket.Dialer{ + TLSClientConfig: tlsConfig, + HandshakeTimeout: options.DialTimeout, + Subprotocols: []string{"graphql-ws"}, + } + + return &FastHTTPWebSocketClient{ + Dialer: &dialer, + ConnStr: fmt.Sprintf("%s://%s", options.Scheme, path.Join(options.Host, options.Path)), + Logger: logger, + }, nil +} + +func (f *FastHTTPWebSocketClient) GetConn(ctx *fasthttp.RequestCtx) (*FastHTTPWebSocketConn, error) { + backendConn, backendResp, err := f.Dialer.Dial(f.ConnStr, builtinForwardHeaderHandler(ctx)) + if err != nil { + return nil, err + } + + // copy response from ws to client response + if err := wsCopyResponse(&ctx.Response, backendResp); err != nil { + return nil, err + } + + return &FastHTTPWebSocketConn{Conn: backendConn, Logger: f.Logger, Ctx: ctx}, nil +} diff --git a/internal/platform/proxy/wsClient_mock.go b/internal/platform/proxy/wsClient_mock.go new file mode 100644 index 0000000..b96713c --- /dev/null +++ b/internal/platform/proxy/wsClient_mock.go @@ -0,0 +1,50 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: ./internal/platform/proxy/wsClient.go + +// Package proxy is a generated GoMock package. +package proxy + +import ( + reflect "reflect" + + gomock "github.com/golang/mock/gomock" + fasthttp "github.com/valyala/fasthttp" +) + +// MockWebSocketClient is a mock of WebSocketClient interface. +type MockWebSocketClient struct { + ctrl *gomock.Controller + recorder *MockWebSocketClientMockRecorder +} + +// MockWebSocketClientMockRecorder is the mock recorder for MockWebSocketClient. +type MockWebSocketClientMockRecorder struct { + mock *MockWebSocketClient +} + +// NewMockWebSocketClient creates a new mock instance. +func NewMockWebSocketClient(ctrl *gomock.Controller) *MockWebSocketClient { + mock := &MockWebSocketClient{ctrl: ctrl} + mock.recorder = &MockWebSocketClientMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockWebSocketClient) EXPECT() *MockWebSocketClientMockRecorder { + return m.recorder +} + +// GetConn mocks base method. +func (m *MockWebSocketClient) GetConn(ctx *fasthttp.RequestCtx) (*FastHTTPWebSocketConn, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetConn", ctx) + ret0, _ := ret[0].(*FastHTTPWebSocketConn) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetConn indicates an expected call of GetConn. +func (mr *MockWebSocketClientMockRecorder) GetConn(ctx interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetConn", reflect.TypeOf((*MockWebSocketClient)(nil).GetConn), ctx) +} diff --git a/internal/platform/proxy/ws_mock.go b/internal/platform/proxy/ws_mock.go new file mode 100644 index 0000000..62a812c --- /dev/null +++ b/internal/platform/proxy/ws_mock.go @@ -0,0 +1,120 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: ./internal/platform/proxy/ws.go + +// Package proxy is a generated GoMock package. +package proxy + +import ( + reflect "reflect" + + gomock "github.com/golang/mock/gomock" +) + +// MockWebSocketConn is a mock of WebSocketConn interface. +type MockWebSocketConn struct { + ctrl *gomock.Controller + recorder *MockWebSocketConnMockRecorder +} + +// MockWebSocketConnMockRecorder is the mock recorder for MockWebSocketConn. +type MockWebSocketConnMockRecorder struct { + mock *MockWebSocketConn +} + +// NewMockWebSocketConn creates a new mock instance. +func NewMockWebSocketConn(ctrl *gomock.Controller) *MockWebSocketConn { + mock := &MockWebSocketConn{ctrl: ctrl} + mock.recorder = &MockWebSocketConnMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockWebSocketConn) EXPECT() *MockWebSocketConnMockRecorder { + return m.recorder +} + +// Close mocks base method. +func (m *MockWebSocketConn) Close() error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Close") + ret0, _ := ret[0].(error) + return ret0 +} + +// Close indicates an expected call of Close. +func (mr *MockWebSocketConnMockRecorder) Close() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Close", reflect.TypeOf((*MockWebSocketConn)(nil).Close)) +} + +// ReadMessage mocks base method. +func (m *MockWebSocketConn) ReadMessage() (int, []byte, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "ReadMessage") + ret0, _ := ret[0].(int) + ret1, _ := ret[1].([]byte) + ret2, _ := ret[2].(error) + return ret0, ret1, ret2 +} + +// ReadMessage indicates an expected call of ReadMessage. +func (mr *MockWebSocketConnMockRecorder) ReadMessage() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ReadMessage", reflect.TypeOf((*MockWebSocketConn)(nil).ReadMessage)) +} + +// SendCloseConnection mocks base method. +func (m *MockWebSocketConn) SendCloseConnection(closeType int) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "SendCloseConnection", closeType) + ret0, _ := ret[0].(error) + return ret0 +} + +// SendCloseConnection indicates an expected call of SendCloseConnection. +func (mr *MockWebSocketConnMockRecorder) SendCloseConnection(closeType interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SendCloseConnection", reflect.TypeOf((*MockWebSocketConn)(nil).SendCloseConnection), closeType) +} + +// SendComplete mocks base method. +func (m *MockWebSocketConn) SendComplete(messageType int, id string) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "SendComplete", messageType, id) + ret0, _ := ret[0].(error) + return ret0 +} + +// SendComplete indicates an expected call of SendComplete. +func (mr *MockWebSocketConnMockRecorder) SendComplete(messageType, id interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SendComplete", reflect.TypeOf((*MockWebSocketConn)(nil).SendComplete), messageType, id) +} + +// SendError mocks base method. +func (m *MockWebSocketConn) SendError(messageType int, msgID string, requestErrors error) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "SendError", messageType, msgID, requestErrors) + ret0, _ := ret[0].(error) + return ret0 +} + +// SendError indicates an expected call of SendError. +func (mr *MockWebSocketConnMockRecorder) SendError(messageType, msgID, requestErrors interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SendError", reflect.TypeOf((*MockWebSocketConn)(nil).SendError), messageType, msgID, requestErrors) +} + +// WriteMessage mocks base method. +func (m *MockWebSocketConn) WriteMessage(messageType int, data []byte) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "WriteMessage", messageType, data) + ret0, _ := ret[0].(error) + return ret0 +} + +// WriteMessage indicates an expected call of WriteMessage. +func (mr *MockWebSocketConnMockRecorder) WriteMessage(messageType, data interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "WriteMessage", reflect.TypeOf((*MockWebSocketConn)(nil).WriteMessage), messageType, data) +} diff --git a/internal/platform/validator/graphql.go b/internal/platform/validator/graphql.go new file mode 100644 index 0000000..2fbb27c --- /dev/null +++ b/internal/platform/validator/graphql.go @@ -0,0 +1,165 @@ +package validator + +import ( + "bytes" + "encoding/json" + "errors" + "io" + "net/url" + "strings" + "sync" + + "github.com/savsgio/gotils/strconv" + "github.com/valyala/fasthttp" + "github.com/wallarm/api-firewall/internal/config" + "github.com/wallarm/api-firewall/internal/platform/complexity" + "github.com/wundergraph/graphql-go-tools/pkg/astparser" + "github.com/wundergraph/graphql-go-tools/pkg/graphql" + "github.com/wundergraph/graphql-go-tools/pkg/operationreport" +) + +var bufferPool = sync.Pool{ + New: func() any { + return new(bytes.Buffer) + }, +} + +var ( + ErrNotAllowIntrospectionQuery = errors.New("introspection query is not allowed") + ErrGraphQLQueryNotFound = errors.New("GraphQL query not found in the request") + ErrWrongGraphQLQueryTypeInGETRequest = errors.New("wrong GraphQL query type in GET request") +) + +// ValidateGraphQLRequest validates the GraphQL request +func ValidateGraphQLRequest(cfg *config.GraphQL, schema *graphql.Schema, r *graphql.Request) (*graphql.ValidationResult, error) { + + // introspection request check + if !cfg.Introspection { + isIntrospectQuery, err := r.IsIntrospectionQuery() + if err != nil { + return &graphql.ValidationResult{Valid: false, Errors: nil}, err + } + + if isIntrospectQuery { + return &graphql.ValidationResult{Valid: false, Errors: graphql.RequestErrorsFromError(ErrNotAllowIntrospectionQuery)}, nil + } + + } + + // validate operation name value + if err := validateOperationName(r); err != nil { + return &graphql.ValidationResult{Valid: false, Errors: graphql.RequestErrorsFromError(err)}, nil + } + + // skip query complexity check if it is not configured + if cfg.NodeCountLimit > 0 || cfg.MaxQueryDepth > 0 || cfg.MaxQueryComplexity > 0 { + + // check query complexity + requestErrors := complexity.ValidateQuery(cfg, schema, r) + if requestErrors.Count() > 0 { + return &graphql.ValidationResult{Valid: false, Errors: requestErrors}, nil + } + } + + // normalize query + normResult, err := r.Normalize(schema) + if err != nil { + return &graphql.ValidationResult{Valid: false, Errors: nil}, err + } + + if !normResult.Successful { + return &graphql.ValidationResult{Valid: false, Errors: normResult.Errors}, nil + } + + // validate query + result, err := r.ValidateForSchema(schema) + if err != nil { + return &graphql.ValidationResult{Valid: false, Errors: nil}, err + } + + if !result.Valid { + return &result, nil + } + + return &graphql.ValidationResult{Valid: true, Errors: nil}, nil +} + +// ParseGraphQLRequest function parses the GraphQL request +func ParseGraphQLRequest(ctx *fasthttp.RequestCtx) (*graphql.Request, error) { + + gqlRequest := new(graphql.Request) + + query := bufferPool.Get().(*bytes.Buffer) + defer func() { + query.Reset() + bufferPool.Put(query) + }() + + httpMethod := string(ctx.Method()) + + switch httpMethod { + case fasthttp.MethodGet: + + // build json query + query.WriteString("{\"query\":") + // unescape the query string and encode JSON special chars + queryArgQuery, err := url.QueryUnescape(strconv.B2S(ctx.Request.URI().QueryArgs().Peek("query"))) + if err != nil { + return nil, err + } + err = json.NewEncoder(query).Encode(&queryArgQuery) + if err != nil { + return nil, err + } + query.WriteString(",\"operationName\":\"") + query.Write(ctx.Request.URI().QueryArgs().Peek("operationName")) + query.WriteString("\"}") + + case fasthttp.MethodPost: + query = bytes.NewBuffer(ctx.Request.Body()) + } + + if query.Len() > 0 { + + if err := graphql.UnmarshalRequest(io.NopCloser(query), gqlRequest); err != nil { + return nil, err + } + + operationType, err := gqlRequest.OperationType() + if err != nil { + return nil, err + } + + if httpMethod == fasthttp.MethodGet && operationType != graphql.OperationTypeQuery { + return nil, ErrWrongGraphQLQueryTypeInGETRequest + } + + return gqlRequest, nil + } + + return nil, ErrGraphQLQueryNotFound +} + +func validateOperationName(gqlRequest *graphql.Request) error { + + operation, _ := astparser.ParseGraphqlDocumentString(gqlRequest.Query) + numOfOperations := operation.NumOfOperationDefinitions() + operationName := strings.TrimSpace(gqlRequest.OperationName) + report := &operationreport.Report{} + + // operationName is not present in the request but the number of operations is more than 1 + if operationName == "" && numOfOperations > 1 { + report.AddExternalError(operationreport.ErrRequiredOperationNameIsMissing()) + + return report + } + + // operationName is not found in the request + if !operation.OperationNameExists(operationName) { + report.AddExternalError(operationreport.ErrOperationWithProvidedOperationNameNotFound(operationName)) + + return report + } + + return nil +} diff --git a/internal/platform/validator/internal.go b/internal/platform/validator/internal.go index 8a40050..bbe7dd1 100644 --- a/internal/platform/validator/internal.go +++ b/internal/platform/validator/internal.go @@ -1,9 +1,10 @@ package validator import ( - "github.com/valyala/fastjson" "reflect" "strings" + + "github.com/valyala/fastjson" ) func parseMediaType(contentType string) string { @@ -14,7 +15,7 @@ func parseMediaType(contentType string) string { return contentType[:i] } -func isNilValue(value interface{}) bool { +func isNilValue(value any) bool { if value == nil { return true } diff --git a/internal/platform/validator/unknown_parameters_request.go b/internal/platform/validator/unknown_parameters_request.go index c705082..27692e0 100644 --- a/internal/platform/validator/unknown_parameters_request.go +++ b/internal/platform/validator/unknown_parameters_request.go @@ -3,12 +3,12 @@ package validator import ( "bytes" "encoding/csv" - "github.com/getkin/kin-openapi/routers" "io" "net/http" "github.com/getkin/kin-openapi/openapi3" "github.com/getkin/kin-openapi/openapi3filter" + "github.com/getkin/kin-openapi/routers" "github.com/pkg/errors" "github.com/valyala/fasthttp" "github.com/valyala/fastjson" @@ -20,9 +20,6 @@ var ErrUnknownQueryParameter = errors.New("query parameter not defined in the Op // ErrUnknownBodyParameter is returned when a body parameter not defined in the OpenAPI specification. var ErrUnknownBodyParameter = errors.New("body parameter not defined in the OpenAPI specification") -// ErrUnknownContentType is returned when the API FW can't parse the request body -var ErrUnknownContentType = errors.New("unknown content type of the request body") - // ErrDecodingFailed is returned when the API FW got error or unexpected value from the decoder var ErrDecodingFailed = errors.New("the decoder returned the error") diff --git a/internal/platform/validator/validate_request.go b/internal/platform/validator/validate_request.go index 9e460c1..c811635 100644 --- a/internal/platform/validator/validate_request.go +++ b/internal/platform/validator/validate_request.go @@ -291,12 +291,11 @@ func ValidateRequestBody(ctx context.Context, input *openapi3filter.RequestValid // Validate JSON with the schema if err := contentType.Schema.Value.VisitJSON(value, opts...); err != nil { - schemaId := getSchemaIdentifier(contentType.Schema) - schemaId = prependSpaceIfNeeded(schemaId) + schemaID := prependSpaceIfNeeded(getSchemaIdentifier(contentType.Schema)) return &openapi3filter.RequestError{ Input: input, RequestBody: requestBody, - Reason: fmt.Sprintf("doesn't match schema %s", schemaId), + Reason: fmt.Sprintf("doesn't match schema %s", schemaID), Err: err, } } diff --git a/internal/platform/validator/validate_response.go b/internal/platform/validator/validate_response.go index 06174b7..0c87a38 100644 --- a/internal/platform/validator/validate_response.go +++ b/internal/platform/validator/validate_response.go @@ -144,11 +144,10 @@ func ValidateResponse(ctx context.Context, input *openapi3filter.ResponseValidat // Validate data with the schema. if err := contentType.Schema.Value.VisitJSON(value, append(opts, openapi3.VisitAsResponse())...); err != nil { - schemaId := getSchemaIdentifier(contentType.Schema) - schemaId = prependSpaceIfNeeded(schemaId) + schemaID := prependSpaceIfNeeded(getSchemaIdentifier(contentType.Schema)) return &openapi3filter.ResponseError{ Input: input, - Reason: fmt.Sprintf("response body doesn't match schema%s", schemaId), + Reason: fmt.Sprintf("response body doesn't match schema%s", schemaID), Err: err, } } diff --git a/internal/platform/web/adaptor.go b/internal/platform/web/adaptor.go new file mode 100644 index 0000000..3eb5188 --- /dev/null +++ b/internal/platform/web/adaptor.go @@ -0,0 +1,99 @@ +package web + +import ( + "io" + "net/http" + + "github.com/valyala/fasthttp" + "github.com/valyala/fasthttp/fasthttpadaptor" +) + +// NewFastHTTPHandler wraps net/http handler to fasthttp request handler, +// so it can be passed to fasthttp server. +// +// While this function may be used for easy switching from net/http to fasthttp, +// it has the following drawbacks comparing to using manually written fasthttp +// request handler: +// +// - A lot of useful functionality provided by fasthttp is missing +// from net/http handler. +// - net/http -> fasthttp handler conversion has some overhead, +// so the returned handler will be always slower than manually written +// fasthttp handler. +// +// So it is advisable using this function only for quick net/http -> fasthttp +// switching. Then manually convert net/http handlers to fasthttp handlers +// according to https://github.com/valyala/fasthttp#switching-from-nethttp-to-fasthttp . +func NewFastHTTPHandler(h http.Handler, isPlayground bool) Handler { + return func(ctx *fasthttp.RequestCtx) error { + var r http.Request + if err := fasthttpadaptor.ConvertRequest(ctx, &r, true); err != nil { + ctx.Logger().Printf("cannot parse requestURI %q: %v", r.RequestURI, err) + ctx.Error("Internal Server Error", fasthttp.StatusInternalServerError) + return err + } + + w := netHTTPResponseWriter{w: ctx.Response.BodyWriter()} + h.ServeHTTP(&w, r.WithContext(ctx)) + + ctx.SetStatusCode(w.StatusCode()) + haveContentType := false + for k, vv := range w.Header() { + if k == fasthttp.HeaderContentType { + haveContentType = true + } + + for _, v := range vv { + ctx.Response.Header.Add(k, v) + } + } + if !haveContentType { + // From net/http.ResponseWriter.Write: + // If the Header does not contain a Content-Type line, Write adds a Content-Type set + // to the result of passing the initial 512 bytes of written data to DetectContentType. + l := 512 + b := ctx.Response.Body() + if len(b) < 512 { + l = len(b) + } + ctx.Response.Header.Set(fasthttp.HeaderContentType, http.DetectContentType(b[:l])) + } + + // mark requests to the playground + if isPlayground { + ctx.SetUserValue(Playground, struct{}{}) + } + + return nil + } +} + +type netHTTPResponseWriter struct { + statusCode int + h http.Header + w io.Writer +} + +func (w *netHTTPResponseWriter) StatusCode() int { + if w.statusCode == 0 { + return http.StatusOK + } + return w.statusCode +} + +func (w *netHTTPResponseWriter) Header() http.Header { + if w.h == nil { + w.h = make(http.Header) + } + return w.h +} + +func (w *netHTTPResponseWriter) WriteHeader(statusCode int) { + w.statusCode = statusCode +} + +func (w *netHTTPResponseWriter) Write(p []byte) (int, error) { + return w.w.Write(p) +} + +func (w *netHTTPResponseWriter) Flush() {} diff --git a/internal/platform/web/apps.go b/internal/platform/web/apps.go index bdeae72..8bbb267 100644 --- a/internal/platform/web/apps.go +++ b/internal/platform/web/apps.go @@ -44,7 +44,7 @@ func (a *Apps) SetDefaultBehavior(schemaID int, handler Handler, mw ...Middlewar } - //Set NOT FOUND behavior + // Set NOT FOUND behavior a.Routers[schemaID].NotFound = customHandler // Set Method Not Allowed behavior @@ -56,7 +56,7 @@ func NewApps(lock *sync.RWMutex, passOPTIONS bool, storedSpecs database.DBOpenAP schemaIDs := storedSpecs.SchemaIDs() - // init routers + // Init routers routers := make(map[int]*router.Router) for _, schemaID := range schemaIDs { routers[schemaID] = router.New() @@ -101,19 +101,19 @@ func (a *Apps) Handle(schemaID int, method string, path string, handler Handler, func getWallarmSchemaID(ctx *fasthttp.RequestCtx, storedSpecs database.DBOpenAPILoader) (int, error) { - // get Wallarm Schema ID + // Get Wallarm Schema ID xWallarmSchemaID := string(ctx.Request.Header.Peek(XWallarmSchemaIDHeader)) if xWallarmSchemaID == "" { return 0, errors.New("required X-WALLARM-SCHEMA-ID header is missing") } - // get schema version + // Get schema version schemaID, err := strconv2.Atoi(xWallarmSchemaID) if err != nil { return 0, fmt.Errorf("error parsing value: %v", err) } - // check if schema ID is loaded + // Check if schema ID is loaded if !storedSpecs.IsLoaded(schemaID) { return 0, fmt.Errorf("provided via X-WALLARM-SCHEMA-ID header schema ID %d not found", schemaID) } @@ -143,10 +143,10 @@ func (a *Apps) APIModeHandler(ctx *fasthttp.RequestCtx) { return } - // add internal header to the context + // Add internal header to the context ctx.SetUserValue(WallarmSchemaID, schemaID) - // delete internal header + // Delete internal header ctx.Request.Header.Del(XWallarmSchemaIDHeader) a.lock.RLock() @@ -154,7 +154,7 @@ func (a *Apps) APIModeHandler(ctx *fasthttp.RequestCtx) { a.Routers[schemaID].Handler(ctx) - // if pass request with OPTIONS method is enabled then log request + // If pass request with OPTIONS method is enabled then log request if ctx.Response.StatusCode() == fasthttp.StatusOK && a.passOPTIONS && strconv.B2S(ctx.Method()) == fasthttp.MethodOptions { a.Log.WithFields(logrus.Fields{ "request_id": fmt.Sprintf("#%016X", ctx.ID()), diff --git a/internal/platform/web/response.go b/internal/platform/web/response.go index a4fd710..150bfd1 100644 --- a/internal/platform/web/response.go +++ b/internal/platform/web/response.go @@ -4,13 +4,14 @@ import ( "bytes" "encoding/json" "errors" - "golang.org/x/exp/slices" "io" "net/http" "github.com/klauspost/compress/flate" "github.com/klauspost/compress/zlib" "github.com/valyala/fasthttp" + "github.com/wundergraph/graphql-go-tools/pkg/graphql" + "golang.org/x/exp/slices" ) // List of the supported compression schemes @@ -115,17 +116,16 @@ func RespondError(ctx *fasthttp.RequestCtx, statusCode int, statusHeader string) return nil } -// RespondOk sends an empty response with 200 status OK back to the client. -func RespondOk(ctx *fasthttp.RequestCtx) error { +// RespondGraphQLErrors sends errors back to the client via GraphQL +func RespondGraphQLErrors(ctx *fasthttp.Response, errors error) error { - ctx.Error("", fasthttp.StatusOK) + gqlErrors := graphql.RequestErrorsFromError(errors) - return nil -} + ctx.Header.Set("Content-Type", "application/json") -// Redirect302 redirects client with code 302 -func Redirect302(ctx *fasthttp.RequestCtx, redirectUrl string) error { + if _, err := gqlErrors.WriteResponse(ctx.BodyWriter()); err != nil { + return err + } - ctx.Redirect(redirectUrl, 302) return nil } diff --git a/internal/platform/web/trace.go b/internal/platform/web/trace.go index 1d80e02..0352460 100644 --- a/internal/platform/web/trace.go +++ b/internal/platform/web/trace.go @@ -7,33 +7,48 @@ import ( "github.com/valyala/fasthttp" ) +const responseBodyOmitted = "" + func LogRequestResponseAtTraceLevel(ctx *fasthttp.RequestCtx, logger *logrus.Logger) { - if logger.Level == logrus.TraceLevel { - requestHeaders := "" - ctx.Request.Header.VisitAll(func(key, value []byte) { - requestHeaders += string(key) + ":" + string(value) + "\n" - }) - - logger.WithFields(logrus.Fields{ - "request_id": fmt.Sprintf("#%016X", ctx.ID()), - "method": string(ctx.Request.Header.Method()), - "uri": string(ctx.Request.URI().RequestURI()), - "headers": requestHeaders, - "body": string(ctx.Request.Body()), - "client_address": ctx.RemoteAddr(), - }).Trace("new request") - - responseHeaders := "" - ctx.Response.Header.VisitAll(func(key, value []byte) { - responseHeaders += string(key) + ":" + string(value) + "\n" - }) - - logger.WithFields(logrus.Fields{ - "request_id": fmt.Sprintf("#%016X", ctx.ID()), - "status_code": ctx.Response.StatusCode(), - "headers": responseHeaders, - "body": string(ctx.Response.Body()), - "client_address": ctx.RemoteAddr(), - }).Trace("response from the API-Firewall") + + if logger.Level < logrus.TraceLevel { + return + } + + requestHeaders := "" + ctx.Request.Header.VisitAll(func(key, value []byte) { + requestHeaders += string(key) + ":" + string(value) + "\n" + }) + + logger.WithFields(logrus.Fields{ + "request_id": fmt.Sprintf("#%016X", ctx.ID()), + "method": string(ctx.Request.Header.Method()), + "uri": string(ctx.Request.URI().RequestURI()), + "headers": requestHeaders, + "body": string(ctx.Request.Body()), + "client_address": ctx.RemoteAddr(), + }).Trace("new request") + + responseHeaders := "" + ctx.Response.Header.VisitAll(func(key, value []byte) { + responseHeaders += string(key) + ":" + string(value) + "\n" + }) + + isPlayground := false + if ctx.UserValue(Playground) != nil { + isPlayground = true } + + body := responseBodyOmitted + if !isPlayground { + body = string(ctx.Response.Body()) + } + + logger.WithFields(logrus.Fields{ + "request_id": fmt.Sprintf("#%016X", ctx.ID()), + "status_code": ctx.Response.StatusCode(), + "headers": responseHeaders, + "body": body, + "client_address": ctx.RemoteAddr(), + }).Trace("response from the API-Firewall") } diff --git a/internal/platform/web/web.go b/internal/platform/web/web.go index 856c090..63d9712 100644 --- a/internal/platform/web/web.go +++ b/internal/platform/web/web.go @@ -3,25 +3,27 @@ package web import ( "bytes" "fmt" - "github.com/savsgio/gotils/strconv" "os" + "strings" "syscall" "github.com/fasthttp/router" + "github.com/savsgio/gotils/strconv" "github.com/sirupsen/logrus" "github.com/valyala/fasthttp" - "github.com/wallarm/api-firewall/internal/config" ) const ( + Playground = "playground" + ValidationStatus = "APIFW-Validation-Status" XWallarmSchemaIDHeader = "X-WALLARM-SCHEMA-ID" WallarmSchemaID = "WallarmSchemaID" - ValidationDisable = "DISABLE" - ValidationBlock = "BLOCK" - ValidationLog = "LOG_ONLY" + ValidationDisable = "disable" + ValidationBlock = "block" + ValidationLog = "log_only" RequestProxyNoRoute = "proxy_no_route" RequestProxyFailed = "proxy_failed" @@ -29,8 +31,11 @@ const ( ResponseBlocked = "response_blocked" ResponseStatusNotFound = "response_status_not_found" - APIMode = "api" - ProxyMode = "proxy" + APIMode = "api" + ProxyMode = "proxy" + GraphQLMode = "graphql" + + AnyMethod = "any" ) // A Handler is a type that handles an http request within our own little mini @@ -44,8 +49,16 @@ type App struct { Router *router.Router Log *logrus.Logger shutdown chan os.Signal - cfg *config.APIFWConfiguration mw []Middleware + Options *AppAdditionalOptions +} + +type AppAdditionalOptions struct { + Mode string + PassOptions bool + RequestValidation string + ResponseValidation string + CustomBlockStatusCode int } func (a *App) SetDefaultBehavior(handler Handler, mw ...Middleware) { @@ -58,15 +71,17 @@ func (a *App) SetDefaultBehavior(handler Handler, mw ...Middleware) { customHandler := func(ctx *fasthttp.RequestCtx) { // Block request if it's not found in the route. Not for API mode. - if a.cfg.Mode == ProxyMode { - if a.cfg.RequestValidation == ValidationBlock || a.cfg.ResponseValidation == ValidationBlock { + if strings.EqualFold(a.Options.Mode, ProxyMode) { + if strings.EqualFold(a.Options.RequestValidation, ValidationBlock) || strings.EqualFold(a.Options.ResponseValidation, ValidationBlock) { a.Log.WithFields(logrus.Fields{ "request_id": fmt.Sprintf("#%016X", ctx.ID()), "method": bytes.NewBuffer(ctx.Request.Header.Method()).String(), "path": string(ctx.Path()), "client_address": ctx.RemoteAddr(), }).Info("request blocked") - ctx.Error("", a.cfg.CustomBlockStatusCode) + + ctx.Error("", a.Options.CustomBlockStatusCode) + return } } @@ -86,16 +101,17 @@ func (a *App) SetDefaultBehavior(handler Handler, mw ...Middleware) { } // NewApp creates an App value that handle a set of routes for the application. -func NewApp(shutdown chan os.Signal, cfg *config.APIFWConfiguration, logger *logrus.Logger, mw ...Middleware) *App { +func NewApp(options *AppAdditionalOptions, shutdown chan os.Signal, logger *logrus.Logger, mw ...Middleware) *App { + app := App{ Router: router.New(), shutdown: shutdown, mw: mw, Log: logger, - cfg: cfg, + Options: options, } - app.Router.HandleOPTIONS = cfg.PassOptionsRequests + app.Router.HandleOPTIONS = options.PassOptions return &app } @@ -118,14 +134,19 @@ func (a *App) Handle(method string, path string, handler Handler, mw ...Middlewa return } - // if pass request with OPTIONS method is enabled then log request - if ctx.Response.StatusCode() == fasthttp.StatusOK && a.cfg.PassOptionsRequests && strconv.B2S(ctx.Method()) == fasthttp.MethodOptions { + // if pass request with OPTIONS method is enabled then log reques + if ctx.Response.StatusCode() == fasthttp.StatusOK && a.Options.PassOptions && strconv.B2S(ctx.Method()) == fasthttp.MethodOptions { a.Log.WithFields(logrus.Fields{ "request_id": fmt.Sprintf("#%016X", ctx.ID()), }).Debug("pass request with OPTIONS method") } } + if method == AnyMethod { + a.Router.ANY(path, h) + return + } + // Add this handler for the specified verb and route. a.Router.Handle(method, path, h) } diff --git a/mkdocs.yml b/mkdocs.yml index 6c5d6db..09ce528 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -22,6 +22,7 @@ theme: - navigation.tracking - content.code.copy - content.action.edit + - navigation.sections logo: images/wallarm-logo.svg favicon: images/favicon.png font: @@ -76,9 +77,21 @@ markdown_extensions: - meta nav: - - API Firewall overview: index.md - - Running API Firewall on Docker: installation-guides/docker-container.md + - Overview: index.md + - Changelog: release-notes.md + - REST API: + - Running API Firewall: installation-guides/docker-container.md + - Validating Individual Requests Without Proxying: installation-guides/api-mode.md + - GraphQL API: + - Running API Firewall: installation-guides/graphql/docker-container.md + - GraphQL Limits Compliance: installation-guides/graphql/limit-compliance.md + - WebSocket Origin Validation: installation-guides/graphql/websocket-origin-check.md + - GraphQL Playground: installation-guides/graphql/playground.md + - Additional Configuration: + - Validating Request Authentication Tokens: configuration-guides/validate-tokens.md + - Blocking Requests with Compromised Tokens: configuration-guides/denylist-leaked-tokens.md + - SSL/TLS Configuration: configuration-guides/ssl-tls.md + - System Settings: configuration-guides/system-settings.md - Demos: - - Running the example application and API Firewall with Docker Compose: demos/docker-compose.md - - Wallarm API Firewall demo with Kubernetes: demos/kubernetes-cluster.md - - API Firewall changelog: release-notes.md + - Docker Compose: demos/docker-compose.md + - Kubernetes: demos/kubernetes-cluster.md diff --git a/resources/test/tokens/test.db b/resources/test/tokens/test.db index d66ebad..34bbcaa 100644 --- a/resources/test/tokens/test.db +++ b/resources/test/tokens/test.db @@ -5,6 +5,4 @@ eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJzb21lIjoicGF5bG9hZDk5OTk5ODUifQ.S9P-DEiW eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJzb21lIjoicGF5bG9hZDk5OTk5ODYifQ.HdINfOmk59NdNYBnMjrqUdD4gEikAUafKjAhBI1_Ue8 eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJzb21lIjoicGF5bG9hZDk5OTk5ODcifQ.MDPMmuAquxi55sGTajKQjcFzoaNzFZJFMkDg3fIyhx0 eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJzb21lIjoicGF5bG9hZDk5OTk5ODgifQ.-HfLUDIIHawNbZJkAbml_Um8vlQw7UMeiYmzdRbbwHs -eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJzb21lIjoicGF5bG9hZDk5OTk5ODkifQ.zyFgVDFYCKyp10GKbC8HCUpeT0rRajqG192gb-s7L8U -eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJzb21lIjoicGF5bG9hZDk5OTk5OTAifQ.6b3J4xCO6U88k0ZmLPUeDpopsg6krl6Q7UyuLbcH-l8 -eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJzb21lIjoicGF5bG9hZDk5OTk5OTEifQ.NdeEQYz4vE56uJrniAHCDO2NeMJmlrUH5F_a5bQJOo4 \ No newline at end of file +eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJzb21lIjoicGF5bG9hZDk5OTk5ODkifQ.zyFgVDFYCKyp10GKbC8HCUpeT0rRajqG192gb-s7L8U \ No newline at end of file