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 often carries the
credential that made the target worth scanning. On Unix a new log file is
created with mode 0600. An existing file keeps the permissions it already
has, and the server warns at startup if group or other can read it. Adjust your
log shipper's uid rather than widening the file. If the log file can't be
opened, the server exits with an error instead of starting.
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. The server warns at startup when it is bound to a non-loopback address with no API key.
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: GET /scan starts a scan
from query parameters alone, and callback_url POSTs the findings anywhere. 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
Originheader that isn't in--allowed-origins, or - a
Sec-Fetch-Sitevalue other thansame-originornone, such ascross-siteorsame-site(browsers attach this header 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 never accepts a
longer host that happens to contain it: regex:https://app\.example\.com does
not match https://app.example.com.evil.com. Writing the anchors yourself is
fine but redundant. 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. A regex that doesn't compile is
dropped with a startup warning, so the origin it described gets 403.
--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 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
handler-generated statuses you will see are 400 (invalid body or option),
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 or draining), 413 (body over
--max-body-bytes), 415 (body without a JSON Content-Type), 500 (a
preflight that failed inside the server), 503 (at capacity) and 504 (a
preflight that outlived its scan_timeout or the server's --scan-timeout). 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. Every envelope also carries Cache-Control: no-store and
X-Content-Type-Options: nosniff.
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, the same name the MCP scan_with_dalfox tool and the response use. url is accepted as an alias, both in the JSON body and as ?url= in the GET /scan query string. The target must start with http:// or https://; anything else is a 400.
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
}
}
resultsappears once the scan's worker has finished: the findings of adonescan, or the partial findings of anerror/cancelledone. A scan that never reached the target, or was cancelled before it started, has noresultsat all. A scan cancelled while running reportscancelledat once but only gainsresultswhen the worker drains, which can take a few seconds.error_messageis added when a scan failed or ran out of itsscan_timeout.progressis absent while the scan is stillqueued.requests_failedcounts requests that never reached the target (connect, TLS, timeout). When it is a large share ofrequests_sent, the scan did not really run: read zero findings as "not scanned", not "clean".suggested_poll_interval_msdrops from3000to2000past 10% and to1000past 80%, and is0once 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, in any
case (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 also carries error_message whenever the scan has one (a failure, a
scan_timeout expiry, a capped deep_scan), so a failed scan 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. So is a cancelled scan whose worker
is still draining: purging it would drop its concurrency slot while the worker
runs. Retry once it settles; a worker wedged past the 5-minute drain grace
becomes purgeable.
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"}'
Use it to see what a scan would cover before you start one.
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). It is bounded by the request's scan_timeout and the
server's --scan-timeout, and answers 504 when that runs out. 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 endpoints (the supported endpoints, each as {method, path, description}). 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,
"min_confidence": "high",
"blind_oob": true,
"blind_oob_wait": 30,
"session_check": "Sign out",
"session_check_url": "https://app/me"
}
}
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.
min_confidence ("low" or "high"; omitted keeps every finding) is the CLI's
--min-confidence: "high" drops every low-confidence finding from the job's
results. I findings carry no grade and are kept. GET /scan takes it as a
query parameter.
method, encoders, remote_payloads, remote_wordlists, waf_bypass,
force_waf and min_confidence are checked against the same values the CLI
accepts, and an unknown value is a 400. method is uppercased for you
("post" → "POST"). Each header entry must be in Name: value form, and a
header, user_agent or cookie value that can't be sent as an HTTP header
(a control character, for example) is also a 400.
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.
blind_oob is the managed OAST (interactsh) channel, the server/MCP equivalent
of the CLI's --blind-oob: pass true for the public mesh or a list of up to
8 server hosts (["oast.fun","oast.me"]). Dalfox registers, polls, and
correlates each callback to the payload that caused it, so you don't run a
listener yourself. A callback becomes a V finding with
detection_method: "oob". The poller is bound to the job: it is
started when the scan starts and stopped when it ends, is cancelled, or is
deleted, and it counts against the job caps. blind_oob_wait (0–600,
default 30) is how long to keep polling after the scan's last request; it
counts against scan_timeout. On GET /scan, ?blind_oob=true or
?blind_oob=oast.fun,oast.me select the same thing. The self-hosted auth token
(--blind-oob-secret) stays CLI/config-only.
session_check (a regex that must keep matching an authenticated response) and
session_check_url (a cheap authed endpoint to probe instead of the target)
turn on session-loss detection even without cookies (see the dead-session note
further down). A regex that does not compile or a non-http(s) probe URL is a
400.
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": [], "error_message": null }
status is done, error or cancelled, the same value GET /scan/{id}
reports. error_message is that endpoint's error_message too: null for a
clean finish or a plain cancel, otherwise the reason (target unreachable, a
scan_timeout expiry, lost session, worker panic). A warnings list is added
when the job has one, exactly as GET /scan/{id} reports it. 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).0or unset means unlimited.--scan-timeout <secs>: hard wall-clock budget per scan (0or unset means unbounded). Bounds long ordeep_scanjobs so one target can't pin a worker indefinitely. Also boundsPOST /preflight, which answers504on expiry.--max-concurrent-scans <n>: reject new submissions with503oncenscans are queued/running (default100,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>: request-body cap forPOST /scanand/preflight(default1048576= 1 MiB). An oversized body is refused with413before the handler parses it.--max-retained-scans <n>: cap on finished scans kept in memory (default1000,0= unlimited).--max-concurrent-scansonly counts active scans, so without this a flood of quick scans holds every result (response bodies included, wheninclude_responsewas 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 requestHostheader, 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. On SIGINT/SIGTERM the server stops accepting connections, waits up
to 10 seconds for HTTP responses already in flight, and exits. Scans still
running are abandoned rather than waited for, and no completion webhook fires
for them.
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, with a
discovered params capped to 512 (dropped N) entry in warnings and in the
server log. Split such a target with param if every parameter matters.
A deep_scan scan, which records one finding per reflecting payload, stops once
it has about 2000 findings (requests already in flight can add a few more). It
still ends done, and error_message says the results are partial.
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). It does not end
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 same rule covers a dead session. When the scan request carries credentials
(a cookie, or a Cookie / Authorization entry in header), or you set
session_check / session_check_url, 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 and no
session_check the monitoring is off and costs nothing.
Softer problems that don't fail the scan still reach you: GET /scan/{id} adds
a warnings list (absent when empty) for conditions such as blind_oob failing
to register with any server or failing to poll it, a session_check baseline that could not be
captured (monitoring inactive), or the discovered-parameter cap. A done scan
with zero findings and a non-empty warnings is not proof of a clean target.
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 need remote access. That keeps the network
out, but it is not a security boundary on its own: a web page you visit can
reach a loopback API through your own browser. The cross-site and
Hostgate in Browser requests is what blocks that. - Always set
--api-keyon 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_urland 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 tocallback_url. Onlyhttp(s)schemes are dialed, but the host is not filtered: loopback, link-local (e.g. cloud metadata at169.254.169.254), and private addresses are all reachable. On an unauthenticated bind this is a server-side request forgery and exfiltration primitive for anyone who can submit a scan, so set--api-keyand restrict egress when exposing the API to untrusted callers.--jsonpmakesGETendpoints readable cross-origin via<script>, which the CORS allow-list doesn't cover. A script load also carries noOriginto check, so it switches off the cross-site gate too. Enable it only when you intend that, and pair it with--api-key.- Bound scan runtime with
--scan-timeout. The per-requesttimeoutonly caps a single HTTP request; a scan with many parameters and payloads (ordeep_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.