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.
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:
$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.