Kai Ole Hartwig — Blog
Zwei gravierte Messingschilder auf dunklem Schiefer, links 'TYPO3 14 backend', rechts 'CrowdSec LAPI', verbunden durch ein kurzes Cat6-Patch-Kabel als sichtbare Bridge; daneben Kraftpapier-Labels mit IP-Decisions an einem oxblood Baumwollfaden — Metapher für die Operator-Bridge ins CrowdSec Local API.
AI-generated · gpt-image 2.0

moselwal/crowdsec-bridge — CrowdSec LAPI inside the TYPO3 backend.

moselwal/crowdsec-bridge brings the operationally relevant subset of the CrowdSec Local API into a TYPO3 14 backend module: list decisions, ban and unban IPs, inspect alerts, check the LAPI heartbeat — all without container shell or SSH access. Read-only by default, write only for the dedicated operator role, every write attempt is recorded in sys_log for forensic traceability.

Six building blocks

Requirements

Architecture: strict DDD four-layer split

The layers are enforced via deptrac.yaml in CI. Upward imports are forbidden; every commit goes through the gate.

 

Classes/
├── Domain/          # Pure value objects, enums, contracts. Zero external dependencies.
├── Application/     # CQRS queries and commands plus handlers. May depend only on Domain.
├── Infrastructure/  # HTTP client, repositories, audit loggers, cache decorator. May depend on Application, Domain and framework types.
└── Presentation/    # Backend controller, Fluid templates, event listeners.

 

The CQRS split is shallow but consistent: every backend interaction goes through a *Query / *Command object plus its dedicated *Handler. Handlers receive their dependencies via constructor injection — final readonly throughout.

The HTTP client implements Psr\Http\Client\ClientInterface and is constructor-injected. Domain code is framework-free and trivially unit-testable.

The optional cache is a decorator over LapiDecisionRepository that is only wired into the DI container when moselwal/keyvalue-store is installed and cacheEnabled=1 is set (see Configuration/Services.php). When the package is absent, a NullDecisionCacheInvalidator no-op binding ensures the write handlers do not need any runtime branching.

Configuration: bouncer key and extension options

Generate the bouncer API key inside the CrowdSec container:

 

cscli bouncers add typo3-bridge

 

The bridge looks up the key in this exact order:

  1. Docker secret file /run/secrets/crowdsec_api_key — recommended for production, SOPS-encrypted and mounted as a Docker secret.
  2. Environment variable CROWDSEC_LAPI_KEY.
  3. $GLOBALS['TYPO3_CONF_VARS']['EXTENSIONS']['crowdsec_bridge']['lapiKey'] — development only, never use in production.

If none of these resolves, the module surfaces a clear configuration error on first access.

Extension configuration

Managed via Admin Tools → Settings → Extension Configuration → crowdsec_bridge.

lapiBaseUrl

Default crowdsec. Full URL to the CrowdSec Local API. Must be reachable from the TYPO3 container.

lapiTimeout

Default 5. HTTP timeout in seconds for all LAPI calls.

allowDeleteForeignDecisions

Default 0. When 1, TYPO3 admins (not regular operators) may also delete decisions created by CrowdSec or cscli. Regular crowdsec_operator members are never allowed to delete foreign decisions regardless of this setting.

cacheEnabled

Default 1. Enables the decision-list cache. Requires moselwal/keyvalue-store to be installed; without it the setting has no effect.

cacheTtl

Default 10 seconds. Cache lifetime. Valid range 1–300; out-of-range values are clamped at construction time and a notice is written to the TYPO3 log.

Permissions and security model

The bridge does not auto-create backend groups. Three roles are created once under System → Backend Users → Groups and granted module access:

crowdsec_viewer — read-only

Decisions list, alerts list, IP quick-check, LAPI status.

crowdsec_operator — operator

All viewer rights, plus add new decisions and delete own typo3-origin decisions.

TYPO3 admin ($BE_USER->isAdmin() === true)

All operator rights, plus delete foreign-origin decisions when allowDeleteForeignDecisions=1.

All access checks run server-side. The UI hides buttons that the current user is not permitted to use; the controller additionally enforces every check, so direct URL manipulation results in HTTP 403.

Defence in depth against writes

No secret material is ever rendered into the browser. The bouncer API key lives in PHP-side state only, sourced from a Docker secret, environment variable, or TYPO3_CONF_VARS (in that order).

Audit trail in sys_log

The bridge writes structured entries to sys_log. Filter the System → Log module by these type values to inspect activity:

crowdsec_bridge:quickcheck

Every IP quick-check (hit or miss). Channel security, severity info.

crowdsec_bridge:decision_add

Successful or failed add attempts. Channel security, severity info or warning.

crowdsec_bridge:decision_delete

Successful, not-found, or failed delete attempts. Channel security, severity info or warning.

crowdsec_bridge:write_denied

Write attempt by a user without the operator role. Channel security, severity warning.

crowdsec_bridge:delete_denied

Delete attempt against a foreign-origin row without admin override. Channel security, severity warning.

crowdsec_bridge:csrf_denied

Write attempt with missing or invalid form token. Channel security, severity warning.

crowdsec_bridge:duplicate_submit

Re-submission of an already-consumed form token. Channel security, severity warning.

The full audit context (action, status, decision data, original origin, latency, error message) is JSON-serialised into the log_data column — ready for downstream analysis.

Optional decision-list cache

With moselwal/keyvalue-store installed and cacheEnabled=1, the bridge caches paginated decision-list responses with a short TTL (default 10 s). Typical impact: repeated F5 reloads stay below 50 ms.

Invalidation with a generation counter

Atomic INCR on a single KVS key. After every successful add or delete the counter is incremented — all previously cached keys become unreachable in O(1) without scanning. Old entries expire via TTL.

Failure handling

Any KVS error (backend down, connection timeout, malformed payload) silently degrades to direct LAPI calls. The first failure of a request is logged once at warning level into the TYPO3 logger; subsequent failures within the same request are suppressed to avoid log spam.

What is not cached

Installation

composer require moselwal/crowdsec-bridge
vendor/bin/typo3 extension:setup

 

Optionally enable the decision-list cache:

 

composer require moselwal/keyvalue-store
vendor/bin/typo3 cache:flush

 

Docker compose example

A minimal snippet wiring the bridge to a CrowdSec container and an optional Redis cache:

 

services:
  app:
    image: typo3:14
    depends_on:
      - crowdsec
      - redis
    networks:
      - internal
    secrets:
      - crowdsec_api_key
    environment:
      CROWDSEC_LAPI_KEY_FILE: /run/secrets/crowdsec_api_key

  crowdsec:
    image: crowdsecurity/crowdsec:latest
    networks:
      - internal
    expose:
      - "8080"

  redis:
    image: redis:7-alpine
    networks:
      - internal
    expose:
      - "6379"

networks:
  internal:

secrets:
  crowdsec_api_key:
    file: ./secrets/dev/crowdsec_api_key

 

Production: encrypt the secret file with SOPS and age, mount through Docker secrets, and do not expose either Redis or CrowdSec ports outside the internal network.

Next step

Bringing CrowdSec into the TYPO3 backend?

If you already run CrowdSec and want to operate decisions and alerts directly inside the TYPO3 backend — without container shell, with clear role separation, foreign-origin protection and a complete audit trail in sys_log — talk to me. I coordinate bouncer setup, backend groups, the optional decision cache and operator onboarding.

Talk about CrowdSec bridge

Or email us directly: kontakt@moselwal.de

Where I deploy this

This package belongs to the operator layer of any TYPO3 platform running behind Caddy with CrowdSec — decisions and alerts directly in the backend instead of in the container shell. In the managed variant: AI-Ready CMS as a Service with a CrowdSec bouncer, decision cache via keyvalue-store and properly configured backend roles.