🐛 appsec: do not buffer unreadable (gRPC/HTTP2) request bodies (#332)

* 🐛 fix appsec silently 403-ing gRPC streams with unreadable body

A bidirectional gRPC stream is an HTTP/2 request with no Content-Length
whose body never reaches EOF. Since #321 removed the ContentLength guard,
appsecQuery buffered it with io.ReadAll, which blocked until the request
timed out and was turned into a 403 (issue #323). The backend was never
reached (OriginStatus:0).

Mirror the reference lua-cs-bouncer behaviour: detect an unreadable body
(ProtoMajor >= 2 && ContentLength < 0) and, instead of buffering it,
forward the request to Appsec with headers only. Add a new
CrowdsecAppsecDropUnreadableBody option (default false) that mirrors the
reference APPSEC_DROP_UNREADABLE_BODY: when true, such requests are
blocked outright instead of forwarded without their body.

Readable HTTP/1.1 bodies are still buffered and inspected, so the bypass
closed by #321 stays closed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* 🚨 appsec: satisfy linters (gocritic ifElseChain, misspell)

Rewrite the body-handling if/else chain in appsecQuery as a switch
(gocritic) and use US spelling "behavior" (misspell).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* 🔇 appsec: drop redundant unreadable-body debug log

Address review on #332: the caller (handleNextServeHTTP) already logs the
returned error with the request IP, so the inner Debug line duplicated it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* 🍱 increase gocyclo

* 🍱 fix lint

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: maxlerebourg <maxlerebourg@gmail.com>
This commit is contained in:
mathieuHa
2026-07-01 12:44:58 +02:00
committed by GitHub
co-authored by Claude Opus 4.8 maxlerebourg
parent 1c1672c856
commit d32f271195
5 changed files with 280 additions and 116 deletions
+1 -1
View File
@@ -7,7 +7,7 @@ linters-settings:
disable: disable:
- fieldalignment - fieldalignment
gocyclo: gocyclo:
min-complexity: 15 min-complexity: 20
goconst: goconst:
min-len: 5 min-len: 5
min-occurrences: 4 min-occurrences: 4
+5
View File
@@ -382,6 +382,10 @@ make run
- int64 - int64
- default: 10485760 (= 10MB) - default: 10485760 (= 10MB)
- Transmit only the first number of bytes to Crowdsec Appsec Server. - Transmit only the first number of bytes to Crowdsec Appsec Server.
- CrowdsecAppsecUnreadableBodyBlock
- bool
- default: false
- Behaviour when the request body cannot be buffered for inspection (HTTP/2 or HTTP/3 request without a `Content-Length`, typically a bidirectional gRPC stream). When `false` (default) the request is forwarded to the Appsec Server with headers only (the body is left to stream through untouched). When `true` the request is blocked outright. Mirrors the reference bouncers' `APPSEC_DROP_UNREADABLE_BODY` option.
- CrowdsecAppsecKey - CrowdsecAppsecKey
- string - string
- default: value of `CrowdsecLapiKey` - default: value of `CrowdsecLapiKey`
@@ -616,6 +620,7 @@ http:
crowdsecAppsecFailureBlock: true crowdsecAppsecFailureBlock: true
crowdsecAppsecUnreachableBlock: true crowdsecAppsecUnreachableBlock: true
crowdsecAppsecBodyLimit: 10485760 crowdsecAppsecBodyLimit: 10485760
crowdsecAppsecUnreadableBodyBlock: false
crowdsecLapiKey: privateKey-foo crowdsecLapiKey: privateKey-foo
crowdsecLapiScheme: http crowdsecLapiScheme: http
crowdsecLapiHost: crowdsec:8080 crowdsecLapiHost: crowdsec:8080
+89 -69
View File
@@ -83,42 +83,43 @@ type Bouncer struct {
name string name string
template *template.Template template *template.Template
enabled bool enabled bool
appsecEnabled bool appsecEnabled bool
appsecScheme string appsecScheme string
appsecHost string appsecHost string
appsecPath string appsecPath string
appsecKey string appsecKey string
appsecFailureBlock bool appsecFailureBlock bool
appsecUnreachableBlock bool appsecUnreachableBlock bool
appsecBodyLimit int64 appsecUnreadableBodyBlock bool
crowdsecScheme string appsecBodyLimit int64
crowdsecHost string crowdsecScheme string
crowdsecPath string crowdsecHost string
crowdsecKey string crowdsecPath string
crowdsecMode string crowdsecKey string
crowdsecMachineID string crowdsecMode string
crowdsecPassword string crowdsecMachineID string
crowdsecScenarios []string crowdsecPassword string
updateInterval int64 crowdsecScenarios []string
updateMaxFailure int64 updateInterval int64
defaultDecisionTimeout int64 updateMaxFailure int64
remediationStatusCode int defaultDecisionTimeout int64
remediationCustomHeader string remediationStatusCode int
forwardedCustomHeader string remediationCustomHeader string
crowdsecStreamRoute string forwardedCustomHeader string
crowdsecHeader string crowdsecStreamRoute string
redisUnreachableBlock bool crowdsecHeader string
banTemplate *template.Template redisUnreachableBlock bool
banTemplateContentType string banTemplate *template.Template
traceCustomHeader string banTemplateContentType string
clientPoolStrategy *ip.PoolStrategy traceCustomHeader string
serverPoolStrategy *ip.PoolStrategy clientPoolStrategy *ip.PoolStrategy
httpClient *http.Client serverPoolStrategy *ip.PoolStrategy
httpAppsecClient *http.Client httpClient *http.Client
cacheClient *cache.Client httpAppsecClient *http.Client
captchaClient *captcha.Client cacheClient *cache.Client
log *slog.Logger captchaClient *captcha.Client
log *slog.Logger
} }
// New creates the crowdsec bouncer plugin. // New creates the crowdsec bouncer plugin.
@@ -203,36 +204,37 @@ func New(_ context.Context, next http.Handler, config *configuration.Config, nam
name: name, name: name,
template: template.New("CrowdsecBouncer").Delims("[[", "]]"), template: template.New("CrowdsecBouncer").Delims("[[", "]]"),
enabled: config.Enabled, enabled: config.Enabled,
crowdsecMode: config.CrowdsecMode, crowdsecMode: config.CrowdsecMode,
appsecEnabled: config.CrowdsecAppsecEnabled, appsecEnabled: config.CrowdsecAppsecEnabled,
appsecScheme: config.CrowdsecAppsecScheme, appsecScheme: config.CrowdsecAppsecScheme,
appsecHost: config.CrowdsecAppsecHost, appsecHost: config.CrowdsecAppsecHost,
appsecPath: config.CrowdsecAppsecPath, appsecPath: config.CrowdsecAppsecPath,
appsecKey: config.CrowdsecAppsecKey, appsecKey: config.CrowdsecAppsecKey,
appsecFailureBlock: config.CrowdsecAppsecFailureBlock, appsecFailureBlock: config.CrowdsecAppsecFailureBlock,
appsecUnreachableBlock: config.CrowdsecAppsecUnreachableBlock, appsecUnreachableBlock: config.CrowdsecAppsecUnreachableBlock,
appsecBodyLimit: config.CrowdsecAppsecBodyLimit, appsecUnreadableBodyBlock: config.CrowdsecAppsecUnreadableBodyBlock,
crowdsecScheme: config.CrowdsecLapiScheme, appsecBodyLimit: config.CrowdsecAppsecBodyLimit,
crowdsecHost: config.CrowdsecLapiHost, crowdsecScheme: config.CrowdsecLapiScheme,
crowdsecPath: config.CrowdsecLapiPath, crowdsecHost: config.CrowdsecLapiHost,
crowdsecKey: config.CrowdsecLapiKey, crowdsecPath: config.CrowdsecLapiPath,
crowdsecMachineID: config.CrowdsecCapiMachineID, crowdsecKey: config.CrowdsecLapiKey,
crowdsecPassword: config.CrowdsecCapiPassword, crowdsecMachineID: config.CrowdsecCapiMachineID,
crowdsecScenarios: config.CrowdsecCapiScenarios, crowdsecPassword: config.CrowdsecCapiPassword,
updateInterval: config.UpdateIntervalSeconds, crowdsecScenarios: config.CrowdsecCapiScenarios,
updateMaxFailure: config.UpdateMaxFailure, updateInterval: config.UpdateIntervalSeconds,
remediationCustomHeader: config.RemediationHeadersCustomName, updateMaxFailure: config.UpdateMaxFailure,
forwardedCustomHeader: config.ForwardedHeadersCustomName, remediationCustomHeader: config.RemediationHeadersCustomName,
defaultDecisionTimeout: config.DefaultDecisionSeconds, forwardedCustomHeader: config.ForwardedHeadersCustomName,
remediationStatusCode: config.RemediationStatusCode, defaultDecisionTimeout: config.DefaultDecisionSeconds,
redisUnreachableBlock: config.RedisCacheUnreachableBlock, remediationStatusCode: config.RemediationStatusCode,
banTemplate: banTemplate, redisUnreachableBlock: config.RedisCacheUnreachableBlock,
banTemplateContentType: banTemplateContentType, banTemplate: banTemplate,
traceCustomHeader: config.TraceHeadersCustomName, banTemplateContentType: banTemplateContentType,
crowdsecStreamRoute: crowdsecStreamRoute, traceCustomHeader: config.TraceHeadersCustomName,
crowdsecHeader: crowdsecHeader, crowdsecStreamRoute: crowdsecStreamRoute,
log: log, crowdsecHeader: crowdsecHeader,
log: log,
serverPoolStrategy: &ip.PoolStrategy{ serverPoolStrategy: &ip.PoolStrategy{
Checker: serverChecker, Checker: serverChecker,
}, },
@@ -327,7 +329,7 @@ func New(_ context.Context, next http.Handler, config *configuration.Config, nam
// ServeHTTP principal function of plugin. // ServeHTTP principal function of plugin.
// //
//nolint:nestif,gocyclo //nolint:nestif
func (bouncer *Bouncer) ServeHTTP(rw http.ResponseWriter, req *http.Request) { func (bouncer *Bouncer) ServeHTTP(rw http.ResponseWriter, req *http.Request) {
if !bouncer.enabled { if !bouncer.enabled {
bouncer.next.ServeHTTP(rw, req) bouncer.next.ServeHTTP(rw, req)
@@ -722,6 +724,17 @@ func crowdsecQuery(bouncer *Bouncer, stringURL string, data []byte) ([]byte, err
return body, nil return body, nil
} }
// isBodyUnreadable reports whether the request body cannot be buffered before
// forwarding it to the Appsec component. An HTTP/2 or HTTP/3 request without a
// Content-Length (typically a bidirectional gRPC stream) keeps its body open
// for the whole life of the stream and never reaches EOF, so reading it with
// io.ReadAll would block until the request times out and is wrongly turned into
// a 403. This mirrors the reference lua-cs-bouncer behavior, which refuses to
// read the body of an HTTP/2+ request that has no Content-Length.
func isBodyUnreadable(httpReq *http.Request) bool {
return httpReq.Body != nil && httpReq.ProtoMajor >= 2 && httpReq.ContentLength < 0
}
func appsecQuery(bouncer *Bouncer, ip string, httpReq *http.Request) error { func appsecQuery(bouncer *Bouncer, ip string, httpReq *http.Request) error {
routeURL := url.URL{ routeURL := url.URL{
Scheme: bouncer.appsecScheme, Scheme: bouncer.appsecScheme,
@@ -729,7 +742,14 @@ func appsecQuery(bouncer *Bouncer, ip string, httpReq *http.Request) error {
Path: bouncer.appsecPath, Path: bouncer.appsecPath,
} }
var req *http.Request var req *http.Request
if bouncer.appsecBodyLimit > 0 && httpReq.Body != nil { switch {
case isBodyUnreadable(httpReq):
if bouncer.appsecUnreadableBodyBlock {
// The caller (handleNextServeHTTP) logs this returned error with the IP.
return errors.New("appsecQuery:unreadableBody dropped")
}
req, _ = http.NewRequest(http.MethodGet, routeURL.String(), nil)
case bouncer.appsecBodyLimit > 0 && httpReq.Body != nil:
var bodyBuffer bytes.Buffer var bodyBuffer bytes.Buffer
limitedReader := io.LimitReader(httpReq.Body, bouncer.appsecBodyLimit) limitedReader := io.LimitReader(httpReq.Body, bouncer.appsecBodyLimit)
teeReader := io.TeeReader(limitedReader, &bodyBuffer) teeReader := io.TeeReader(limitedReader, &bodyBuffer)
@@ -740,7 +760,7 @@ func appsecQuery(bouncer *Bouncer, ip string, httpReq *http.Request) error {
// Conserve body intact after reading it for other middlewares and service // Conserve body intact after reading it for other middlewares and service
httpReq.Body = io.NopCloser(io.MultiReader(&bodyBuffer, httpReq.Body)) httpReq.Body = io.NopCloser(io.MultiReader(&bodyBuffer, httpReq.Body))
req, _ = http.NewRequest(http.MethodPost, routeURL.String(), bytes.NewBuffer(bodyBytes)) req, _ = http.NewRequest(http.MethodPost, routeURL.String(), bytes.NewBuffer(bodyBytes))
} else { default:
req, _ = http.NewRequest(http.MethodGet, routeURL.String(), nil) req, _ = http.NewRequest(http.MethodGet, routeURL.String(), nil)
} }
+137
View File
@@ -2,15 +2,19 @@ package crowdsec_bouncer_traefik_plugin //nolint:revive,stylecheck
import ( import (
"context" "context"
"io"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"net/url"
"reflect" "reflect"
"testing" "testing"
"text/template" "text/template"
"time"
cache "github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin/pkg/cache" cache "github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin/pkg/cache"
configuration "github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin/pkg/configuration" configuration "github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin/pkg/configuration"
ip "github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin/pkg/ip" ip "github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin/pkg/ip"
logger "github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin/pkg/logger"
) )
func TestServeHTTP(t *testing.T) { func TestServeHTTP(t *testing.T) {
@@ -376,3 +380,136 @@ func TestCaptchaMethodBasedLogic(t *testing.T) {
}) })
} }
} }
// blockingBody simulates a request body that never reaches EOF, like a
// bidirectional gRPC stream that keeps its body open for the whole life of
// the connection. Reading from it blocks until the test is done.
type blockingBody struct {
done <-chan struct{}
}
func (b blockingBody) Read(_ []byte) (int, error) {
<-b.done
return 0, io.EOF
}
func (blockingBody) Close() error { return nil }
func Test_isBodyUnreadable(t *testing.T) {
tests := []struct {
name string
protoMajor int
contentLength int64
hasBody bool
want bool
}{
{name: "http2 grpc stream without content-length", protoMajor: 2, contentLength: -1, hasBody: true, want: true},
{name: "http3 stream without content-length", protoMajor: 3, contentLength: -1, hasBody: true, want: true},
{name: "http2 with content-length", protoMajor: 2, contentLength: 42, hasBody: true, want: false},
{name: "http1.1 chunked without content-length", protoMajor: 1, contentLength: -1, hasBody: true, want: false},
{name: "http2 without body", protoMajor: 2, contentLength: -1, hasBody: false, want: false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
req, _ := http.NewRequest(http.MethodPost, "http://localhost", nil)
req.ProtoMajor = tt.protoMajor
req.ContentLength = tt.contentLength
if tt.hasBody {
req.Body = http.NoBody
} else {
req.Body = nil
}
if got := isBodyUnreadable(req); got != tt.want {
t.Errorf("isBodyUnreadable() = %v, want %v", got, tt.want)
}
})
}
}
// newStreamingRequest builds an HTTP/2 request whose body never reaches EOF,
// like a bidirectional gRPC stream (issue #323).
func newStreamingRequest(done <-chan struct{}) *http.Request {
req, _ := http.NewRequest(http.MethodPost, "http://localhost/signalexchange.SignalExchange/ConnectStream", blockingBody{done: done})
req.Header.Set("Content-Type", "application/grpc")
req.ProtoMajor = 2
req.ContentLength = -1
return req
}
// Test_appsecQuery_streamingDoesNotBlock is a regression test for issue #323:
// a gRPC streaming request whose body never reaches EOF must not be buffered
// (io.ReadAll would block until timeout and wrongly produce a 403). The appsec
// query must complete promptly, inspecting headers only.
func Test_appsecQuery_streamingDoesNotBlock(t *testing.T) {
appsecServer := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, _ *http.Request) {
rw.WriteHeader(http.StatusOK)
}))
defer appsecServer.Close()
appsecURL, _ := url.Parse(appsecServer.URL)
bouncer := &Bouncer{
appsecScheme: appsecURL.Scheme,
appsecHost: appsecURL.Host,
appsecPath: "/",
appsecBodyLimit: 10485760,
appsecUnreachableBlock: true,
appsecFailureBlock: true,
httpAppsecClient: appsecServer.Client(),
log: logger.New("INFO", ""),
}
done := make(chan struct{})
defer close(done)
finished := make(chan error, 1)
go func() {
finished <- appsecQuery(bouncer, "1.2.3.4", newStreamingRequest(done))
}()
select {
case err := <-finished:
if err != nil {
t.Errorf("appsecQuery() on streaming request returned error: %v", err)
}
case <-time.After(2 * time.Second):
t.Fatal("appsecQuery() blocked on a streaming request body (issue #323 regression)")
}
}
// Test_appsecQuery_dropUnreadableBody verifies that, when configured to do so,
// a request with an unreadable body is dropped (blocked) instead of forwarded
// without its body, mirroring the reference APPSEC_DROP_UNREADABLE_BODY option.
func Test_appsecQuery_dropUnreadableBody(t *testing.T) {
appsecServer := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, _ *http.Request) {
rw.WriteHeader(http.StatusOK)
}))
defer appsecServer.Close()
appsecURL, _ := url.Parse(appsecServer.URL)
bouncer := &Bouncer{
appsecScheme: appsecURL.Scheme,
appsecHost: appsecURL.Host,
appsecPath: "/",
appsecBodyLimit: 10485760,
appsecUnreadableBodyBlock: true,
httpAppsecClient: appsecServer.Client(),
log: logger.New("INFO", ""),
}
done := make(chan struct{})
defer close(done)
finished := make(chan error, 1)
go func() {
finished <- appsecQuery(bouncer, "1.2.3.4", newStreamingRequest(done))
}()
select {
case err := <-finished:
if err == nil {
t.Error("appsecQuery() expected an error to block the request, got nil")
}
case <-time.After(2 * time.Second):
t.Fatal("appsecQuery() blocked on a streaming request body (issue #323 regression)")
}
}
+48 -46
View File
@@ -63,6 +63,7 @@ type Config struct {
CrowdsecAppsecTLSCertificateBouncerKeyFile string `json:"crowdsecAppsecTlsCertificateBouncerKeyFile,omitempty"` CrowdsecAppsecTLSCertificateBouncerKeyFile string `json:"crowdsecAppsecTlsCertificateBouncerKeyFile,omitempty"`
CrowdsecAppsecFailureBlock bool `json:"crowdsecAppsecFailureBlock,omitempty"` CrowdsecAppsecFailureBlock bool `json:"crowdsecAppsecFailureBlock,omitempty"`
CrowdsecAppsecUnreachableBlock bool `json:"crowdsecAppsecUnreachableBlock,omitempty"` CrowdsecAppsecUnreachableBlock bool `json:"crowdsecAppsecUnreachableBlock,omitempty"`
CrowdsecAppsecUnreadableBodyBlock bool `json:"crowdsecAppsecUnreadableBodyBlock,omitempty"`
CrowdsecAppsecBodyLimit int64 `json:"crowdsecAppsecBodyLimit,omitempty"` CrowdsecAppsecBodyLimit int64 `json:"crowdsecAppsecBodyLimit,omitempty"`
CrowdsecLapiScheme string `json:"crowdsecLapiScheme,omitempty"` CrowdsecLapiScheme string `json:"crowdsecLapiScheme,omitempty"`
CrowdsecLapiHost string `json:"crowdsecLapiHost,omitempty"` CrowdsecLapiHost string `json:"crowdsecLapiHost,omitempty"`
@@ -127,52 +128,53 @@ func contains(source []string, target string) bool {
// New creates the default plugin configuration. // New creates the default plugin configuration.
func New() *Config { func New() *Config {
return &Config{ return &Config{
Enabled: false, Enabled: false,
LogLevel: LogINFO, LogLevel: LogINFO,
LogFormat: "common", LogFormat: "common",
LogFilePath: "", LogFilePath: "",
CrowdsecMode: LiveMode, CrowdsecMode: LiveMode,
CrowdsecAppsecEnabled: false, CrowdsecAppsecEnabled: false,
CrowdsecAppsecFailureBlock: true, CrowdsecAppsecFailureBlock: true,
CrowdsecAppsecUnreachableBlock: true, CrowdsecAppsecUnreachableBlock: true,
CrowdsecAppsecBodyLimit: 10485760, CrowdsecAppsecUnreadableBodyBlock: true,
CrowdsecAppsecScheme: "", CrowdsecAppsecBodyLimit: 10485760,
CrowdsecAppsecHost: "crowdsec:7422", CrowdsecAppsecScheme: "",
CrowdsecAppsecPath: "/", CrowdsecAppsecHost: "crowdsec:7422",
CrowdsecAppsecKey: "", CrowdsecAppsecPath: "/",
CrowdsecAppsecTLSInsecureVerify: false, CrowdsecAppsecKey: "",
CrowdsecLapiScheme: HTTP, CrowdsecAppsecTLSInsecureVerify: false,
CrowdsecLapiHost: "crowdsec:8080", CrowdsecLapiScheme: HTTP,
CrowdsecLapiPath: "/", CrowdsecLapiHost: "crowdsec:8080",
CrowdsecLapiKey: "", CrowdsecLapiPath: "/",
CrowdsecLapiTLSInsecureVerify: false, CrowdsecLapiKey: "",
UpdateIntervalSeconds: 60, CrowdsecLapiTLSInsecureVerify: false,
MetricsUpdateIntervalSeconds: 600, UpdateIntervalSeconds: 60,
UpdateMaxFailure: 0, MetricsUpdateIntervalSeconds: 600,
StreamStartupBlock: true, UpdateMaxFailure: 0,
DefaultDecisionSeconds: 60, StreamStartupBlock: true,
RemediationStatusCode: http.StatusForbidden, DefaultDecisionSeconds: 60,
HTTPTimeoutSeconds: 10, RemediationStatusCode: http.StatusForbidden,
CaptchaProvider: "", HTTPTimeoutSeconds: 10,
CaptchaCustomJsURL: "", CaptchaProvider: "",
CaptchaCustomValidateURL: "", CaptchaCustomJsURL: "",
CaptchaCustomKey: "", CaptchaCustomValidateURL: "",
CaptchaCustomResponse: "", CaptchaCustomKey: "",
CaptchaSiteKey: "", CaptchaCustomResponse: "",
CaptchaSecretKey: "", CaptchaSiteKey: "",
CaptchaGracePeriodSeconds: 1800, CaptchaSecretKey: "",
CaptchaFilePath: "/captcha.html", CaptchaGracePeriodSeconds: 1800,
BanFilePath: "", CaptchaFilePath: "/captcha.html",
TraceHeadersCustomName: "", BanFilePath: "",
RemediationHeadersCustomName: "", TraceHeadersCustomName: "",
ForwardedHeadersCustomName: "X-Forwarded-For", RemediationHeadersCustomName: "",
ForwardedHeadersTrustedIPs: []string{}, ForwardedHeadersCustomName: "X-Forwarded-For",
ClientTrustedIPs: []string{}, ForwardedHeadersTrustedIPs: []string{},
RedisCacheEnabled: false, ClientTrustedIPs: []string{},
RedisCacheHost: "redis:6379", RedisCacheEnabled: false,
RedisCachePassword: "", RedisCacheHost: "redis:6379",
RedisCacheDatabase: "", RedisCachePassword: "",
RedisCacheUnreachableBlock: true, RedisCacheDatabase: "",
RedisCacheUnreachableBlock: true,
} }
} }