Skip to content

Commit 51b4d8c

Browse files
committed
Add exposure check config: a non allow listed proxy, and validate proxy
The DAST admin settings gain a proxy that leaves from an IP the targets do not allow list, so a site can be seen the way the public sees it, next to the scanner's own allow listed IP. The proxy address carries basic auth as user:password@host and is held encrypted. Validate proxy asks an IP echo service what address this server and the proxy each leave from, so an admin can confirm the proxy works and is a different vantage. The network calls run off the event loop. A toggle is added to compare each site from the non allow listed IP on a refresh. The second screenshot and the open to the world posture it drives come in the next change; the toggle has no effect until then.
1 parent 84e8f66 commit 51b4d8c

4 files changed

Lines changed: 150 additions & 3 deletions

File tree

‎app/core/external.py‎

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,43 @@ def options(db) -> dict:
128128
"whitebox": bool(s.get(db, "external.whitebox"))}
129129

130130

131+
def exposure_proxy(db) -> str:
132+
"""The saved non allow listed proxy, decrypted, or empty."""
133+
from app.core import settings_store as s
134+
135+
return (s.get(db, "dast.unwhitelisted_proxy") or "").strip()
136+
137+
138+
def egress_ip(proxy: str = "", echo_url: str = "", allow_private: bool = False) -> dict:
139+
"""Ask a service that echoes the caller's IP what address a request leaves
140+
from, optionally through a proxy. -> {ok, ip, detail}."""
141+
import ipaddress
142+
143+
from app.core import external_probe
144+
145+
url = (echo_url or "https://api.ipify.org").strip()
146+
try:
147+
with external_probe.via_proxy(proxy or ""):
148+
got = external_probe.fetch(url, allow_private=allow_private, body_limit=256)
149+
except Exception as exc: # noqa: BLE001 - every failure is an answer
150+
return {"ok": False, "ip": "", "detail": f"{type(exc).__name__}: {str(exc)[:160]}"}
151+
body = (got.get("body") or "").strip()
152+
candidate = body.split()[0] if body else ""
153+
try:
154+
return {"ok": True, "ip": str(ipaddress.ip_address(candidate)), "detail": ""}
155+
except ValueError:
156+
detail = got.get("error") or (f"the service answered {got['status']}: {body[:80]}"
157+
if got.get("status") else "no answer from the echo service")
158+
return {"ok": False, "ip": "", "detail": detail}
159+
160+
161+
def validate_proxy(echo_url: str, proxy: str, allow_private: bool = False) -> dict:
162+
"""The egress IP this server shows directly, and the one the proxy shows.
163+
-> {direct: {...}, through: {...}}."""
164+
return {"direct": egress_ip("", echo_url, allow_private),
165+
"through": egress_ip(proxy, echo_url, allow_private)}
166+
167+
131168
def data_dir() -> Path:
132169
path = app_settings.data_dir / "external"
133170
path.mkdir(parents=True, exist_ok=True)

‎app/core/settings_store.py‎

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -388,6 +388,25 @@
388388
"can be made to fetch a URL will hit it here, which proves "
389389
"server side request forgery. Leave empty to switch the "
390390
"out of band checks off; they are then skipped."),
391+
"dast.exposure_check": (False, False,
392+
"On a telemetry refresh, also load each site through the "
393+
"non allow listed proxy below and take a second "
394+
"screenshot, to tell a site that only its allow listed "
395+
"callers reach from one that is open to the world."),
396+
"dast.whitelisted_ip": ("", False,
397+
"This scanner's own public IP, the one a target's allow "
398+
"list lets in. Shown for reference when the proxy is "
399+
"validated. Optional."),
400+
"dast.unwhitelisted_proxy": ("", True,
401+
"A proxy that leaves from an IP the targets do not allow "
402+
"list, so a site can be seen as the public sees it. A "
403+
"Squid address such as http://proxy.host:3128, or "
404+
"http://user:password@proxy.host:3128 when it needs a "
405+
"sign in. Held encrypted."),
406+
"dast.ip_echo_url": ("https://api.ipify.org", False,
407+
"A service that answers with the caller's IP, used by "
408+
"Validate proxy to show the address the proxy leaves "
409+
"from. It must return the plain IP."),
391410

392411
# ---- whose sites these are ----
393412
"web.company_domains": ([], False,

‎app/web/routes_external.py‎

Lines changed: 63 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1555,6 +1555,10 @@ def _rule_context(db, user):
15551555
scan_profiles=saved, profile_rulesets=profile_rulesets,
15561556
available_rulesets=_available_rulesets(db),
15571557
oob_url=settings_store.get(db, "dast.oob_url") or "",
1558+
whitelisted_ip=settings_store.get(db, "dast.whitelisted_ip") or "",
1559+
ip_echo_url=settings_store.get(db, "dast.ip_echo_url") or "https://api.ipify.org",
1560+
exposure_check=bool(settings_store.get(db, "dast.exposure_check")),
1561+
has_exposure_proxy=bool((settings_store.get(db, "dast.unwhitelisted_proxy") or "").strip()),
15581562
drafts=drafts, drafting=any(d.status == "working" for d in drafts),
15591563
values=settings_store.all_values(db), ias=_ias_context(),
15601564
ai_on=ai.enabled(ai.config(db)))
@@ -1658,18 +1662,74 @@ async def save_dast_settings(request: Request, user: User = Depends(current_user
16581662
from app.core import settings_store
16591663

16601664
oob = str(form.get("oob_url") or "").strip()[:500]
1661-
if oob and not oob.startswith(("http://", "https://")):
1662-
return redirect(_back(back, "The callback address must start with http:// "
1663-
"or https://, or be left empty.", "error"))
1665+
whitelisted = str(form.get("whitelisted_ip") or "").strip()[:60]
1666+
echo = str(form.get("ip_echo_url") or "").strip()[:500]
1667+
exposure = _checked(form, "exposure_check")
1668+
proxy_typed = str(form.get("unwhitelisted_proxy") or "").strip()[:1000]
1669+
proxy_clear = _checked(form, "proxy_clear")
1670+
for label, value in (("callback address", oob), ("IP echo service", echo),
1671+
("proxy address", proxy_typed)):
1672+
if value and not value.startswith(("http://", "https://")):
1673+
return redirect(_back(back, f"The {label} must start with http:// or "
1674+
"https://, or be left empty.", "error"))
16641675
with session_scope() as db:
16651676
_guard(db, user)
16661677
settings_store.set_value(db, "dast.oob_url", oob, user.username)
1678+
settings_store.set_value(db, "dast.whitelisted_ip", whitelisted, user.username)
1679+
settings_store.set_value(db, "dast.ip_echo_url",
1680+
echo or "https://api.ipify.org", user.username)
1681+
settings_store.set_value(db, "dast.exposure_check", exposure, user.username)
1682+
if proxy_clear:
1683+
settings_store.set_value(db, "dast.unwhitelisted_proxy", "", user.username)
1684+
elif proxy_typed:
1685+
settings_store.set_value(db, "dast.unwhitelisted_proxy", proxy_typed, user.username)
16671686
record(db, "dast.settings_saved", actor=user.username,
16681687
detail="oob_url set" if oob else "oob_url cleared",
16691688
source_ip=client_ip(request))
16701689
return redirect(_back(back, "Saved DAST settings."))
16711690

16721691

1692+
@router.post("/dast-admin/validate-proxy")
1693+
async def validate_proxy(request: Request, user: User = Depends(current_user)):
1694+
"""Show the egress IP this server presents directly and through the saved
1695+
non allow listed proxy, so an admin can confirm the proxy works and leaves
1696+
from a different address. The network calls run off the event loop."""
1697+
from starlette.concurrency import run_in_threadpool
1698+
1699+
from app.core import settings_store
1700+
1701+
await check_csrf(request)
1702+
back = "/dast-admin#tab-settings"
1703+
with session_scope() as db:
1704+
_guard(db, user)
1705+
proxy = external.exposure_proxy(db)
1706+
echo = settings_store.get(db, "dast.ip_echo_url") or "https://api.ipify.org"
1707+
allow_private = external.options(db)["allow_private"]
1708+
if not proxy:
1709+
return redirect(_back(back, "Set and save the non allow listed proxy first.", "warn"))
1710+
got = await run_in_threadpool(external.validate_proxy, echo, proxy, allow_private)
1711+
direct, through = got["direct"], got["through"]
1712+
if direct["ok"] and through["ok"]:
1713+
if direct["ip"] == through["ip"]:
1714+
msg = (f"Direct egress IP {direct['ip']}. Through the proxy {through['ip']}. "
1715+
"Warning: the proxy leaves from the same IP as this server, so it is "
1716+
"not a different vantage.")
1717+
kind = "warn"
1718+
else:
1719+
msg = (f"Direct egress IP {direct['ip']}. Through the proxy {through['ip']}. "
1720+
"The proxy leaves from a different IP, which is what the exposure "
1721+
"check needs.")
1722+
kind = "ok"
1723+
elif direct["ok"]:
1724+
msg = f"Direct egress IP {direct['ip']}, but the proxy did not answer: {through['detail']}"
1725+
kind = "error"
1726+
else:
1727+
msg = (f"Could not read the egress IP. Direct: {direct['detail']}. "
1728+
f"Proxy: {through['detail']}")
1729+
kind = "error"
1730+
return redirect(_back(back, msg, kind))
1731+
1732+
16731733
@router.post("/dast-admin/profiles")
16741734
async def save_scan_profile(request: Request, user: User = Depends(current_user)):
16751735
await check_csrf(request)

‎app/web/templates/dast_admin.html‎

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -305,8 +305,39 @@
305305
URL will hit it here, which proves server side request forgery. It
306306
must be reachable from the sites you scan. Leave empty to switch the
307307
out of band checks off.</span></label>
308+
309+
<div class="card-head" style="margin-top:1rem">Exposure check</div>
310+
<label style="margin-top:.4rem">This scanner's allow listed IP
311+
<input type="text" name="whitelisted_ip" maxlength="60" value="{{ whitelisted_ip }}"
312+
placeholder="203.0.113.10">
313+
<span class="faint small">The public IP a target's allow list lets in,
314+
shown for reference. Optional.</span></label>
315+
<label style="margin-top:.6rem">Non allow listed proxy (Squid)
316+
<input type="password" name="unwhitelisted_proxy" maxlength="1000" autocomplete="off"
317+
placeholder="{{ 'saved, leave empty to keep' if has_exposure_proxy else 'http://proxy.host:3128 or http://user:password@proxy.host:3128' }}">
318+
<span class="faint small">Leaves from an IP the targets do not allow list,
319+
so a site can be seen the way the public sees it. Basic auth goes in the
320+
address as user:password@host. Held encrypted.</span></label>
321+
{% if has_exposure_proxy %}<label style="margin-top:.4rem" class="faint small">
322+
<input type="checkbox" name="proxy_clear" value="1"> Remove the saved proxy</label>{% endif %}
323+
<label style="margin-top:.6rem">IP echo service
324+
<input type="url" name="ip_echo_url" maxlength="500" value="{{ ip_echo_url }}"
325+
placeholder="https://api.ipify.org">
326+
<span class="faint small">Answers with the caller's IP, used by Validate
327+
proxy to show the address the proxy leaves from.</span></label>
328+
<label style="margin-top:.6rem">
329+
<input type="checkbox" name="exposure_check" value="1" {{ 'checked' if exposure_check }}>
330+
Compare each site from a non allow listed IP on refresh
331+
<span class="faint small">Also loads each site through the proxy and takes a
332+
second screenshot, to tell an allow listed site from one open to the
333+
world.</span></label>
334+
308335
<div style="margin-top:.5rem"><button class="btn primary" type="submit">Save</button></div>
309336
</form>
337+
{% if has_exposure_proxy %}<form method="post" action="{{ base }}/dast-admin/validate-proxy" style="margin-top:.5rem">
338+
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
339+
<button class="btn" type="submit">Validate proxy</button>
340+
<span class="faint small">Shows the egress IP this server and the proxy each present.</span></form>{% endif %}
310341
<p class="faint small" style="margin-top:.8rem">More dynamic scanning
311342
settings will move here from the general Settings page over time.</p>
312343
</div>

0 commit comments

Comments
 (0)