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
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -287,7 +287,9 @@ Both HTTP servers (`mcp --http` and `catalog webui`) refuse cross-origin browser
with 403 — a page you visit cannot post writes to your SAP system through them. Requests
without an `Origin` header (curl, native MCP clients), same-origin requests and loopback
origins are always allowed; add others with `--cors-origin`, and require a token with
`--auth-token` / `--auth-token-env`. See [docs/cli-usage.md](docs/cli-usage.md#http-transport-and-access-control).
`--auth-token` / `--auth-token-env`. Origin checking cannot see DNS rebinding, where the
attacker controls the `Host` too — pass `--allowed-hosts` to refuse any `Host` other than
loopback, an IP literal and the address bound. See [docs/cli-usage.md](docs/cli-usage.md#http-transport-and-access-control).

The web UI ([`flutter/erpl_catalog_kit`](flutter/erpl_catalog_kit), compiled and embedded straight into the `erpl-adt` binary — see [Building from source](#building-from-source)) is **read-only against the cache except for curation**: Search, Browse, Entity Detail, Lineage, and Driver Tree all query the same fast `catalog_*` MCP tools the CLI and AI agents use; the Curate screen is the only one that writes, via `catalog_annotate`. There's no build/sync button — `catalog webui` doesn't hold a live SAP connection, so building, exporting, and syncing stay CLI-only operations. The Sync Status screen shows past sync runs and cache health, and Feed Export surfaces the exact `erpl-adt catalog build --format ...` command to run for each format, rather than re-implementing either client-side.

Expand Down
19 changes: 19 additions & 0 deletions docs/cli-usage.md
Original file line number Diff line number Diff line change
Expand Up @@ -245,6 +245,7 @@ matters:
| Flag | Effect |
|------|--------|
| `--cors-origin <list>` | Comma-separated extra browser origins allowed to call `/mcp`. `*` allows every origin. |
| `--allowed-hosts <list>` | Comma-separated `Host` header values this server answers to. Passing it refuses any other `Host` with 403. `*` allows every host. |
| `--auth-token <tok>` | Require `Authorization: Bearer <tok>`; requests without it get 401 and run nothing. |
| `--auth-token-env <var>` | Read that token from an environment variable instead of the command line. |

Expand All @@ -256,6 +257,24 @@ the case where a page the developer merely visited could otherwise post writes t
SAP system, and binding to `127.0.0.1` does not prevent it because the browser is already
inside the loopback boundary.

Origin validation alone does not stop **DNS rebinding**, which is why `--allowed-hosts`
exists. Once a page at `evil.example` makes `rebind.evil.example` resolve to `127.0.0.1`,
the browser believes it is talking to its own origin: the request arrives with `Host:
rebind.evil.example` and either a matching `Origin` or none at all, and both of those
satisfy the rules above — the attacker controls each side of the comparison. The `Host`
header is the half they cannot launder.

`--allowed-hosts` names the hosts this server answers to; loopback names, IP literals (an
IP address has no DNS name to rebind, so `--mcp-host 0.0.0.0` reached at a LAN address
keeps working) and the address bound are always allowed. **Passing the flag is what turns
refusal on.** Without it, an unrecognised `Host` is still served, with one warning per
distinct host on stderr — so a deployment reached through a DNS name or a reverse proxy
does not break on upgrade, and closing the hole is one flag:

```bash
erpl-adt mcp --http --mcp-host 0.0.0.0 --allowed-hosts mcp.internal.example
```

Authentication is off unless a token is configured. Binding beyond loopback without one
warns on stderr; `/healthz` never requires the token so liveness probes keep working.

Expand Down
62 changes: 52 additions & 10 deletions include/erpl_adt/mcp/http_security.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -19,16 +19,30 @@ namespace erpl_adt {
// Binding to 127.0.0.1 does not help there — the browser is inside the
// loopback boundary.
//
// Host validation stops the same browser being aimed here by DNS
// rebinding, which Origin validation cannot see: once evil.example makes
// rebind.evil.example resolve to 127.0.0.1, the browser treats the call as
// same-origin and sends either a matching Origin or none at all. Both of
// those satisfy the Origin rules, because the attacker controls each side
// of the comparison. The Host header is the half they cannot launder.
//
// The bearer token stops everything else that can reach the port.
//
// Both are permissive by default so that no existing deployment breaks: a
// request without an Origin header is not a browser and is allowed, and the
// token is only enforced once one is configured.
// All three are permissive by default so that no existing deployment breaks:
// a request without an Origin header is not a browser and is allowed, an
// unrecognised Host is served with a warning until --allowed-hosts opts in to
// refusing it, and the token is only enforced once one is configured.
// ---------------------------------------------------------------------------
struct HttpSecurityOptions {
// Extra origins allowed beyond same-origin and loopback. The single
// entry "*" restores the historical allow-everything behaviour.
std::vector<std::string> allowed_origins;
// Hosts allowed beyond loopback and IP literals — the bind host and
// anything named by --allowed-hosts. "*" allows every host.
std::vector<std::string> allowed_hosts;
// Whether an unrecognised Host is refused (true) or served with a
// warning (false, the default). Set by passing --allowed-hosts.
bool enforce_hosts = false;
// When non-empty, /mcp requires "Authorization: Bearer <token>".
std::string auth_token;
};
Expand All @@ -55,25 +69,53 @@ enum class OriginVerdict {
const std::string& host,
const HttpSecurityOptions& options);

// Why a request's Host was accepted or refused.
enum class HostVerdict {
NoHost, // no Host header — browsers always send one, so not a browser
Loopback, // localhost / 127.0.0.1 / [::1]
IpLiteral, // an IP address — there is no name to rebind
Allowlisted, // the bind host, or named by --allowed-hosts
Wildcard, // --allowed-hosts '*'
Unrecognised, // a DNS name we were not told about — the rebinding shape
};

[[nodiscard]] constexpr bool IsAllowed(HostVerdict verdict) {
return verdict != HostVerdict::Unrecognised;
}

// Classify a Host header against the options. `host` is the raw header value
// ("name" or "name:port"); an empty one means the header was absent. Whether
// an Unrecognised host is actually refused is the caller's decision, via
// HttpSecurityOptions::enforce_hosts.
[[nodiscard]] HostVerdict ClassifyHost(const std::string& host,
const HttpSecurityOptions& options);

// True when `header` carries the configured bearer token. Comparison is
// constant-time so a token cannot be recovered one byte at a time by timing
// the response. An empty configured token means "no auth required" and every
// request passes.
[[nodiscard]] bool BearerTokenMatches(const std::string& authorization_header,
const std::string& expected_token);

// Split a comma-separated --cors-origin value into individual origins,
// trimming whitespace and dropping empties.
[[nodiscard]] std::vector<std::string> ParseOriginList(const std::string& value);
// Split a comma-separated flag value into pieces, trimming whitespace and
// dropping empties.
[[nodiscard]] std::vector<std::string> ParseCommaList(const std::string& value);

// Historical name, kept because --cors-origin is the older flag.
[[nodiscard]] inline std::vector<std::string> ParseOriginList(
const std::string& value) {
return ParseCommaList(value);
}

// Build the options from CLI flag values, shared by `mcp --http` and
// `catalog webui`. Warns on `err` about the two configurations worth
// noticing — a wildcard origin, and binding somewhere other than loopback
// without a token — but does not refuse either, so nothing that runs today
// stops running. Returns nullopt only on an unusable configuration (an
// `catalog webui`. Warns on `err` about the configurations worth noticing —
// a wildcard origin or host, and binding somewhere other than loopback
// without a token — but does not refuse any of them, so nothing that runs
// today stops running. Returns nullopt only on an unusable configuration (an
// --auth-token-env naming a variable that is not set), having reported it.
[[nodiscard]] std::optional<HttpSecurityOptions> ResolveHttpSecurity(
const std::string& cors_origin_flag,
const std::string& allowed_hosts_flag,
const std::string& auth_token_flag,
const std::string& auth_token_env_flag,
const std::string& bind_host,
Expand Down
13 changes: 10 additions & 3 deletions src/cli/command_executor.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -2900,6 +2900,7 @@ int HandleCatalogWebui(const CommandArgs& args) {
RegisterCatalogStoreTools(registry, store);

auto security = ResolveHttpSecurity(GetFlag(args, "cors-origin"),
GetFlag(args, "allowed-hosts"),
GetFlag(args, "auth-token"),
GetFlag(args, "auth-token-env"), host,
std::cerr);
Expand Down Expand Up @@ -7952,13 +7953,19 @@ void RegisterAllCommands(CommandRouter& router) {
"having been run first, this serves an instructional message instead of the app.\n\n"
"Access control: requests without an Origin header (curl, native clients), "
"same-origin requests and loopback origins are allowed; any other browser "
"origin is refused with 403 unless named with --cors-origin. Binding beyond "
"127.0.0.1 without --auth-token exposes the catalog API — including the "
"curation writes of catalog_annotate — to everyone who can reach the port.";
"origin is refused with 403 unless named with --cors-origin. Origin alone "
"does not stop DNS rebinding, though: a page that points its own name at "
"127.0.0.1 arrives with a Host it controls and an Origin to match. Pass "
"--allowed-hosts to refuse any Host other than loopback, an IP literal and "
"the address bound; without it such a request is served with a warning. "
"Binding beyond 127.0.0.1 without --auth-token exposes the catalog API — "
"including the curation writes of catalog_annotate — to everyone who can "
"reach the port.";
help.flags = {
{"port", "<n>", "Port to listen on (default: 8383)", false},
{"host", "<addr>", "Host/address to bind (default: 127.0.0.1)", false},
{"cors-origin", "<list>", "Comma-separated extra origins allowed to call the API", false},
{"allowed-hosts", "<list>", "Host headers this server answers to; passing it refuses any other with 403", false},
{"auth-token", "<tok>", "Require 'Authorization: Bearer <tok>' on the API", false},
{"auth-token-env", "<var>", "Read that token from an environment variable", false},
};
Expand Down
11 changes: 9 additions & 2 deletions src/main.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,12 @@ void PrintMcpHelp(std::ostream& out) {
out << " Same-origin, loopback and non-browser (no Origin\n";
out << " header) requests are always allowed; anything else is\n";
out << " refused with 403. '*' allows every origin.\n";
out << " --allowed-hosts <l> Comma-separated Host header values this server answers\n";
out << " to. Loopback, IP literals and the bound address are\n";
out << " always allowed. Passing this refuses any other Host\n";
out << " with 403 (DNS-rebinding defence); without it such a\n";
out << " request is served with a warning. '*' allows every\n";
out << " Host.\n";
out << " --auth-token <tok> Require 'Authorization: Bearer <tok>' on /mcp\n";
out << " --auth-token-env <v> Read that token from an environment variable\n";
}
Expand Down Expand Up @@ -625,8 +631,9 @@ int HandleMcpServer(int argc, const char* const* argv) {
}
auto mcp_host = get("mcp-host", "127.0.0.1");

auto security = ResolveHttpSecurity(get("cors-origin"), get("auth-token"),
get("auth-token-env"), mcp_host, std::cerr);
auto security = ResolveHttpSecurity(get("cors-origin"), get("allowed-hosts"),
get("auth-token"), get("auth-token-env"),
mcp_host, std::cerr);
if (!security.has_value()) {
return kExitInternal;
}
Expand Down
86 changes: 81 additions & 5 deletions src/mcp/http_security.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,25 @@ bool IsLoopbackHost(const std::string& host) {
host == "::1";
}

// Is this host an IP address rather than a name? Only names can be pointed at
// 127.0.0.1 by an attacker's DNS server, so an IP literal cannot carry a
// rebinding attack — which is what lets `--host 0.0.0.0` stay reachable at a
// LAN address with no configuration.
//
// Deliberately loose: a bracketed value is IPv6, and anything made only of
// digits and dots is IPv4. Both tests are cheap and neither can be satisfied
// by a registrable domain name, which is the only property that matters here.
bool IsIpLiteral(const std::string& host) {
if (host.size() >= 2 && host.front() == '[' && host.back() == ']') {
return true;
}
if (host.empty()) {
return false;
}
return host.find_first_not_of("0123456789.") == std::string::npos &&
host.find('.') != std::string::npos;
}

} // namespace

OriginVerdict ClassifyOrigin(const std::string& origin, const std::string& host,
Expand Down Expand Up @@ -98,6 +117,43 @@ OriginVerdict ClassifyOrigin(const std::string& origin, const std::string& host,
return OriginVerdict::Denied;
}

HostVerdict ClassifyHost(const std::string& host,
const HttpSecurityOptions& options) {
const auto authority = Authority(host);
if (authority.empty()) {
// Browsers always send Host, so an absent one is not the shape this
// check exists for — it is an HTTP/1.0 or hand-rolled client.
return HostVerdict::NoHost;
}

for (const auto& allowed : options.allowed_hosts) {
if (Trim(allowed) == "*") {
return HostVerdict::Wildcard;
}
}

const auto name = HostOf(authority);
if (IsLoopbackHost(name)) {
return HostVerdict::Loopback;
}
if (IsIpLiteral(name)) {
return HostVerdict::IpLiteral;
}

for (const auto& allowed : options.allowed_hosts) {
const auto entry = Authority(allowed);
// An entry naming a port is held to it; a bare name matches any port,
// because "the host I serve on" is what the operator meant to say.
const bool matches = (HostOf(entry) == entry) ? (entry == name)
: (entry == authority);
if (matches) {
return HostVerdict::Allowlisted;
}
}

return HostVerdict::Unrecognised;
}

bool BearerTokenMatches(const std::string& authorization_header,
const std::string& expected_token) {
if (expected_token.empty()) {
Expand Down Expand Up @@ -128,11 +184,22 @@ bool BearerTokenMatches(const std::string& authorization_header,
}

std::optional<HttpSecurityOptions> ResolveHttpSecurity(
const std::string& cors_origin_flag, const std::string& auth_token_flag,
const std::string& auth_token_env_flag, const std::string& bind_host,
std::ostream& err) {
const std::string& cors_origin_flag, const std::string& allowed_hosts_flag,
const std::string& auth_token_flag, const std::string& auth_token_env_flag,
const std::string& bind_host, std::ostream& err) {
HttpSecurityOptions options;
options.allowed_origins = ParseOriginList(cors_origin_flag);
options.allowed_origins = ParseCommaList(cors_origin_flag);
options.allowed_hosts = ParseCommaList(allowed_hosts_flag);
// Passing the flag at all is what turns refusal on. Without it an
// unrecognised Host is served with a warning, so no deployment reached
// through a DNS name today stops working.
options.enforce_hosts = !options.allowed_hosts.empty();
// The address the operator bound to is allowed without having to name it
// twice. Loopback and IP literals are already allowed by ClassifyHost.
const auto bind_name = HostOf(Authority(bind_host));
if (!bind_name.empty() && !IsLoopbackHost(bind_name) && !IsIpLiteral(bind_name)) {
options.allowed_hosts.push_back(bind_name);
}
options.auth_token = auth_token_flag;

if (options.auth_token.empty() && !auth_token_env_flag.empty()) {
Expand All @@ -153,6 +220,15 @@ std::optional<HttpSecurityOptions> ResolveHttpSecurity(
}
}

for (const auto& host : options.allowed_hosts) {
if (host == "*") {
err << "Warning: --allowed-hosts '*' accepts any Host header, which "
"leaves DNS rebinding open. Name the hosts you serve on "
"instead.\n";
break;
}
}

if (options.auth_token.empty() && !IsLoopbackHost(HostOf(Authority(bind_host)))) {
err << "Warning: binding " << bind_host
<< " exposes this server beyond this machine with no authentication. "
Expand All @@ -162,7 +238,7 @@ std::optional<HttpSecurityOptions> ResolveHttpSecurity(
return options;
}

std::vector<std::string> ParseOriginList(const std::string& value) {
std::vector<std::string> ParseCommaList(const std::string& value) {
std::vector<std::string> out;
size_t pos = 0;
while (pos <= value.size()) {
Expand Down
Loading