REST API Server

dalfox server starts a long-lived HTTP service that queues and runs scans asynchronously. You submit a scan, get back a scan_id, and poll or cancel it however you like.

Starting the server

dalfox server
# listens on http://127.0.0.1:6664 by default

Common options:

dalfox server \
  --port 6664 \
  --host 0.0.0.0 \
  --api-key "8f2b1c6d4a9e7053b8c1f4d2e6a09b73" \
  --log-file /var/log/dalfox.log

--log-file records every submitted target URL, which routinely carries the credential that made the target worth scanning, so a new log file is created mode 0600. An existing file keeps whatever permissions it already has — the server warns at startup if it is readable by group or other, which is what an in-place upgrade from an older version leaves behind. Adjust your log shipper's uid rather than widening the file.

Authentication

If --api-key is set (or DALFOX_API_KEY is exported), every request must include:

X-API-KEY: 8f2b1c6d4a9e7053b8c1f4d2e6a09b73

If you don't set an API key, the server accepts unauthenticated requests; bind to 127.0.0.1 in that case.

Nothing throttles or locks out a wrong key, so its length is the only thing standing between an attacker who can reach the port and a valid key. Use at least 24 random characters — the server warns at startup for anything shorter:

export DALFOX_API_KEY="$(openssl rand -hex 16)"

Browser requests

Binding to 127.0.0.1 keeps the network out, but it does not keep browsers out: a web page you happen to visit can make your own browser call a loopback API. That matters here more than for most services, because GET /scan starts a scan from query parameters alone and callback_url POSTs the findings anywhere — so an attacker never needs to read a response to get the results.

The server therefore refuses requests that a browser identifies as cross-site:

  • an Origin header that isn't in --allowed-origins, or
  • Sec-Fetch-Site: cross-site / same-site (the header browsers attach to every subresource load, including <img> and <script>).

Both are answered with 403. Non-browser clients — curl, the CLI, agents, your CI job — send neither header and are unaffected.

The Host header is checked the same way, which is what blocks DNS rebinding (a hostname the attacker controls, re-resolved to your machine, which the browser then treats as same-origin). IP literals, localhost and the --host you bound to are always accepted; any other hostname must be listed:

# only needed when a proxy forwards a public hostname to dalfox
dalfox server --allowed-hosts "dalfox.internal,scan.corp.example"

To let a real web UI call the API, name its origin — that is the supported way through the gate:

dalfox server --allowed-origins "https://app.example.com"

CORS

dalfox server \
  --allowed-origins "https://app.example.com,https://admin.example.com" \
  --cors-allow-methods "GET,POST,OPTIONS,DELETE" \
  --cors-allow-headers "Content-Type,X-API-KEY,Authorization"

* on its own allows every origin (see below); a * inside an entry is a wildcard (https://*.example.com). Regex is supported via regex:^https://.*\.example\.com$.

No CORS headers are sent at all unless --allowed-origins is set. When it is, --cors-allow-methods defaults to GET,POST,OPTIONS,PUT,PATCH,DELETE and --cors-allow-headers to Content-Type,X-API-KEY,Authorization.

Both forms are matched against the whole Origin, so a pattern can never accept a longer host that merely contains it — regex:https://app\.example\.com does not match https://app.example.com.evil.com. Writing the anchors yourself is still fine; they are redundant, not wrong. The flip side is that a pattern has to cover the port when the origins it describes carry one (regex:https://app\.example\.com(:8443)?). Exact entries are compared case-insensitively.

--allowed-origins '*' means every origin is allowed, which switches the cross-site gate off the same way --jsonp does. The server warns at startup when either is combined with no API key.

JSONP

For browser clients that can't set custom headers:

dalfox server --jsonp --callback-param-name callback
# then GET /scan?target=...&callback=myFunction

JSONP is delivered to <script src> loads, which carry no Origin to check, so enabling it necessarily switches off the cross-site gate described above — any site can then launch scans through this API and read the results. Pair it with --api-key, or prefer CORS (--allowed-origins), which keeps the gate on. The server prints a startup warning when --jsonp is enabled without an API key.

With --jsonp on, every endpoint honours the callback parameter: the body is wrapped as name(json); and served as application/javascript. The callback name must be 1–64 characters from [A-Za-z0-9_$.], starting with a letter, _ or $; any other value is ignored and plain JSON comes back.

Endpoints

Method Path What it does
POST /scan Submit a new scan (JSON body)
GET /scan?target=... Submit a new scan (query string)
GET /scan/{id} Get scan status and results
DELETE /scan/{id} Cancel a queued or running scan
GET /scans List all scans (optional ?status=)
GET /result/{id} Alias for /scan/{id}
POST /preflight Discover parameters without sending payloads
GET /health Server info + capability list

Every response from these endpoints, success or failure, is the same {code, msg, data} envelope served as application/json. On an error code repeats the HTTP status, msg says what went wrong, and data is absent. The statuses you will see are 400 (invalid body or option, including a body over --max-body-bytes), 401 (missing or wrong API key), 403 (cross-site or untrusted Host, see Browser requests), 404 (unknown scan id), 409 (purge of a scan that is still active), 500 (a preflight that failed inside the server) and 503 (at capacity). The exceptions are a CORS preflight (OPTIONS), which answers 204 with no body (or a bare 403 when the browser gate refuses it), and a path or method not in the table, which gets a bare 404 / 405.

Submit a scan

curl -X POST http://127.0.0.1:6664/scan \
  -H "X-API-KEY: 8f2b1c6d4a9e7053b8c1f4d2e6a09b73" \
  -H "Content-Type: application/json" \
  -d '{
    "target": "https://target.app?q=test",
    "options": {
      "worker": 50,
      "timeout": 10,
      "encoders": ["url", "html"],
      "blind": "https://callback.interact.sh"
    }
  }'

The scan target field is target (matching the MCP scan_with_dalfox tool and the response payload). The legacy field name url is still accepted as an alias, in the JSON body and in the ?target= / ?url= query string alike, so existing clients keep working.

Options go under options. An unknown key, at the top level or inside options, is rejected with 400 instead of being ignored, so a flat body such as {"target": ..., "worker": 5} fails loudly rather than scanning with every option dropped.

Response:

{
  "code": 200,
  "msg": "ok",
  "data": {
    "scan_id": "9f2c…",
    "target": "https://target.app?q=test"
  }
}

Poll status

curl -H "X-API-KEY: 8f2b1c6d4a9e7053b8c1f4d2e6a09b73" http://127.0.0.1:6664/scan/9f2c…

Response (while running):

{
  "code": 200,
  "msg": "ok",
  "data": {
    "target": "https://target.app?q=test",
    "status": "running",
    "progress": {
      "params_total": 12,
      "params_tested": 5,
      "requests_sent": 234,
      "requests_failed": 0,
      "findings_so_far": 1,
      "estimated_completion_pct": 41,
      "suggested_poll_interval_ms": 2000
    },
    "queued_at_ms": 1758700000000,
    "started_at_ms": 1758700000120,
    "finished_at_ms": null,
    "duration_ms": 8450
  }
}
  • results appears once the scan's worker has finished: the findings of a done scan, or the partial findings of an error / cancelled one. A scan that never reached the target, or was cancelled before it started, has no results at all. A scan cancelled while running reports cancelled at once but only gains results when the worker drains, which can take a few seconds.
  • error_message is added when a scan failed or ran out of its scan_timeout.
  • progress is absent while the scan is still queued.
  • requests_failed counts requests that never reached the target (connect, TLS, timeout). When it is a large share of requests_sent, the scan did not really run: read zero findings as "not scanned", not "clean".
  • suggested_poll_interval_ms drops from 3000 to 2000 past 10% and to 1000 past 80%, and is 0 once the scan is terminal.

List scans

curl -H "X-API-KEY: 8f2b1c6d4a9e7053b8c1f4d2e6a09b73" 'http://127.0.0.1:6664/scans?status=running'

status is one of queued, running, done, error, cancelled (anything else is a 400). offset and limit page through the list (limit=0, the default, returns everything from offset on). Scans come back newest first:

{
  "code": 200,
  "msg": "ok",
  "data": {
    "total": 1,
    "scans": [
      {
        "scan_id": "9f2c…",
        "target": "https://target.app?q=test",
        "status": "running",
        "result_count": 0,
        "queued_at_ms": 1758700000000,
        "started_at_ms": 1758700000120,
        "finished_at_ms": null,
        "duration_ms": 8450
      }
    ],
    "pagination": { "offset": 0, "limit": 0, "returned": 1, "has_more": false }
  }
}

A row for a failed scan also carries its error_message, so it can't be mistaken for a clean one with result_count: 0.

Cancel a scan

curl -X DELETE -H "X-API-KEY: 8f2b1c6d4a9e7053b8c1f4d2e6a09b73" http://127.0.0.1:6664/scan/9f2c…

The response data is {scan_id, target, cancelled, previous_status}. cancelled is true only when the scan was queued or running; on a scan that had already finished the call is a no-op and cancelled is false. A cancelled scan stays listed with whatever partial results it gathered.

To remove a terminal record, append ?purge=1; the data is then {scan_id, target, deleted: true, previous_status}, and a scan that is still queued or running is refused with 409. This is an explicit force-purge escape hatch: unlike MCP's safe delete, it may discard partial results or a terminal webhook if the cancelled worker is still draining.

Preflight (no attack)

curl -X POST http://127.0.0.1:6664/preflight \
  -H "X-API-KEY: 8f2b1c6d4a9e7053b8c1f4d2e6a09b73" \
  -H "Content-Type: application/json" \
  -d '{"target":"https://target.app"}'

Response includes params_discovered, estimated_total_requests, and a list of parameters so you can scope before committing to a real scan.

The body is the same {target, options} shape as POST /scan. The data comes back as {target, reachable, method, params_discovered, estimated_total_requests, params: [{name, location, estimated_requests}]}. An unreachable target returns reachable: false with error_code: "CONNECTION_FAILED" and no parameters. Preflight is not silent on the wire: discovery and mining send real requests, paced by the request's delay, worker and rate_limit (and capped by the server's --rate-limit). At most 32 preflights run at once; beyond that the server answers 503.

Health

curl http://127.0.0.1:6664/health

Returns status: "ok", version, auth_required, and the list of supported endpoints. Good for uptime checks. It needs no API key, but the browser gate in Browser requests still applies.

ScanOptions reference (request body)

{
  "target": "https://target.app",
  "options": {
    "worker": 50,
    "delay": 0,
    "timeout": 10,
    "rate_limit": 0,
    "scan_timeout": 0,
    "blind": "https://callback.interact.sh",
    "method": "POST",
    "data": "user=test",
    "header": ["Authorization: Bearer token"],
    "cookie": "session=abc123; lang=en",
    "user_agent": "Custom",
    "encoders": ["url", "html"],
    "remote_payloads": ["portswigger"],
    "remote_wordlists": ["burp"],
    "include_request": false,
    "include_response": false,
    "callback_url": "https://your-webhook.example/dalfox",
    "param": ["q", "id:query"],
    "proxy": "http://127.0.0.1:8080",
    "insecure": true,
    "follow_redirects": false,
    "skip_mining": false,
    "skip_discovery": false,
    "deep_scan": false,
    "skip_ast_analysis": false,
    "analyze_external_js": false,
    "detect_outdated_libs": false,
    "waf_bypass": "auto",
    "skip_waf_probe": false,
    "force_waf": "cloudflare",
    "waf_evasion": false,
    "waf_min_confidence": 0.3,
    "max_payloads_per_param": 0
  }
}

Fields mirror the CLI flags. See the CLI reference for meaning and defaults. cookie is a single Cookie: header value; a list of name=value strings is also accepted and joined with ; . The MCP spellings are accepted as aliases, so an argument dict written for scan_with_dalfox works here too: cookies for cookie, headers for header, workers for worker, and blind_callback_url for blind.

Numeric options are range-checked and an out-of-range value is a 400: timeout 1–299 seconds, delay 0–9999 ms, worker 1–500, scan_timeout 0–86400 seconds, max_payloads_per_param 0–100000.

detect_outdated_libs is opt-in (default false): set it true to also report outdated / known-vulnerable JS libraries as informational [I] findings (CWE-1104, 0 extra requests). The same key works as a GET /scan query parameter. insecure defaults to true (TLS certificate verification is skipped, matching the CLI scanner default); send "insecure": false (or ?insecure=false on GET /scan) to enforce certificate validation.

proxy and callback_url are validated at submission, and an unusable value is a 400. callback_url must be http:// or https:// (empty means no webhook).

analyze_external_js is opt-in (default false): set it true to fetch same-origin <script src> bundles at preflight time and AST-analyze them for DOM XSS. Useful for SPAs whose sink logic lives entirely in external bundles. Off by default because it costs extra requests.

rate_limit caps the scan's outbound requests/second (0 = unlimited, the default), enforced across all worker tasks. The server-wide --rate-limit flag is an upper bound: a request may ask for a lower rate but cannot exceed or disable it.

max_payloads_per_param caps how many payloads each discovered parameter is tested with (default 0 = no explicit cap, the built-in payload safety cap still applies). Use a small value (e.g. 10–50) for smoke scans. Mirrors the MCP scan tool's field of the same name.

The five WAF fields mirror the CLI's WAF flags and are all optional — omit them and the scanner defaults apply. waf_bypass selects the handling mode: "auto" (detect then bypass, the default) or "off" (detect and report only); "force" is accepted and behaves like "auto". skip_waf_probe (default false) skips the active provocation probe; passive detection on the preflight response still runs. force_waf pins a specific WAF profile (e.g. "cloudflare") in place of whatever detection found, under "auto" or "force" alike; under "off" it is reported but no bypass is applied. waf_evasion (default false) enables adaptive evasion. waf_min_confidence is the detection confidence floor in [0.0, 1.0] (default 0.3); fingerprints below it are discarded.

method, encoders, remote_payloads and remote_wordlists are checked against the same values the CLI accepts, and an unknown verb, encoder or provider name is a 400. method is uppercased for you ("post" → "POST").

blind must be empty (meaning "no blind XSS") or an absolute http:// / https:// URL; anything else is a 400. Setting it arms stored blind-XSS injection: <script src=...> payloads are written into every query, body, header and cookie parameter and stay in the target.

scan_timeout is the whole-scan wall-clock budget in seconds (default 0 = unbounded), distinct from the per-request timeout. When the budget is reached the scan stops, keeps whatever partial findings it gathered, and settles as cancelled with an error_message that mentions scan_timeout (so you can tell a timeout apart from a client-issued cancel). The server-wide --scan-timeout flag caps every submitted scan the same way --rate-limit does.

GET /scan query parameters

GET /scan takes the same option names as query parameters, url for target included; the MCP aliases (workers, headers, cookies, blind_callback_url) are not read here. Unlike the JSON body, an unknown query parameter is ignored rather than rejected, so check the spelling. List options (encoders, param, remote_payloads, remote_wordlists) are comma-separated. header packs several headers into one value and is split only at a comma that starts a new Name:, so a comma inside a value such as Accept: text/html,application/xhtml+xml survives. Booleans read 1, true, yes or on (any case) as true and anything else as false. A number that is present but unparseable is a 400. method defaults to GET and encoders to url,html.

Completion webhook

When callback_url is set, the server POSTs one JSON body to it when the scan ends, whichever way it ends (including a scan cancelled before it started). For a scan cancelled mid-run the POST goes out once the worker has drained, not at the moment of the DELETE:

{ "scan_id": "9f2c…", "status": "done", "url": "https://target.app?q=test", "results": [] }

status is done, error or cancelled, the same value GET /scan/{id} reports. The target is under url here, not target. The POST goes through the scan's own proxy and TLS settings, times out after 10 seconds, and is not retried.

Server flags worth setting

  • --rate-limit <rps> — cap every scan's outbound request rate (protects targets).
  • --scan-timeout <secs> — hard wall-clock budget per scan; bounds long or deep_scan jobs so one target can't pin a worker indefinitely.
  • --max-concurrent-scans <n> — reject new submissions with 503 once n scans are queued/running (default 100, 0 = unlimited). Bounds memory and the blocking pool against a flood of submissions. A cancelled scan keeps its slot until its worker has actually stopped (at most five minutes), so a cancel does not free capacity instantly.
  • --max-body-bytes <n> — explicit request-body cap for POST /scan and /preflight (default 1048576 = 1 MiB); an oversized body is refused with 400 (invalid request body: ... length limit exceeded).
  • --max-retained-scans <n> — cap on finished scans kept in memory (default 1000, 0 = unlimited). --max-concurrent-scans only counts active scans, so without this a flood of quick scans holds every result — response bodies included, when include_response was set — until the one-hour retention TTL. Once the cap is hit the oldest finished scans are dropped; queued and running scans are never dropped.
  • --allowed-hosts <names> — extra hostnames accepted in the request Host header, on top of the bind host, localhost, and any IP literal. Needed when a reverse proxy forwards a public hostname; see Browser requests.

Job lifecycle

queued → running → done
                 ↘ error
                 ↘ cancelled
queued → cancelled

Terminal states (done, error, cancelled) are sticky. A queued scan can be cancelled before it starts. Jobs live in memory only: a finished scan is kept for one hour (or until --max-retained-scans evicts it) and nothing survives a restart.

A single scan tests at most 512 parameters. On a target that exposes more, the discovered set is truncated and the scan still ends done; the only trace is a discovered params capped to 512 warning in the server log. Split such a target with param if every parameter matters.

A target that can't be connected to (DNS failure, connection refused, TLS error, timeout) ends as error with an error_message of target unreachable: connection failed (CONNECTION_FAILED) — not done with zero findings, so you can tell "scanned, nothing found" apart from "never reached the host." Use POST /preflight first if you want to check reachability without launching a scan. The target must start with http:// or https://; any other scheme is rejected with 400 (same as /preflight).

The same rule covers a dead session. When the scan request carries credentials (a cookie, or a Cookie / Authorization entry in header), Dalfox fingerprints the authenticated response before scanning and re-checks it when the scan ends. If the session expired in between (every later request answered by a login page, nothing reflecting), the scan ends as error with an error_message beginning SESSION_LOST: and the signal that fired, rather than done with zero findings. Partial results stay attached. For a scan with no credentials the monitoring is off and costs nothing.

Running under systemd

# /etc/systemd/system/dalfox.service
[Unit]
Description=Dalfox scanner service
After=network.target

[Service]
ExecStart=/usr/local/bin/dalfox server --port 6664 --host 127.0.0.1 --log-file /var/log/dalfox.log
Environment=DALFOX_API_KEY=8f2b1c6d4a9e7053b8c1f4d2e6a09b73
Restart=on-failure
User=dalfox

[Install]
WantedBy=multi-user.target
sudo systemctl enable --now dalfox

Security notes

  • Bind to localhost unless you absolutely need remote access — but treat that as keeping the network out, not as a security boundary. A web page you visit can reach a loopback API through your own browser, which is what the cross-site and Host gate in Browser requests blocks.
  • Always set --api-key on a remote bind.
  • Keep the API key out of logs. Dalfox does not log it, but reverse proxies might.
  • Put it behind TLS (nginx, Caddy, Traefik) if you expose it over a network.
  • callback_url and the scan target are server-side requests. Dalfox is a URL scanner: it dials whatever target you submit, and on completion it POSTs the result JSON to callback_url. Only http(s) schemes are dialed, but the host is not filtered — loopback, link-local (e.g. cloud metadata at 169.254.169.254), and private addresses are all reachable. On an unauthenticated bind this is a server-side request forgery + exfiltration primitive for anyone who can submit a scan, so set --api-key and restrict egress when exposing the API to untrusted callers.
  • --jsonp makes GET endpoints readable cross-origin via <script>, which is not subject to the CORS allow-list — and, because a script load carries no Origin to check, it also switches off the cross-site gate. Enable it only when you intend that, and pair it with --api-key.
  • Bound scan runtime with --scan-timeout. The per-request timeout only caps a single HTTP request; a scan with many parameters and payloads (or deep_scan) can still run for a long time. Set --scan-timeout <secs> so every submitted scan has a hard wall-clock budget and a single slow target can't tie up a worker indefinitely.
ESC