diff --git a/pkg/acquisition/modules/appsec/config.go b/pkg/acquisition/modules/appsec/config.go index f92962790fb..31d043aae42 100644 --- a/pkg/acquisition/modules/appsec/config.go +++ b/pkg/acquisition/modules/appsec/config.go @@ -130,9 +130,9 @@ func loadCertPool(caCertPath string, logger log.FieldLogger) (*x509.CertPool, er } // expandAppsecConfigEntry resolves a single appsec_config(s) entry into the list -// of appsec-config item names to load. A literal entry is returned untouched. An entry containing a glob meta-character -// is matched against the installed appsec-configs with the same matcher used -// to expand appsec-rule patterns; it errors when no installed config matches. +// of appsec-config item names to load. A literal entry is returned untouched. +// An entry containing a glob meta-character is matched against the installed appsec-configs, +// errors when no installed config matches. func expandAppsecConfigEntry(entry string, hub *cwhub.Hub) ([]string, error) { if !strings.ContainsAny(entry, "*?") { return []string{entry}, nil @@ -281,6 +281,11 @@ func (w *Source) Configure(ctx context.Context, yamlConfig []byte, logger *log.E return fmt.Errorf("unable to build challenge options: %w", err) } + // cwhub manages the script. + if customJS := appsecCfg.LoadCustomJS(w.hub.GetDataDir()); customJS != "" { + challengeOpts = append(challengeOpts, challenge.WithCustomJS(customJS)) + } + challengeRuntime, err := challenge.NewChallengeRuntime(ctx, challengeOpts...) if err != nil { return fmt.Errorf("unable to create challenge runtime: %w", err) diff --git a/pkg/appsec/appsec.go b/pkg/appsec/appsec.go index a6be3141528..c64f91252e5 100644 --- a/pkg/appsec/appsec.go +++ b/pkg/appsec/appsec.go @@ -1275,6 +1275,19 @@ func (w *AppsecRuntimeConfig) ProcessOnChallengeRules(ctx context.Context, state map[string]string{"Content-Type": "application/javascript", "Cache-Control": "public, max-age=3600"}, nil) } + // Unreferenced when no appsec-config shipped a script, so the path then + // falls through to normal request handling. + // + // Never cached: the script changes whenever an operator changes the shipped + // detections, and a returning visitor running old detections against new + // scoring rules would be silent. + if path == challenge.ChallengeCustomJSPath { + if customJS := w.ChallengeRuntime.CustomJS(); customJS != "" { + return w.setChallengeResponse(state, http.StatusOK, customJS, + map[string]string{"Content-Type": "application/javascript", "Cache-Control": "no-cache, no-store"}, nil) + } + } + // Challenge submission: validate, give on_challenge_submit hooks a chance // to reject the submission, then issue (or deny) the cookie. Per-route // on_challenge inspection happens on subsequent cookie-bearing requests. diff --git a/pkg/appsec/challenge/challenge.go b/pkg/appsec/challenge/challenge.go index 03a1884009e..c930e97fd97 100644 --- a/pkg/appsec/challenge/challenge.go +++ b/pkg/appsec/challenge/challenge.go @@ -31,6 +31,7 @@ import ( "strconv" "strings" "sync" + "sync/atomic" "text/template" "time" @@ -52,6 +53,8 @@ const ( ChallengeSubmitPath = "/crowdsec-internal/challenge/submit" ChallengePowWorkerPath = "/crowdsec-internal/challenge/pow-worker.js" ChallengeFPScannerPath = "/crowdsec-internal/challenge/fpscanner.js" + // ChallengeCustomJSPath serves the custom detection script; see WithCustomJS. + ChallengeCustomJSPath = "/crowdsec-internal/challenge/custom.js" ) // Sentinel errors (reasons) returned by ValidateChallengeResponse. @@ -78,11 +81,17 @@ const cryptoObfuscationPoolDefaultSize = 1 // outlive the per-epoch signing window without widening forgery exposure. const defaultCookieTTL = 12 * time.Hour +// DefaultCustomJSTimeout is the wall-clock all detection hooks share when +// custom_js_timeout is unset. It is time the visitor spends on the challenge +// page, so it is deliberately short. +const DefaultCustomJSTimeout = 500 * time.Millisecond + // DefaultChallengeCSP is the Content-Security-Policy header used on the // challenge page when the operator hasn't configured a custom one. Allows -// inline script/style (the challenge runtime injects both) and blob workers -// (the PoW worker is loaded from a blob URL). -const DefaultChallengeCSP = "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; worker-src 'self' blob:;" +// inline script/style (the challenge runtime injects both), blob workers +// (the PoW worker is loaded from a blob URL) and WebAssembly compilation +// (hub-shipped detection modules can use it). +const DefaultChallengeCSP = "default-src 'self'; script-src 'self' 'unsafe-inline' 'wasm-unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; worker-src 'self' blob:;" //go:embed challenge.html.tmpl var htmlTemplate string @@ -125,6 +134,23 @@ type ChallengeRuntime struct { // dynamic_module.go. challengeCode string + // customJS is the concatenated detection script shipped through the hub. + customJS string + + // customJSTimeout is the budget those hooks share, sent to the browser on + // each challenge page. + customJSTimeout time.Duration + + // customJSVersion is a digest of customJS. It is reported at startup so an + // operator can tell which build of the shipped detections an engine is + // actually running; it is not part of the script URL. + customJSVersion string + + // customOverflowWarned latches the first cookie-overflow warning. Overflow is + // a property of the deployment (a detector reporting too much), not of the + // visitor, so warning per submission repeats it for every visitor. + customOverflowWarned atomic.Bool + powDifficulty int // keys derives per-epoch sign keys (per-challenge secret / PoW MAC HMACs) @@ -183,12 +209,29 @@ type runtimeOptions struct { maxCookieLen int cryptoObfuscationPoolSize int spentSetMaxEntries int + customJS string + customJSTimeout time.Duration logger *log.Entry // nil → default "challenge" sublogger // skipPreWarm drops the constructor's synchronous obfuscation and the // background pre-warmer. Only withoutPreWarm (challenge_test.go) sets it. skipPreWarm bool } +// WithCustomJS sets the hub-distributed detection script. Empty disables it. +func WithCustomJS(src string) Option { + return func(o *runtimeOptions) { + o.customJS = src + } +} + +// WithCustomJSTimeout sets the shared detection-hook budget; zero or negative +// is ignored, leaving DefaultCustomJSTimeout. +func WithCustomJSTimeout(d time.Duration) Option { + return func(o *runtimeOptions) { + o.customJSTimeout = d + } +} + func WithLogger(logger *log.Entry) Option { return func(o *runtimeOptions) { o.logger = logger @@ -426,6 +469,11 @@ func NewChallengeRuntime(ctx context.Context, opts ...Option) (*ChallengeRuntime cookieTTL = defaultCookieTTL } + customJSTimeout := resolvedOpts.customJSTimeout + if customJSTimeout <= 0 { + customJSTimeout = DefaultCustomJSTimeout + } + maxCookieLen := resolvedOpts.maxCookieLen if maxCookieLen <= 0 { maxCookieLen = MaxCookieLen @@ -476,9 +524,12 @@ func NewChallengeRuntime(ctx context.Context, opts ...Option) (*ChallengeRuntime maxCookieLen: maxCookieLen, htmlTpl: htmlTpl, spent: newSpentSet(spentSetMaxEntries), + customJSTimeout: customJSTimeout, logger: logger, } + challengeRuntime.setCustomJS(resolvedOpts.customJS) + // Load the build-time-obfuscated challenge code from the baked-in bundle so // we can serve immediately. if err := challengeRuntime.seedCacheFromInitialBundle(); err != nil { @@ -505,13 +556,22 @@ func NewChallengeRuntime(ctx context.Context, opts ...Option) (*ChallengeRuntime go challengeRuntime.dynamicModulePreWarmer(runCtx) } - logger.WithFields(log.Fields{ + fields := log.Fields{ "rotation_interval": rotationInterval, "cookie_ttl": cookieTTL, "max_cookie_len": maxCookieLen, "pow_difficulty": defaultPowDifficulty, "crypto_pool_size": cryptoPoolSize, - }).Info("WAF challenge runtime initialized") + } + + // Only with a script loaded: the timeout bounds nothing without hooks, and an + // empty version on every challenge-mode startup reads as a failed load. + if challengeRuntime.customJS != "" { + fields["custom_js_version"] = challengeRuntime.customJSVersion + fields["custom_js_timeout"] = customJSTimeout + } + + logger.WithFields(fields).Info("WAF challenge runtime initialized") return challengeRuntime, nil } @@ -585,17 +645,26 @@ func (c *ChallengeRuntime) GetChallengePage(ctx context.Context, userAgent strin return "", fmt.Errorf("build dynamic key module: %w", err) } + // Empty drops the script tag, rather than pointing every challenge page at + // an empty file. + customJSPath := "" + if c.customJS != "" { + customJSPath = ChallengeCustomJSPath + } + var renderedPage strings.Builder if err := c.htmlTpl.Execute(&renderedPage, map[string]interface{}{ - "JSChallenge": challengeCode, - "DynamicModule": dynamicModule, - "FPScannerPath": ChallengeFPScannerPath, - "PowDifficulty": difficulty, - "PowPrefix": powSalt, - "PowMAC": powMAC, - "Timestamp": ts, - "R": r, + "JSChallenge": challengeCode, + "DynamicModule": dynamicModule, + "FPScannerPath": ChallengeFPScannerPath, + "CustomJSPath": customJSPath, + "CustomJSTimeoutMS": c.customJSTimeout.Milliseconds(), + "PowDifficulty": difficulty, + "PowPrefix": powSalt, + "PowMAC": powMAC, + "Timestamp": ts, + "R": r, }); err != nil { return "", fmt.Errorf("render challenge page: %w", err) } @@ -705,6 +774,7 @@ func (c *ChallengeRuntime) ValidateChallengeResponse(request *http.Request, body "k_epoch": fmt.Sprintf("%x", signKey), "fsid": fpData.FSID, "is_bot": fpData.FastBotDetection, + "custom": fpData.CustomKeys(), }).Debug("validated submission") } @@ -721,6 +791,15 @@ func (c *ChallengeRuntime) ValidateChallengeResponse(request *http.Request, body // rotation); the browser Max-Age below matches so both expire together. notAfter := time.Now().Add(c.cookieTTL).Unix() cookieValue, err := sealCookieV0(envelope, c.keys.MasterCookieKey(), notAfter, 0, "", []byte(request.UserAgent()), c.maxCookieLen) + + // If the cookie is too large, try to drop custom detection as a last chance to fit it within the size limit. + if errors.Is(err, ErrCookieTooLarge) && envelope.GetFingerprint().GetCustom() != nil { + c.logCustomOverflow(&fpData) + + envelope.Fingerprint.Custom = nil + cookieValue, err = sealCookieV0(envelope, c.keys.MasterCookieKey(), notAfter, 0, "", []byte(request.UserAgent()), c.maxCookieLen) + } + if err != nil { return nil, FingerprintData{}, 0, fmt.Errorf("failed to seal challenge cookie: %w", err) } @@ -794,3 +873,36 @@ func (c *ChallengeRuntime) ValidCookie(ck *http.Cookie, userAgent string) (*Cook AllowlistReason: envelope.AllowlistReason, }, nil } + +// setCustomJS keeps the script and the digest reported for it in step. +func (c *ChallengeRuntime) setCustomJS(src string) { + c.customJS = src + c.customJSVersion = CustomJSVersion(src) +} + +// CustomJS returns the detection script the dispatcher serves at +// ChallengeCustomJSPath, empty when none is loaded. +func (c *ChallengeRuntime) CustomJS() string { + if c == nil { + return "" + } + + return c.customJS +} + +// logCustomOverflow reports a dropped custom map. The first one is the operator's +// signal; the rest are the same deployment fact repeated once per visitor, so they +// go to debug. CustomKeys allocates, hence the level check. +func (c *ChallengeRuntime) logCustomOverflow(fpData *FingerprintData) { + const msg = "custom detections do not fit in the cookie, dropped" + + if c.customOverflowWarned.CompareAndSwap(false, true) { + c.log().WithField("custom", fpData.CustomKeys()).Warn(msg) + + return + } + + if c.log().Logger.IsLevelEnabled(log.DebugLevel) { + c.log().WithField("custom", fpData.CustomKeys()).Debug(msg) + } +} diff --git a/pkg/appsec/challenge/challenge.html.tmpl b/pkg/appsec/challenge/challenge.html.tmpl index 729ec556588..2e9c91c77d4 100644 --- a/pkg/appsec/challenge/challenge.html.tmpl +++ b/pkg/appsec/challenge/challenge.html.tmpl @@ -388,10 +388,13 @@ })(); - + + {{if .CustomJSPath}} + {{end}}