Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 9 additions & 4 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -22,10 +22,15 @@ CIBERWEBSCAN_HTTP_TIMEOUT_READ=30.0 # -> http.timeout.read
CIBERWEBSCAN_HTTP_PROXY_ROTATE=false # -> http.proxy.rotate
CIBERWEBSCAN_HTTP_RETRY_MAX_ATTEMPTS=3 # -> http.retry.max_attempts
CIBERWEBSCAN_HTTP_RATE_LIMIT_ADAPTIVE=true # -> http.rate_limit.adaptive
# Use standard proxy env vars for proxy URLs
HTTP_PROXY=http://proxy.example.com:3128
HTTPS_PROXY=http://proxy.example.com:3128
NO_PROXY=localhost,127.0.0.1
# Permit loopback/RFC1918 proxy endpoints (e.g. local Burp). Independent
# from the *_ALLOW_LOCAL target opt-ins; IMDS/link-local stays blocked.
CIBERWEBSCAN_HTTP_PROXY_ALLOW_LOCAL=false # -> http.proxy.allow_local
# Standard proxy env vars are IGNORED unless use_env is enabled; when
# enabled, every env proxy is SSRF-validated before use (fail-closed).
CIBERWEBSCAN_HTTP_PROXY_USE_ENV=false # -> http.proxy.use_env
# HTTP_PROXY=http://proxy.example.com:3128
# HTTPS_PROXY=http://proxy.example.com:3128
# NO_PROXY=localhost,127.0.0.1

# Playwright / Scraping
CIBERWEBSCAN_SCRAPING_DYNAMIC_ENABLED=false # -> scraping.dynamic.enabled
Expand Down
27 changes: 27 additions & 0 deletions docs/API.md
Original file line number Diff line number Diff line change
Expand Up @@ -819,6 +819,33 @@ Update a single configuration value.
}
```

**Protected (operator-only) keys — HTTP 403:**

Security-sensitive configuration cannot be modified through this
endpoint, regardless of the API key used (keys carry no administrative
scope distinction, so no key is trusted with security exceptions):

- `http.proxy.*` — every proxy option (`http`, `https`, `socks5`,
`rotate`, `rotation_interval`, `proxy_list`, `allow_local`, `use_env`)
- Any `*.allow_local` security exception (`analysis.allow_local`,
`scraping.allow_local`, `attack.allow_local`)

The check is case-insensitive and also covers section/batch updates that
embed protected keys inside the `value` object (e.g. `path: "http"` with
`{"proxy": {"allow_local": true}}`). Rejected requests return
`403 Forbidden` and change nothing. The local operator can still change
these settings through the configuration file, environment variables or
the CLI (`ciberwebscan config set ...`), which do not pass through this
endpoint. `POST /api/config/reset` remains allowed because it restores
the safe defaults.

Additionally, every `proxy` field accepted by the scan endpoints
(`/api/analyze`, `/api/attack`, `/api/scrape`, `/api/quick`) is
validated against the SSRF proxy policy before any connection: local,
private, link-local (IMDS) and other forbidden proxy endpoints are
rejected with a validation-class error (`VALIDATION_ERROR`), never
silently ignored.

#### POST /api/config/export

Export the current configuration to a file inside the configuration base
Expand Down
69 changes: 66 additions & 3 deletions docs/CONFIGURATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,8 @@ Examples that work out of the box:
- `CIBERWEBSCAN_ATTACK_ALLOW_LOCAL` → `attack.allow_local`
- `CIBERWEBSCAN_ANALYSIS_ALLOW_LOCAL` → `analysis.allow_local`
- `CIBERWEBSCAN_SCRAPING_ALLOW_LOCAL` → `scraping.allow_local`
- `CIBERWEBSCAN_HTTP_PROXY_ALLOW_LOCAL` → `http.proxy.allow_local`
- `CIBERWEBSCAN_HTTP_PROXY_USE_ENV` → `http.proxy.use_env`
- `CIBERWEBSCAN_ATTACK_USER_CONSENT` → `attack.user_consent`
- `CIBERWEBSCAN_USER_AGENT_MODE` / `CIBERWEBSCAN_USER_AGENT_AGENTS` → `user_agent.*`
- `CIBERWEBSCAN_EXPORT_INCLUDE_SCREENSHOTS` → `export.include_screenshots`
Expand All @@ -87,9 +89,9 @@ Examples that work out of the box:

Not supported (and why):

- **Whole sections** cannot be set from a single variable — only leaf fields. For example `CIBERWEBSCAN_HTTP_PROXY` targets the nested `http.proxy` model and is ignored with a debug log; set `CIBERWEBSCAN_HTTP_PROXY_HTTP` (→ `http.proxy.http`) or use the standard `HTTP_PROXY`/`HTTPS_PROXY` variables instead.
- **Whole sections** cannot be set from a single variable — only leaf fields. For example `CIBERWEBSCAN_HTTP_PROXY` targets the nested `http.proxy` model and is ignored with a debug log; set `CIBERWEBSCAN_HTTP_PROXY_HTTP` (→ `http.proxy.http`). The standard `HTTP_PROXY`/`HTTPS_PROXY` variables are ignored unless `http.proxy.use_env` is enabled (and are SSRF-validated when honoured).
- Unknown variables are ignored with a debug log; they never break configuration loading.
- Invalid values on a valid key log the validation error and the loader falls back to defaults (see `Invalid configuration` error in the logs).
- Invalid values on a valid key log the validation error and the loader falls back to defaults (see `Invalid configuration` error in the logs). This includes proxies that violate the SSRF policy — the effective configuration is the safe default (no proxy), never an unvalidated one.

**Note**: Command-line options are specific to individual commands and do not override global configuration. They are used to customize behavior for that particular command execution.

Expand Down Expand Up @@ -186,7 +188,9 @@ Configure HTTP request behavior.
"socks5": null,
"rotate": false,
"rotation_interval": 10,
"proxy_list": null
"proxy_list": null,
"allow_local": false,
"use_env": false
},
"http2": true,
"follow_redirects": true,
Expand Down Expand Up @@ -216,6 +220,8 @@ Configure HTTP request behavior.
| `http.rate_limit.latency_window` | `10` | Sliding window size for latency measurements |
| `http.proxy.rotate` | `false` | Proxy rotation disabled by default |
| `http.proxy.rotation_interval` | `10` | Requests per proxy when rotating |
| `http.proxy.allow_local` | `false` | Permit loopback/RFC1918 proxy endpoints |
| `http.proxy.use_env` | `false` | Honour `HTTP_PROXY`/`HTTPS_PROXY`/`ALL_PROXY` |
| `http.http2` | `true` | Enable HTTP/2 by default |
| `http.follow_redirects` | `true` | Follow redirects |
| `http.max_redirects` | `10` | Max redirects to follow |
Expand Down Expand Up @@ -260,6 +266,63 @@ Example with rotation enabled:
}
```

#### Proxy SSRF Policy

Every proxy — regardless of its source (CLI/API parameter, config file,
environment, rotation list or helper client) — is classified against the
central SSRF policy (`ciberwebscan.core.client.ssrf.check_proxy_url`)
**before any connection is opened**:

- Accepted schemes: `http`, `https` and `socks5` (SOCKS5 is really
supported via `httpx[socks]`/`socksio`, not just accepted syntactically).
- Loopback, RFC1918/ULA, link-local (incl. cloud IMDS `169.254.169.254`),
CGNAT, multicast, reserved and unspecified addresses are blocked **by
default**, including non-canonical IPv4 notations (`2130706433`,
`0x7f000001`, `127.1`, …) and IPv4-mapped IPv6.
- Hostnames are resolved and every resulting address is classified;
resolution failures are treated as blocks (fail-closed).
- Credentials (`http://user:pass@host:port`) are accepted but never
echoed in logs or error messages.

**`http.proxy.allow_local`** (default `false`) permits loopback and
RFC1918/ULA proxy endpoints, e.g. Burp Suite or mitmproxy on the
operator workstation. It is fully independent from the per-module
`allow_local` target opt-ins (`analysis`/`scraping`/`attack`): neither
authorizes the other. Link-local/IMDS, CGNAT, multicast, reserved and
unspecified proxy addresses **stay blocked even when this flag is
enabled**.

```bash
# Local Burp proxy through the CLI config (operator-only setting)
ciberwebscan config set http.proxy.allow_local true
ciberwebscan config set http.proxy.http http://127.0.0.1:8080
```

**`http.proxy.use_env`** (default `false`): when `false`, the standard
`HTTP_PROXY`/`HTTPS_PROXY`/`ALL_PROXY` environment variables (and their
lowercase variants) are **ignored** — HTTP clients run with
`trust_env=False`. When `true`, every environment proxy is validated
against the same policy before the client is built; a single forbidden
value fails the client construction (fail-closed) instead of silently
bypassing the policy.

**Operator-only settings**: `http.proxy.*` (including `allow_local` and
`use_env`) and every `*.allow_local` security exception cannot be
modified through `PUT /api/config` (HTTP 403). Change them through the
configuration file, environment variables or the local CLI.

**Known residual limits** (not fully closable without custom transports):

- _DNS rebinding_: the validated hostname is resolved again by the HTTP
library when the connection is opened, so a hostile authoritative DNS
could answer differently between validation and connection. The same
limitation applies to request targets since BUG-004.
- _Playwright redirects_: Chromium follows redirect hops on its own —
Playwright routing treats a request plus its redirects as a single
unit — so intermediate hops are not re-validated; only the final
`page.url` is re-checked. WebSocket handshakes are routed and validated
separately (`route_web_socket`).

### User Agent

Configure user agent rotation.
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ requires-python = ">=3.10"
dependencies = [
"beautifulsoup4==4.15.0",
"click==8.5.0",
"httpx[http2]==0.28.1",
"httpx[http2,socks]==0.28.1",
"playwright==1.61.0",
"lxml==6.1.1",
"pyOpenSSL==26.4.0",
Expand Down
64 changes: 64 additions & 0 deletions src/ciberwebscan/api/routes/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,51 @@

router = APIRouter()

# Configuration subtrees that only the local operator may change (config
# file, environment variables or the local CLI). Remote updates via
# PUT /config are rejected: API keys carry no administrative scope
# distinction today, so no key can be trusted with security exceptions.
_PROTECTED_CONFIG_PREFIXES: tuple[str, ...] = ("http.proxy",)
_PROTECTED_CONFIG_SUFFIXES: tuple[str, ...] = (".allow_local",)


def _is_protected_config_key(key: str) -> bool:
"""Return True when *key* (dot-notation) is operator-only."""
normalized = key.strip().strip(".").lower()
if not normalized:
return False
if normalized == "allow_local":
return True
if any(
normalized == prefix or normalized.startswith(f"{prefix}.")
for prefix in _PROTECTED_CONFIG_PREFIXES
):
return True
return any(normalized.endswith(suffix) for suffix in _PROTECTED_CONFIG_SUFFIXES)


def config_update_touches_protected(path: str, value: Any) -> bool:
"""Return True when a config update would modify a protected key.

Covers direct updates (``http.proxy.http``), section updates and batch
updates that smuggle protected keys inside a value mapping
(``http`` with ``{"proxy": {...}}``, or root with
``{"analysis": {"allow_local": true}}``), case-insensitively.
"""
base = (path or "").strip().strip(".").lower()
if _is_protected_config_key(base):
return True

def _walk(prefix: str, node: Any) -> bool:
if isinstance(node, dict):
for key, subvalue in node.items():
full = f"{prefix}.{key}" if prefix else str(key)
if _is_protected_config_key(full) or _walk(full, subvalue):
return True
return False

return _walk(base, value)


@router.get(
"/config",
Expand Down Expand Up @@ -185,12 +230,31 @@ async def update_config(
"""
Update a configuration value.

Security-sensitive keys (``http.proxy.*`` and any ``*.allow_local``
security exception) are operator-only: they cannot be modified
remotely through this endpoint.

Args:
request: Contains key path, new value, and optional save flag.

Returns:
Updated configuration value with metadata.
"""
if config_update_touches_protected(request.path, request.value):
logger.warning(
"Rejected remote modification of protected config key %r",
request.path,
)
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=(
f"Configuration key '{request.path}' is protected: proxy "
"options and security exceptions (allow_local) can only be "
"changed by the local operator via the configuration file, "
"environment variables or the CLI."
),
)

try:
service = ConfigService()
result = service.set(request.path, request.value)
Expand Down
81 changes: 75 additions & 6 deletions src/ciberwebscan/config/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
import logging
from typing import Annotated, Literal

from pydantic import BaseModel, Field, HttpUrl, field_validator
from pydantic import BaseModel, Field, HttpUrl, field_validator, model_validator

logger = logging.getLogger(__name__)

Expand Down Expand Up @@ -70,22 +70,91 @@ class ProxyConfig(BaseModel):
"Accepts a list of strings or a single comma/newline-separated string."
),
)
allow_local: bool = Field(
default=False,
description=(
"Permit loopback/RFC1918 proxy endpoints (e.g. Burp Suite or "
"mitmproxy on the operator workstation). Independent from the "
"per-module allow_local target opt-ins: neither authorizes the "
"other. Link-local/IMDS, CGNAT, multicast and reserved proxy "
"addresses stay blocked even when true."
),
)
use_env: bool = Field(
default=False,
description=(
"Honour HTTP_PROXY/HTTPS_PROXY/ALL_PROXY environment "
"variables. Every environment proxy is validated against the "
"SSRF policy before use (fail-closed)."
),
)

@field_validator("proxy_list", mode="before")
@classmethod
def _normalize_proxy_list(cls, value: str | list[str] | None) -> list[str] | None:
"""Normalize proxy_list: accept a string or list, always return list or None."""
"""Normalize proxy_list: accept a string or list, always return list or None.

Purely syntactic (split on commas/newlines, default the scheme):
the SSRF policy is applied afterwards in the model validator,
where ``allow_local`` is already available, so string and list
forms are policed identically.
"""
if value is None:
return None
if isinstance(value, str):
from ciberwebscan.core.client.proxy import parse_proxy_list

parsed = parse_proxy_list(value)
return parsed if parsed else None
entries = [
chunk.strip()
for chunk in value.replace("\n", ",").split(",")
if chunk.strip()
]
normalized = [
entry
if entry.startswith(("http://", "https://", "socks5://"))
else f"http://{entry}"
for entry in entries
]
return normalized or None
if isinstance(value, list):
return [v for v in value if v] or None
return value

@model_validator(mode="after")
def _validate_proxies_against_policy(self) -> ProxyConfig:
"""Enforce the SSRF policy on every configured proxy at ingestion.

Strings and lists in ``proxy_list`` go through the same
classification as the individual fields, so a proxy that is
rejected as a string is equally rejected as a list entry. The
effective usage boundary in ``HTTPClient`` re-validates.
"""
from ciberwebscan.core.client.ssrf import (
SSRFBlockedError,
check_proxy_url,
)

candidates: list[tuple[str, str]] = []
for field_name in ("http", "https"):
value = getattr(self, field_name)
if value is not None:
candidates.append((field_name, str(value)))
if self.socks5:
candidates.append(("socks5", self.socks5))
for entry in self.proxy_list or []:
candidates.append(("proxy_list", entry))

for source, url in candidates:
try:
check_proxy_url(url, allow_local=self.allow_local)
except SSRFBlockedError as exc:
hint = (
" Set http.proxy.allow_local=true to permit local "
"proxy endpoints (operator decision)."
if not self.allow_local
else ""
)
raise ValueError(f"http.proxy.{source}: {exc}.{hint}") from exc
return self


class HTTPConfig(BaseModel):
"""Complete HTTP client configuration."""
Expand Down
Loading
Loading