v1.3.19 -- AppSec sane defaults + self-block escape hatch¶
The v1.3.16 -> v1.3.18 dogfood run produced a recurring failure mode: argos's own AppSec stack would silently auto-ban the operator's IP off legitimate traffic (socket.io polling, monitoring tools, hot-reload dev) and the only recovery was SSH'ing into the host to run cscli decisions delete. v1.3.19 closes that loop.
Three concrete shifts:
- Sane defaults: the AppSec config that ships with a fresh
docker compose up -dno longer self-bans on benign realtime traffic. - Self-block banner: the panel detects when the logged-in operator's IP is currently banned and offers a one-click unban + an optional permanent whitelist entry.
- Whitelist lifecycle: panel-managed whitelist entries land in a DB table and propagate to the CrowdSec parser on the next
setup-appsec.shrun.
What ships¶
Sane defaults (no operator action needed)¶
Three changes to the AppSec stack delivered out of the box:
crowdsecurity/appsec-native and appsec-generic-test removed¶
These are the two scenarios that turn raw inband WAF alerts into LAPI ban decisions. The vendor appsec-default config is opinionated: every WAF alert -> ban, regardless of what the bouncer's appsec_config remediation says. That is fine for a public-cloud SaaS WAF; it is not fine for a homelab where the WAF triggers on a Grafana panel polling every 30 seconds.
setup-appsec.sh now removes both scenarios on every run:
cscli scenarios remove crowdsecurity/appsec-native --force
cscli scenarios remove crowdsecurity/appsec-generic-test --force
The high-signal scenarios stay on: - crowdsecurity/appsec-vpatch -- CVE-specific virtual patches. Real attacks, narrow rules. - crowdsec-appsec-outofband -- 5+ matched rules in window. Threshold-based, low false-positive rate.
Operators who want the vendor-default aggressive posture back can re-install both with cscli scenarios install ...; re-running setup-appsec.sh is a no-op for already-installed scenarios.
Anomaly threshold bumped to 15¶
CRS default is 5 (one strict-rule match -> ban). v1.3.19 ships crowdsec/appsec-rules/argos-tuning.yaml, a local SecLang rule pack that sets:
SecAction "id:900110,phase:1,pass,nolog,setvar:tx.inbound_anomaly_score_threshold=15"
SecAction "id:900111,phase:1,pass,nolog,setvar:tx.outbound_anomaly_score_threshold=4"
15 is high enough that two or three benign rule matches do not converge into a 403; low enough that genuine attack patterns (which typically score 20-40 across SQLi/XSS/RCE classes) still trigger.
Both argos-appsec-block.yaml and argos-appsec-detect.yaml load argos/tuning in inband_rules so detect mode reports against the same bar block mode enforces. The "would have blocked" numbers on the AppSec dashboard stay calibrated to what would actually happen in production.
The rationale is documented at AppSec -> Tuning rationale.
CRS rule 920420 disabled in inband¶
920420 is "Request content-type is not allowed by policy". The default policy whitelist excludes text/plain, which socket.io polling and several monitoring tools legitimately use. Each request scores +5 and at threshold 15 the operator gets banned after three polls.
Both block-mode and detect-mode appsec configs now run:
The rule still loads in outofband (visible in detection metrics) but does not contribute to inband anomaly score.
Migration 028 (non-destructive)¶
ALTER TABLE hosts ADD COLUMN true_detect_mode INTEGER NOT NULL DEFAULT 0;
CREATE TABLE security_whitelist (
id INTEGER PRIMARY KEY AUTOINCREMENT,
scope TEXT NOT NULL CHECK (scope IN ('ip', 'range')),
value TEXT NOT NULL,
reason TEXT NOT NULL DEFAULT '',
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE(scope, value)
);
Existing hosts get true_detect_mode=0 and behaviour is identical. Down migration drops both. The true_detect_mode column is dormant in v1.3.19 (see "Deferred to v1.3.20" below); security_whitelist is exercised end-to-end by the self-block banner.
Self-block escape hatch¶
Cross-route banner¶
SelfBlockBanner mounts in Layout.tsx between the header and main content -- it is visible on every panel page, not just /security. The banner polls GET /api/security/check-self every 60 seconds with the operator's resolved client IP.
When the IP has at least one active LAPI decision the banner appears with three actions:
[!] Your IP (1.2.3.4) is currently banned.
Reason: crowdsecurity/http-bad-user-agent
Expires: in 3h 47m
[Unban my IP] [Whitelist my IP permanently] [Dismiss this session]
- Unban my IP calls
POST /api/security/decisions/unban-ipwith the resolved IP, which calls LAPI'sDELETE /v1/decisions. The bouncer revalidates each request against the live decision set, so the unban is effective immediately for the unbanned client. - Whitelist my IP permanently persists a row in
security_whitelistand rewrites the panel sentinel file at/data/shared/argos-whitelist-entries.txt. The success toast surfaces the exact reload command:docker compose exec crowdsec /setup-appsec.sh. - Dismiss this session stashes a
sessionStorageflag so the banner stays hidden until the operator opens a new browser session. The 60-second poll continues; if a NEW ban appears the banner re-shows.
Three new /api/security/* endpoints¶
GET /api/security/check-self
-> { client_ip, banned, decisions: [...] }
POST /api/security/decisions/unban-ip
body: { ip }
-> { unbanned: N, ip }
POST /api/security/whitelist
body: { scope, value, reason }
-> 201 + { ..., reload_needed, reload_command }
All three sit behind the same session middleware as the rest of /api/*. The full security panel (decisions list, scenarios management, country blocking, audit log, dashboard widget) lands in v1.3.20+.
Bounded LAPI cost: ListDecisionsByIP¶
Initial smoke testing with a CAPI-blocklist-enabled stack hit a silent failure: GET /v1/decisions with no filter returned 2MB+ of payload, which the existing 1MB io.LimitReader truncated, which made JSON decoding fail, which made check-self return banned=false even when the operator WAS banned. v1.3.19 adds ListDecisionsByIP which uses LAPI's ?ip=<X> filter so the response is bounded by the per-IP active count (always a handful, never the full blocklist).
Whitelist lifecycle¶
security_whitelist DB table¶
Every panel-managed whitelist entry persists here. The table is the source of truth; the YAML file is regenerated from it.
argos-whitelist.yaml parser¶
setup-appsec.sh now writes /etc/crowdsec/parsers/s02-enrich/argos-whitelist.yaml with:
- system ranges hard-coded under
cidr:(RFC 1918, loopback, ULA -- always emitted) - operator entries from
security_whitelistpartitioned by scope:iprows underip:,rangerows undercidr:
The CrowdSec whitelist parser is strict about CIDR notation in the cidr: list (a bare 172.18.0.1 will fail with netip.ParsePrefix: no '/'); v1.3.19 partitions correctly so single IPs land in the right list.
Sentinel file pattern¶
The panel writes /data/shared/argos-whitelist-entries.txt on every reconciler pass; setup-appsec.sh reads /shared/argos-whitelist-entries.txt (same volume, the crowdsec service mounts as /shared). The format is one entry per line, <scope> <value>:
Operators are not expected to edit either file directly -- both carry "managed by argos" headers.
Deferred to v1.3.20¶
true_detect_mode per host is schema-only in v1.3.19. The DB column is in place, the model + API plumbing is in place, but the UI checkbox and the enforcement mechanism are deferred.
The original v1.3.19 design was a panel-managed entry in CrowdSec's profiles.yaml that filtered on evt.GetMeta("target_fqdn") to suppress decisions for true-detect hosts. Mid-implementation that hit two upstream blockers in CrowdSec v1.6.3:
- Profile filters compile with only
Alertin the env (pkg/csprofiles/csprofiles.go:59).evtdoes not exist in the filter context; only methods on*models.Alertare reachable. - The AppSec module never populates
Alert.Metawith the target host. The hard-coded meta key set isid, name, method, uri, matched_zones, msg(pkg/acquisition/modules/appsec/utils.go:25);uriis path-only (r.URI), not host.evt.Parsed["target_host"]exists at the runtime event layer but is gone by profile-eval time.
The filter expression literally cannot reference which Caddy site the request hit -- so the original design is structurally blocked, not a syntax fix.
v1.3.20 will land true_detect_mode via the architecturally correct CrowdSec pattern: per-host appsec_config selection through a Caddy template. Hosts with true_detect_mode=1 get the argos/appsec-detect config (which uses default_remediation: allow -- never raises an inband interruption); hosts with true_detect_mode=0 get the argos/appsec-block config. Both configs already exist from v1.3.10 / v1.3.12 work; the missing piece is the Caddy-render plumbing.
The schema column shipped now is forward-compatible with that work and costs nothing dormant.
Smoke¶
Verified end-to-end against a real prod stack:
GET /api/security/check-selfreturns the operator's resolved IP + a populateddecisionsarray when banned, empty array when not.POST /api/security/decisions/unban-ipremoves every active decision for the supplied IP and audits the action.POST /api/security/whitelistpersists the row and the reconciler emits the sentinel; runningdocker compose exec crowdsec /setup-appsec.shrewritesargos-whitelist.yamlcorrectly.- The self-block banner renders on every panel page and the three actions all behave as documented.
- Crowdsec boots clean with the new defaults; CRS rule 920420 no longer contributes to inband anomaly score.
argos-tuning.yamlcorrectly bumps the threshold (verified by tripping +10 worth of CRS rules without a ban).
Known limitations¶
Country geo-blocking silently fails (fixed in v1.3.20)¶
Verified Apr 25 2026 dogfood: a request from 149.102.251.103 (BR, Datacamp AS212238) returned 304 despite an active cscli decisions add --scope Country --value BR decision. The panel-emitted Caddy bouncer config left enable_streaming at the plugin default (true), and stream mode only indexes scope=Ip / scope=Range -- Country, AS, and other non-IP scopes are silently dropped before they reach the per-request lookup.
Fixed in v1.3.20: panel now emits enable_streaming: false explicitly. Verify on your stack with scripts/smoke/country-block.sh. Documented at Access control -> Country-based blocking.
Files changed¶
Backend¶
backend/migrations/028_hosts_true_detect_mode.up.sql(new)backend/migrations/028_hosts_true_detect_mode.down.sql(new)backend/internal/models/models.go--Host.TrueDetectMode boolbackend/internal/db/hosts.go-- column list, INSERT, UPDATE, scanbackend/internal/db/migrate_test.go-- rollback 028 firstbackend/internal/api/hosts.go--hostRequest.TrueDetectModebackend/internal/api/security_self.go(new) --CheckSelf,UnbanIP,AddWhitelisthandlersbackend/internal/security/files.go(new package) --WriteTrueDetectHosts,WriteWhitelistEntries,AddManualWhitelist,atomicWritebackend/internal/crowdsec/client.go--ListDecisionsByIPbackend/internal/reconciler/reconciler.go-- emit whitelist + true-detect sentinel files afterr.load(...)backend/internal/server/server.go-- three new routes
CrowdSec¶
crowdsec/appsec-rules/argos-tuning.yaml(new) -- threshold-15 SecActionscrowdsec/appsec-configs/argos-appsec-block.yaml-- addargos/tuningtoinband_rules,RemoveInBandRuleByID(920420)inon_loadcrowdsec/appsec-configs/argos-appsec-detect.yaml-- samecrowdsec/setup-appsec.sh-- copyargos-tuning.yaml, removeappsec-native+appsec-generic-test,apply_panel_sentinels()for whitelist filedocker-compose.yml-- mountappsec-rulesinto crowdsec setup volume; mountargos_shared_setupinto the long-running crowdsec service so/sharedis readable during sentinel application
Frontend¶
frontend/src/api/client.ts--securityCheckSelf,securityUnbanIP,securityWhitelistAddmethods + request/response typesfrontend/src/components/SelfBlockBanner.tsx(new) -- cross-route banner, 60s poll, three actionsfrontend/src/components/Layout.tsx-- mount the banner
Docs¶
docs/features/appsec.md-- "Detect mode is NOT no-block" section, mode table, scenario cascade, tuning rationale, common false-positives table, v1.3.20 roadmap note fortrue_detect_modedocs/release-notes/v1.3.19.md(this file)CHANGELOG.md,mkdocs.yml, version bump
Upgrade¶
Migration 028 applies automatically on first boot. The new defaults (anomaly threshold 15, rule 920420 disabled, auto-ban scenarios removed) only take effect after the operator runs:
setup-appsec.sh is idempotent. Existing hosts upgrade with no behaviour change beyond the defaults; the true_detect_mode column lands at default false on every row.
Not changed¶
- v1.3.18's
lan_onlyper host, v1.3.16'spreserve_host, v1.3.14'stransport.versions, target health badges, CLI password reset -- all untouched. - No env var, no compose surface change beyond the new shared volume mount on the crowdsec service.