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
| Role | Description |
|---|---|
readonly | Can read system state, statistics, database records, configs, and documentation. Cannot modify anything. |
admin | Full 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.
| Scope | Readonly | Admin | Description |
|---|---|---|---|
mi.read | Yes | Yes | Read MI command output |
mi.list | Yes | Yes | List available MI commands |
mi.execute | No | Yes | Execute arbitrary MI commands |
mi.write | No | Yes | MI commands that modify state |
stats.read | Yes | Yes | Read runtime statistics |
health.read | Yes | Yes | Health check endpoints |
db.read | Yes | Yes | Read database records |
db.write | No | Yes | Create, update, delete database records |
config.read | Yes | Yes | Read configuration files and templates |
config.write | No | Yes | Write configuration files |
config.reload | No | Yes | Trigger configuration reload |
config.generate | No | Yes | Generate new configurations |
resource.read | Yes | Yes | Access MCP resources |
resource.write | No | Yes | Modify MCP resources |
process.manage | No | Yes | Process management operations |
docs.read | Yes | Yes | Access 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_contact—contactparameterusrloc_tools.ul_remove_contact—contactparameterb2b_tools.b2b_bridge—new_dstparameterpresence_tools.pua_publish—pres_uriandoutbound_proxyparametersuac_tools.uac_reg_list— optionalcontactparameterhomer_tools.homer_search_calls_v7—source_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_contact—aorparameteruac_tools.uac_reg_list/uac_reg_enable/uac_reg_disable/uac_reg_force_register—aorparametermid_registrar_tools.mid_registrar_update—aorparameter
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_ip—ipparameterpermissions_tools.address_add—ipparameterhomer_tools.homer_search_calls_v7—source_ipparameter (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/_list—domainparametersubscriber_tools.subscriber_bulk_import— everydomainfield in CSV rowstest_data_tools.gen_test_subscribers—domainparameter
Port Validation — validate_port
Range 1-65535.
Wired in: permissions_tools.address_add — port 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:
- Loopback binds (
127.0.0.1/localhost/::1) — start unconditionally. Trust boundary is the host. - Non-loopback bind without
OPENSIPS_MCP_API_KEY— refuses withRuntimeError. - Non-loopback bind with API key but without
OPENSIPS_MCP_BEHIND_PROXY=true— refuses withRuntimeError. The operator must explicitly affirm a reverse proxy (nginx, traefik, Caddy, etc.) is in front, validating the API key on every incoming request against theAuthorization: Bearer <key>header. Reference proxy config ships atdocker/nginx/nginx.conf. - Non-loopback bind with API key +
BEHIND_PROXY=true+role=admin— starts, but logs aWARNINGthat 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_KEYto 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(not200) for unauthenticated requests against/sseand/streamable-httpendpoints. - Set
OPENSIPS_MCP_BEHIND_PROXY=trueonly after confirming step 3. - Set
OPENSIPS_MCP_ROLE=readonlyunless 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:
- Extracts the current role from the application context.
- Calls the wrapped function.
- Logs a success entry on completion, or an error entry if an exception is raised.
- 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
- Use readonly role for monitoring dashboards and read-only agents.
- Set an API key (
OPENSIPS_MCP_API_KEY) when exposing the server over a network. - Run behind a reverse proxy (nginx, Caddy) with TLS termination.
- Restrict network access to the MCP server port using firewall rules.
- Enable audit logging to a persistent log store for compliance.
- Use database credentials with minimal privileges -- the MCP server only needs SELECT/INSERT/UPDATE/DELETE on OpenSIPS tables.