Skip to content

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:

  1. Sane defaults: the AppSec config that ships with a fresh docker compose up -d no longer self-bans on benign realtime traffic.
  2. 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.
  3. Whitelist lifecycle: panel-managed whitelist entries land in a DB table and propagate to the CrowdSec parser on the next setup-appsec.sh run.

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:

on_load:
  - apply:
      - RemoveInBandRuleByID(920420)

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-ip with the resolved IP, which calls LAPI's DELETE /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_whitelist and 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 sessionStorage flag 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_whitelist partitioned by scope: ip rows under ip:, range rows under cidr:

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>:

ip 192.0.2.42
range 198.51.100.0/24

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:

  1. Profile filters compile with only Alert in the env (pkg/csprofiles/csprofiles.go:59). evt does not exist in the filter context; only methods on *models.Alert are reachable.
  2. The AppSec module never populates Alert.Meta with the target host. The hard-coded meta key set is id, name, method, uri, matched_zones, msg (pkg/acquisition/modules/appsec/utils.go:25); uri is 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-self returns the operator's resolved IP + a populated decisions array when banned, empty array when not.
  • POST /api/security/decisions/unban-ip removes every active decision for the supplied IP and audits the action.
  • POST /api/security/whitelist persists the row and the reconciler emits the sentinel; running docker compose exec crowdsec /setup-appsec.sh rewrites argos-whitelist.yaml correctly.
  • 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.yaml correctly 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 bool
  • backend/internal/db/hosts.go -- column list, INSERT, UPDATE, scan
  • backend/internal/db/migrate_test.go -- rollback 028 first
  • backend/internal/api/hosts.go -- hostRequest.TrueDetectMode
  • backend/internal/api/security_self.go (new) -- CheckSelf, UnbanIP, AddWhitelist handlers
  • backend/internal/security/files.go (new package) -- WriteTrueDetectHosts, WriteWhitelistEntries, AddManualWhitelist, atomicWrite
  • backend/internal/crowdsec/client.go -- ListDecisionsByIP
  • backend/internal/reconciler/reconciler.go -- emit whitelist + true-detect sentinel files after r.load(...)
  • backend/internal/server/server.go -- three new routes

CrowdSec

  • crowdsec/appsec-rules/argos-tuning.yaml (new) -- threshold-15 SecActions
  • crowdsec/appsec-configs/argos-appsec-block.yaml -- add argos/tuning to inband_rules, RemoveInBandRuleByID(920420) in on_load
  • crowdsec/appsec-configs/argos-appsec-detect.yaml -- same
  • crowdsec/setup-appsec.sh -- copy argos-tuning.yaml, remove appsec-native + appsec-generic-test, apply_panel_sentinels() for whitelist file
  • docker-compose.yml -- mount appsec-rules into crowdsec setup volume; mount argos_shared_setup into the long-running crowdsec service so /shared is readable during sentinel application

Frontend

  • frontend/src/api/client.ts -- securityCheckSelf, securityUnbanIP, securityWhitelistAdd methods + request/response types
  • frontend/src/components/SelfBlockBanner.tsx (new) -- cross-route banner, 60s poll, three actions
  • frontend/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 for true_detect_mode
  • docs/release-notes/v1.3.19.md (this file)
  • CHANGELOG.md, mkdocs.yml, version bump

Upgrade

cd argos-edge
git pull
docker compose build
docker compose up -d

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:

docker compose exec crowdsec /setup-appsec.sh

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_only per host, v1.3.16's preserve_host, v1.3.14's transport.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.