Compare commits

...

6 Commits

10 changed files with 156 additions and 25 deletions
+1 -1
View File
@@ -11,7 +11,7 @@ help: ## display this help
.PHONY: run
run: build ## run application
@LOG_LEVEL=debug BLOCK_SCHEMA_INTROSPECTION=false JWT_ROLE_RATE_LIMIT=false JWT_ROLE_CLAIM_PATH="Hasura.x-hasura-default-role" JWT_USER_CLAIM_PATH="Hasura.x-hasura-user-id" HOST_GRAPHQL=https://hasura8.lan/ ./graphql-proxy
@LOG_LEVEL=debug BLOCK_SCHEMA_INTROSPECTION=false CACHE_TTL=10 JWT_ROLE_RATE_LIMIT=false JWT_ROLE_CLAIM_PATH="Hasura.x-hasura-default-role" JWT_USER_CLAIM_PATH="Hasura.x-hasura-user-id" HOST_GRAPHQL=https://hasura8.lan/ ./graphql-proxy
.PHONY: build
build: ## build the binary
+12 -2
View File
@@ -42,12 +42,13 @@ I wanted to monitor the queries and responses of our graphql endpoint. Still, we
| `HOST_GRAPHQL` | The host to proxy the graphql endpoint | `http://localhost/` |
| `JWT_USER_CLAIM_PATH` | Path to the user claim in the JWT token | `` |
| `JWT_ROLE_CLAIM_PATH` | Path to the role claim in the JWT token | `` |
| `JWT_ROLE_FROM_HEADER` | Header name to extract the role from | `` |
| `ROLE_FROM_HEADER` | Header name to extract the role from | `` |
| `ROLE_RATE_LIMIT` | Enable request rate limiting based on role| `false` |
| `ENABLE_GLOBAL_CACHE` | Enable the cache | `false` |
| `CACHE_TTL` | The cache TTL | `60` |
| `LOG_LEVEL` | The log level | `info` |
| `BLOCK_SCHEMA_INTROSPECTION`| Blocks the schema introspection | `false` |
| `ALLOWED_INTROSPECTION` | Allow only certain queries in introspection | `` |
| `ENABLE_ACCESS_LOG` | Enable the access log | `false` |
| `READ_ONLY_MODE` | Enable the read only mode | `false` |
| `ALLOWED_URLS` | Allow access only to certain URLs | `/v1/graphql,/v1/version` |
@@ -107,6 +108,15 @@ You can enable the read-only mode by setting the `READ_ONLY_MODE` environment va
You can allow access only to certain URLs by setting the `ALLOWED_URLS` environment variable to a comma-separated list of URLs. If enabled - other URLs will return `403 Forbidden` error and request will **not** reach the proxied service.
### Blocking introspection
You can block the schema introspection by setting the `BLOCK_SCHEMA_INTROSPECTION` environment variable to `true` - which will block all the queries with introspection parts, like:
`__schema`, `__type`, `__typename`, `__directive`, `__directivelocation`, `__field`, `__inputvalue`, `__enumvalue`, `__typekind`, `__fieldtype`, `__inputobjecttype`, `__enumtype`, `__uniontype`, `__scalars`, `__objects`, `__interfaces`, `__unions`, `__enums`, `__inputobjects`, `__directives`
If you'd like to keep blocking of the schema introspection on but allow one or more of from the list of above for any reason, you can use the `ALLOWED_INTROSPECTION` environment variable to specify the list of allowed queries.
`ALLOWED_INTROSPECTION="__typename,__type"`
### Monitoring endpoint
@@ -125,4 +135,4 @@ graphql_proxy_executed_query{user_id="-",op_type="query",op_name="checkIfSpamAIR
graphql_proxy_requests_failed 324
graphql_proxy_requests_skipped 0
graphql_proxy_requests_succesful 454823
```
```
+5 -12
View File
@@ -4,9 +4,9 @@ import (
"fmt"
"time"
"github.com/akyoto/cache"
fiber "github.com/gofiber/fiber/v2"
"github.com/gookit/goutil/strutil"
libpack_cache "github.com/lukaszraczylo/graphql-monitoring-proxy/cache"
)
func calculateHash(c *fiber.Ctx) string {
@@ -14,20 +14,13 @@ func calculateHash(c *fiber.Ctx) string {
}
func enableCache() {
var err error
cfg.Cache.CacheClient = cache.New(time.Duration(cfg.Cache.CacheTTL) * time.Second * 2)
if err != nil {
cfg.Logger.Critical("Can't create cache client", map[string]interface{}{"error": err.Error()})
panic(err)
}
cfg.Cache.CacheClient = libpack_cache.New(time.Duration(cfg.Cache.CacheTTL) * time.Second * 100)
}
func cacheLookup(hash string) []byte {
if cfg.Cache.CacheClient != nil {
obj, found := cfg.Cache.CacheClient.Get(hash)
if found {
return obj.([]byte)
}
obj, found := cfg.Cache.CacheClient.Get(hash)
if found {
return obj
}
return nil
}
+112
View File
@@ -0,0 +1,112 @@
package libpack_cache
import (
"sync"
"time"
)
type CacheEntry struct {
Value []byte
ExpiresAt time.Time
}
type Cache struct {
sync.RWMutex
entries sync.Map
globalTTL time.Duration
bytePool sync.Pool
}
func New(globalTTL time.Duration) *Cache {
cache := &Cache{
globalTTL: globalTTL,
}
// Initialize the byte pool.
cache.bytePool.New = func() interface{} {
return make([]byte, 0)
}
// Start the cache cleanup.
go cache.cleanupRoutine(globalTTL)
return cache
}
func (c *Cache) cleanupRoutine(globalTTL time.Duration) {
ticker := time.NewTicker(globalTTL / 2)
defer ticker.Stop()
for range ticker.C {
c.CleanExpiredEntries()
}
}
func (c *Cache) Set(key string, value []byte, ttl time.Duration) {
c.Lock()
defer c.Unlock()
expiresAt := time.Now().Add(ttl)
// Get a byte slice from the pool and ensure it's properly sized.
b := c.bytePool.Get().([]byte)
if cap(b) < len(value) {
b = make([]byte, len(value))
} else {
b = b[:len(value)]
}
copy(b, value)
entry := CacheEntry{
Value: b,
ExpiresAt: expiresAt,
}
c.entries.Store(key, entry)
}
func (c *Cache) Get(key string) ([]byte, bool) {
c.RLock()
defer c.RUnlock()
entry, ok := c.entries.Load(key)
if !ok || entry.(CacheEntry).ExpiresAt.Before(time.Now()) {
return nil, false
}
// Copy the value from the byte slice.
value := make([]byte, len(entry.(CacheEntry).Value))
copy(value, entry.(CacheEntry).Value)
return value, true
}
func (c *Cache) Delete(key string) {
c.Lock()
defer c.Unlock()
entry, ok := c.entries.Load(key)
if !ok {
return
}
// Return the byte slice to the pool.
c.bytePool.Put(entry.(CacheEntry).Value)
// Delete the entry from the cache.
c.entries.Delete(key)
}
func (c *Cache) CleanExpiredEntries() {
now := time.Now()
c.entries.Range(func(key, value interface{}) bool {
entry := value.(CacheEntry)
if entry.ExpiresAt.Before(now) {
// Return the byte slice to the pool.
c.bytePool.Put(entry.Value)
// Delete the entry from the cache.
c.entries.Delete(key)
}
// Return true to continue iterating over the map.
return true
})
}
+2 -2
View File
@@ -27,7 +27,7 @@ func (suite *Tests) Test_cacheLookup() {
{
name: "test_existent",
args: args{
hash: "00000000000000000000000000000000000001",
hash: "00000000000000000000000000000000001337",
},
want: []byte("it's fine."),
addCache: struct {
@@ -40,7 +40,7 @@ func (suite *Tests) Test_cacheLookup() {
for _, tt := range tests {
suite.T().Run(tt.name, func(t *testing.T) {
if tt.addCache.data != nil {
cfg.Cache.CacheClient.Set(tt.args.hash, tt.addCache.data, time.Duration(1)*time.Second)
cfg.Cache.CacheClient.Set(tt.args.hash, tt.addCache.data, time.Duration(90*time.Second))
}
got := cacheLookup(tt.args.hash)
assert.Equal(tt.want, got, "Unexpected cache lookup result")
+2 -2
View File
@@ -4,10 +4,9 @@ go 1.21
require (
github.com/VictoriaMetrics/metrics v1.24.0
github.com/akyoto/cache v1.0.6
github.com/buger/jsonparser v1.1.1
github.com/gofiber/fiber/v2 v2.49.2
github.com/gookit/goutil v0.6.12
github.com/gookit/goutil v0.6.13
github.com/graphql-go/graphql v0.8.1
github.com/json-iterator/go v1.1.12
github.com/lukaszraczylo/ask v0.0.0-20230927103145-2ff1123b4415
@@ -19,6 +18,7 @@ require (
)
require (
github.com/akyoto/cache v1.0.6 // indirect
github.com/andybalholm/brotli v1.0.5 // indirect
github.com/avast/retry-go/v4 v4.5.0 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
+2 -2
View File
@@ -21,8 +21,8 @@ github.com/google/uuid v1.3.1 h1:KjJaJ9iWZ3jOFZIf1Lqf4laDRCasjl0BCmnEGxkdLb4=
github.com/google/uuid v1.3.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gookit/color v1.5.4 h1:FZmqs7XOyGgCAxmWyPslpiok1k05wmY3SJTytgvYFs0=
github.com/gookit/color v1.5.4/go.mod h1:pZJOeOS8DM43rXbp4AZo1n9zCU2qjpcRko0b6/QJi9w=
github.com/gookit/goutil v0.6.12 h1:73vPUcTtVGXbhSzBOFcnSB1aJl7Jq9np3RAE50yIDZc=
github.com/gookit/goutil v0.6.12/go.mod h1:g6krlFib8xSe3G1h02IETowOtrUGpAmetT8IevDpvpM=
github.com/gookit/goutil v0.6.13 h1:ttg7yMda6Q9fkE4P+YTwozd2wH1Le0CQldTAtOFBr7o=
github.com/gookit/goutil v0.6.13/go.mod h1:YyDBddefmjS+mU2PDPgCcjVzTDM5WgExiDv5ZA/b8I8=
github.com/graphql-go/graphql v0.8.1 h1:p7/Ou/WpmulocJeEx7wjQy611rtXGQaAcXGqanuMMgc=
github.com/graphql-go/graphql v0.8.1/go.mod h1:nKiHzRM0qopJEwCITUuIsxk9PlVlwIiiI8pnJEhordQ=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
+9 -1
View File
@@ -96,7 +96,15 @@ func parseGraphQLQuery(c *fiber.Ctx) (operationType, operationName string, cache
if cfg.Security.BlockIntrospection {
for _, s := range oper.SelectionSet.Selections {
for _, s2 := range s.GetSelectionSet().Selections {
if _, exists := retrospectionQuerySet[s2.(*ast.Field).Name.Value]; exists {
if _, exists := retrospectionQuerySet[strings.ToLower(s2.(*ast.Field).Name.Value)]; exists {
if len(cfg.Security.IntrospectionAllowed) > 0 {
for _, introspectionQueryAllowed := range cfg.Security.IntrospectionAllowed {
if strings.EqualFold(strings.ToLower(introspectionQueryAllowed), strings.ToLower(s2.(*ast.Field).Name.Value)) {
cfg.Logger.Debug("Introspection query allowed, passing through", m)
return
}
}
}
cfg.Logger.Warning("Introspection query blocked", m)
cfg.Monitoring.Increment(libpack_monitoring.MetricsSkipped, nil)
c.Status(403).SendString("Introspection queries are not allowed")
+7
View File
@@ -30,6 +30,13 @@ func parseConfig() {
c.Cache.CacheEnable = envutil.GetBool("ENABLE_GLOBAL_CACHE", false)
c.Cache.CacheTTL = envutil.GetInt("CACHE_TTL", 60)
c.Security.BlockIntrospection = envutil.GetBool("BLOCK_SCHEMA_INTROSPECTION", false)
c.Security.IntrospectionAllowed = func() []string {
urls := envutil.Getenv("ALLOWED_INTROSPECTION", "")
if urls == "" {
return nil
}
return strings.Split(urls, ",")
}()
c.Logger = libpack_logging.NewLogger()
c.Client.GQLClient = graphql.NewConnection()
c.Client.GQLClient.SetEndpoint(c.Server.HostGraphQL)
+4 -3
View File
@@ -1,8 +1,8 @@
package main
import (
"github.com/akyoto/cache"
graphql "github.com/lukaszraczylo/go-simple-graphql"
libpack_cache "github.com/lukaszraczylo/graphql-monitoring-proxy/cache"
libpack_logging "github.com/lukaszraczylo/graphql-monitoring-proxy/logging"
libpack_monitoring "github.com/lukaszraczylo/graphql-monitoring-proxy/monitoring"
"github.com/valyala/fasthttp"
@@ -36,10 +36,11 @@ type config struct {
Cache struct {
CacheEnable bool
CacheTTL int
CacheClient *cache.Cache
CacheClient *libpack_cache.Cache
}
Security struct {
BlockIntrospection bool
BlockIntrospection bool
IntrospectionAllowed []string
}
}