opcua-session-manager · v4.5.x
Docs · Daemon

Security hardening

A long-lived daemon with privileged access to industrial endpoints needs more than an auth token. Nine concrete hardenings the library ships — what they do, when they kick in, what they assume.

The daemon ships nine hardenings on top of the auth-token / transport posture covered earlier. Each one closes a class of mistake — most of them were closed in v4.3.0 after real-world deployments surfaced the gap.

1 — Socket file permission race

Problem. A naive bind-then-chmod sequence leaves the socket file world-readable / world-writeable in the window between bind() and chmod(). A daemon crash in that window leaves a permissive socket on disk for the next process to pick up.

Fix. SessionManagerDaemon::run() calls umask(0077) around the bind, so the socket is created with the configured mode atomically. The follow-up chmod() is now a belt-and-braces guard, not a load-bearing call.

You do not need to do anything to enable this — it runs unconditionally.

2 — Method whitelist on query

Problem. Without an allow-list, any IPC peer could invoke any ManagedClient-callable method. Some operations (e.g. raw SecureChannel internals) would let a misbehaving peer corrupt the session state for others.

Fix. CommandHandler::ALLOWED_METHODS enumerates the 44 methods that the query command will dispatch. Methods outside the list raise forbidden_method.

The whitelist is conservative by design. To call a method outside it — typically because a third-party module ships a custom operation — use the invoke command, which gates on hasMethod($name) against the loaded module set instead of a static list. See Extensibility · Third-party modules.

3 — Credential sanitisation in list

Problem. The list command returns the set of active sessions with their config. Earlier versions echoed username / clientKeyPath / caCertPath / userKeyPath / password — enabling a local peer to enumerate (endpoint → username) tuples and target the credentials elsewhere.

Fix. CommandHandler::SENSITIVE_CONFIG_KEYS redacts those keys from the list payload. The session-lookup keying (see Session reuse) still uses the real values internally — sessions stay correctly scoped per user — but the wire never carries them.

4 — Per-frame size cap

Problem. A single IPC peer could push the daemon's per- connection buffer to its 1 MiB limit and force repeated json_decode() of the entire buffer on every byte read. At 50 concurrent connections, that is ~50 MiB of JSON parsing per cycle — trivially DoS-able.

Fix. SessionManagerDaemon::MAX_FRAME_BYTES = 65 536. Any frame larger than 64 KiB is rejected with a payload_too_large error and the connection is closed. Legitimate requests are under 2 KiB; 64 KiB is comfortable headroom.

5 — IPv6 loopback consistency

Problem. TcpLoopbackTransport::isLoopbackAddress() previously rejected ::ffff:127.0.0.1 (IPv4-mapped IPv6 loopback — a false negative) and would misclassify ::ffff:192.168.1.10 as non- loopback only by coincidence of address prefix.

Fix. Explicit handling: ::ffff:127.* is accepted (it is genuinely loopback), every other ::ffff: address is rejected at construction. The check runs on both the daemon (refuses to bind) and the client (refuses to connect).

6 — Cross-platform path redaction

Problem. The error-sanitiser regex was Unix-only. Windows paths (C:\Users\…\secret.pem) and URLs with embedded credentials (opc.tcp://user:pwd@host) leaked through error messages unchanged — into logs, IPC error responses, and any monitoring that captured them.

Fix. CommandHandler::sanitizeErrorMessage() now runs three regexes — URL (any scheme), Windows path, Unix path — and emits [url] / [path] in their place. Regression coverage in CommandHandlerSecurityTest.

7 — Allowed certificate directories

Problem. The open command accepts certificate paths in its config payload. Without restriction, a peer could ask the daemon to load /etc/shadow or /proc/1/environ and trigger predictable file reads (the failure mode is constrained — the daemon only calls OpenSSL on the file — but the exposure is real).

Fix. --allowed-cert-dirs <dir1>,<dir2> restricts certificate loading to a closed list of parent directories. When set, the daemon canonicalises every certificate path and verifies it sits within one of the allowed roots.

bash terminal — restricted cert dirs
vendor/bin/opcua-session-manager \
    --allowed-cert-dirs /etc/opcua/certs,/var/lib/opcua/trust

Recommended for any deployment where IPC peers are not fully trusted. The cost is operational: every certificate the daemon loads must live under one of the allowed roots.

8 — Conservative PID liveness check

Problem. On sandboxed hosts where neither posix_kill(0) nor /proc/<pid> is available, isProcessRunning() previously returned "dead". A new daemon could then steal the PID file from a still- running instance.

Fix. When both introspection paths are unavailable, the check returns "alive" — the daemon refuses to launch rather than risk co-tenancy. You remove the stale PID file by hand if you are sure the previous process is gone.

9 — Persistent cache hardening (inherited)

Background. opcua-client v4.3.0 removed unserialize() from every cache code path in favour of JSON gated by an allowlist (Cache\WireCacheCodec). The session manager is long-running and its per-session caches persist across requests; this hardening applies to it for free.

You do not need to do anything, other than flush persistent caches on upgrade — see Recipes · Upgrading to v4.3.

10 — Session and channel hardening (inherited)

Background. opcua-client v4.5.0 closed the remaining items of its security review. Every session the daemon opens gets them for free: the CreateSessionResponse.serverSignature is verified (Part 4 §5.6.2 proof of possession) instead of being discarded, the ECDH ephemeral key signature is verified on ECC profiles before the key is used for nonce derivation, incoming secure-channel headers are checked against the negotiated channelId / tokenId with strictly increasing sequence numbers (anti-replay, Part 6 §6.7.2.4), and FileTrustStore compares stored certificates by SHA-256 rather than trusting the SHA-1 fingerprint alone.

The one knob that reaches the IPC layer is ApplicationUri binding. The core now requires the server certificate's SAN ApplicationUri to match the one the endpoint declares in its ApplicationDescription — so a certificate trusted for server A is not accepted from server B. It is on by default. For a server with a misconfigured SAN extension, opt out for that session only:

php php — per-session opt-out
$client = (new ManagedClient())->verifyApplicationUri(false);
$client->open('opc.tcp://legacy-plc.example:4840');

The flag travels in the open command's config object as verifyApplicationUri, lands in SessionConfig, and is applied to that session's ClientBuilder. Leaving it unset keeps the core default. Treat the opt-out as a workaround for a broken server rather than a deployment default — it is the check that stops one server from presenting another's trusted certificate.

What the daemon does not harden against

  • A compromised PHP process with the auth token. The token is binary trust — possess it, run any allowed command. Use application-layer ACLs (in your code, not the daemon) if you need per-user authorisation.
  • OPC UA server compromise. The daemon trusts the OPC UA server's responses; if the server lies, the daemon happily passes the lie through. Validate at the application layer.
  • Information disclosure via timing. Error responses are sanitised but not constant-time. A determined attacker could in principle infer the existence of files by timing differences. The threat model assumes local trust.

For coordinated disclosure of security issues, see the project's SECURITY.md.