Security Model

April 28, 2026 · View on GitHub

This document describes the security architecture of the OpenSIPS MCP Server.

RBAC Model

The server implements Role-Based Access Control with two roles.

Roles

RoleDescription
readonlyCan read system state, statistics, database records, configs, and documentation. Cannot modify anything.
adminFull access. All readonly permissions plus write operations, MI command execution, config generation, and process management.

The active role is set via the OPENSIPS_MCP_ROLE environment variable (default: readonly). Operators must explicitly set OPENSIPS_MCP_ROLE=admin to enable write / MI-execute access.

Permission Scopes

Every MCP tool is protected by a permission scope. The @require_permission("scope") decorator checks the active role before executing the tool.

ScopeReadonlyAdminDescription
mi.readYesYesRead MI command output
mi.listYesYesList available MI commands
mi.executeNoYesExecute arbitrary MI commands
mi.writeNoYesMI commands that modify state
stats.readYesYesRead runtime statistics
health.readYesYesHealth check endpoints
db.readYesYesRead database records
db.writeNoYesCreate, update, delete database records
config.readYesYesRead configuration files and templates
config.writeNoYesWrite configuration files
config.reloadNoYesTrigger configuration reload
config.generateNoYesGenerate new configurations
resource.readYesYesAccess MCP resources
resource.writeNoYesModify MCP resources
process.manageNoYesProcess management operations
docs.readYesYesAccess documentation

Permission Enforcement

from opensips_mcp.security.rbac import require_permission

@mcp.tool()
@require_permission("db.write")
async def subscriber_create(ctx: Context, username: str, domain: str, password: str):
    ...

If the active role lacks the required permission, an McpError is raised with a clear message:

Permission denied: role 'readonly' lacks 'db.write' permission

Input Validation Strategy

External input is validated at tool boundaries before reaching OpenSIPS, the database, or the cfg engine. This section is precise about which validators are wired where so you can audit the surface yourself.

Each validator below has a "wired in" note. If your tool isn't listed and accepts user input, the validator is available as a utility — pass it through try / except ValueError and return {"error": ...} on the failure path.

SIP URI Validation — validate_sip_uri

Enforces the sip: / sips: scheme, valid user-info, host, port, parameters, and headers.

from opensips_mcp.security.validators import validate_sip_uri

validate_sip_uri("sip:alice@example.com")        # OK
validate_sip_uri("sips:user@host:5061;transport=tls")  # OK
validate_sip_uri("alice@example.com")            # raises (no scheme — that's an AOR)
validate_sip_uri("http://example.com")           # raises

Wired in:

  • usrloc_tools.ul_add_contactcontact parameter
  • usrloc_tools.ul_remove_contactcontact parameter
  • b2b_tools.b2b_bridgenew_dst parameter
  • presence_tools.pua_publishpres_uri and outbound_proxy parameters
  • uac_tools.uac_reg_list — optional contact parameter
  • homer_tools.homer_search_calls_v7source_ip (when set)

Not used for AOR parameters — see validate_aor below.

AOR Validation — validate_aor

Bare user@domain (no scheme prefix). Used for usrloc / registrar / uac / mid_registrar tools where the documented input is alice@example.com. Refuses any leading sip: or sips: scheme so misuse is caught early.

from opensips_mcp.security.validators import validate_aor

validate_aor("alice@example.com")                # OK
validate_aor("alice@example.com:5060")           # OK (port allowed)
validate_aor("sip:alice@example.com")            # raises (use validate_sip_uri)
validate_aor("alice")                            # raises (no @domain)

Wired in:

  • usrloc_tools.ul_show_contacts / ul_add_contact / ul_remove_contactaor parameter
  • uac_tools.uac_reg_list / uac_reg_enable / uac_reg_disable / uac_reg_force_registeraor parameter
  • mid_registrar_tools.mid_registrar_updateaor parameter

IP Address Validation — validate_ip_address

IPv4 and IPv6 addresses validated via Python's ipaddress module.

Wired in:

  • fail2ban_tools.fail2ban_ban_ip / fail2ban_unban_ipip parameter
  • permissions_tools.address_addip parameter
  • homer_tools.homer_search_calls_v7source_ip parameter (when set)

MI Parameter Sanitization — sanitize_mi_params

All parameters sent to the MI interface are sanitized to remove dangerous characters (semicolons, control characters) that could lead to injection.

Wired in: mi_tools.mi_execute — every user-supplied param dict is sanitized before forwarding.

Cfg Value Sanitization — sanitize_cfg_value

Rejects characters that would break out of a quoted modparam value (", \, newline, backtick, null) and caps length at 4 KiB.

Wired in: every Jinja2 scenario render path — cfg/builder.py:_sanitize_params recursively sanitizes every string leaf in the params dict before template render.

SQL Identifier Whitelisting — validate_sql_identifier

Defined as a utility for future use. Not wired globally, by design: tools that touch table/column names (e.g. db_table_backup) ship their own per-tool allowlist that's tighter than the generic whitelist. Use validate_sql_identifier if you write a new tool that takes a free-form identifier from user input; for the existing tools the per-tool allowlist is the source of truth.

Domain Validation — validate_domain

Domain names validated against RFC 1035/1123 hostname rules.

Wired in:

  • subscriber_tools.subscriber_get / _create / _listdomain parameter
  • subscriber_tools.subscriber_bulk_import — every domain field in CSV rows
  • test_data_tools.gen_test_subscribersdomain parameter

Port Validation — validate_port

Range 1-65535.

Wired in: permissions_tools.address_addport parameter.

OpenSIPS Identifier Validation — validate_opensips_identifier

Module names, timer names, and other OpenSIPS identifiers must match [A-Za-z_][A-Za-z0-9_]{0,63}.

Wired in: cfg_tools.cfg_add_module (module name), cfg_tools.cfg_explain (topic), m4 macro names.

Credential Handling

HA1 Hash Generation

The security_generate_ha1 tool generates MD5-based HA1 digest hashes for SIP authentication. Passwords are never stored in plaintext in the database -- only the HA1 hash is persisted.

HA1 = MD5(username:realm:password)
HA1B = MD5(username@domain:realm:password)

Sensitive Parameter Masking

The audit logging system automatically masks parameters whose names contain password, secret, key, token, or ha1:

{
  "operation": "subscriber_create",
  "params": {"username": "1001", "password": "****", "ha1": "****"}
}

Network Transport Security

The MCP server can be exposed over stdio (default; child-process pipe), sse, or streamable-http. The two network transports add a hard-stop precondition that closes a real footgun.

The footgun

FastMCP does not natively enforce OPENSIPS_MCP_API_KEY on incoming SSE / streamable-http requests. Setting the env var alone does not make the framework verify it on every request — the framework just exposes the value to your code. An earlier revision of this server only logged a warning if OPENSIPS_MCP_HOST=0.0.0.0 was paired with an API key, which led to a real risk: an operator could set both, see "API key is configured" in the logs, and reasonably believe the server was authenticated, while actually being open to the public internet.

The mitigation

The server now refuses to start in any unsafe configuration:

  1. Loopback binds (127.0.0.1 / localhost / ::1) — start unconditionally. Trust boundary is the host.
  2. Non-loopback bind without OPENSIPS_MCP_API_KEY — refuses with RuntimeError.
  3. Non-loopback bind with API key but without OPENSIPS_MCP_BEHIND_PROXY=true — refuses with RuntimeError. The operator must explicitly affirm a reverse proxy (nginx, traefik, Caddy, etc.) is in front, validating the API key on every incoming request against the Authorization: Bearer <key> header. Reference proxy config ships at docker/nginx/nginx.conf.
  4. Non-loopback bind with API key + BEHIND_PROXY=true + role=admin — starts, but logs a WARNING that every authenticated client now has full write access.

This is enforced in app_lifespan (src/opensips_mcp/server.py) and runs before any tool, resource, or prompt is exposed.

Operator checklist

Before exposing the MCP on a non-loopback interface:

  • Set OPENSIPS_MCP_API_KEY to a long random string (≥ 32 bytes).
  • Deploy a reverse proxy that validates Authorization: Bearer <api_key> on every request before forwarding.
  • Confirm the proxy returns 401 (not 200) for unauthenticated requests against /sse and /streamable-http endpoints.
  • Set OPENSIPS_MCP_BEHIND_PROXY=true only after confirming step 3.
  • Set OPENSIPS_MCP_ROLE=readonly unless the deployment explicitly requires write access.
  • Terminate TLS at the proxy (mandatory; the MCP itself is plaintext).

If you cannot satisfy steps 1-3, bind to loopback only and use stdio transport (the typical pattern for Claude Desktop / Cursor / Cline integrations on the same host as the MCP).

Shell Safety

The cfg_validate tool invokes the OpenSIPS binary to validate configurations. The binary path is controlled by the OPENSIPS_MCP_OPENSIPS_BIN environment variable and is never constructed from user input. Configuration content is written to a temporary file before validation -- it is never passed as a shell argument.

Audit Logging

The security/audit.py module provides structured audit logging for sensitive operations.

Audit Log Format

Each entry is a JSON object written to the opensips_mcp.audit logger:

{
  "timestamp": 1745856000.0,
  "operation": "subscriber_create",
  "role": "admin",
  "status": "success",
  "params": {"username": "1001", "domain": "example.com", "password": "****"}
}

Using the Audit Decorator

from opensips_mcp.security.audit import audited

@mcp.tool()
@audited("subscriber_create")
@require_permission("db.write")
async def subscriber_create(ctx: Context, username: str, domain: str, password: str):
    ...

The decorator:

  1. Extracts the current role from the application context.
  2. Calls the wrapped function.
  3. Logs a success entry on completion, or an error entry if an exception is raised.
  4. Re-raises any exceptions after logging.

Wired in: every write-path tool in src/opensips_mcp/tools/. The contract is every function decorated with a write-scope @require_permission(...) also carries @audited(...), where the write scopes are: mi.write, mi.execute, db.write, config.write, config.reload, config.generate, process.manage, resource.write.

Coverage is pinned by tests/unit/test_audit_wiring.py. The test walks every file in tools/, discovers each write-path decorator at collection time, and asserts the matching @audited is present — adding a new write-path tool without an audit decorator fails CI.

To re-run the bulk-wiring after adding many new tools at once, the one-shot script is scripts/wire_audited_decorator.py (idempotent — re-running adds nothing if the file is already wired).

Log Configuration

Audit logs use the standard Python logging framework. Configure the opensips_mcp.audit logger to direct audit entries to a file, syslog, or external log aggregator:

import logging

audit_handler = logging.FileHandler("/var/log/opensips-mcp-audit.log")
audit_handler.setLevel(logging.INFO)
logging.getLogger("opensips_mcp.audit").addHandler(audit_handler)

Recommendations for Production

  1. Use readonly role for monitoring dashboards and read-only agents.
  2. Set an API key (OPENSIPS_MCP_API_KEY) when exposing the server over a network.
  3. Run behind a reverse proxy (nginx, Caddy) with TLS termination.
  4. Restrict network access to the MCP server port using firewall rules.
  5. Enable audit logging to a persistent log store for compliance.
  6. Use database credentials with minimal privileges -- the MCP server only needs SELECT/INSERT/UPDATE/DELETE on OpenSIPS tables.