Files
open62541/tests/server/check_server_auditing.c
ChiouYouJiaandClaude Opus 5.5 86dd0be440 fix(server): do not leak the user token secret through the ActivateSession audit
ActivateSession decrypts the UserIdentityToken in place (decryptUserToken). Afterwards
auditActivateSessionEvent put that token unchanged into the audit payload, so the
payload carried the plaintext password of a UserNameIdentityToken (also when it was
sent RSA-OAEP encrypted on a SignAndEncrypt channel) or the plaintext tokenData of an
IssuedIdentityToken.

The payload is used in two places, and both leaked the secret:
- the auditNotificationCallback / globalNotificationCallback of the application;
- the event fields of the AuditActivateSessionEventType event that is generated on
  the Server object. A MonitoredItem selecting /UserIdentityToken receives the
  password of every user who logs in. Audit events are meant to be collected by
  audit/SIEM clients, so the secret leaves the server and ends up in logs.

This affects master only (the auditing code is not in a release branch) and only
servers with auditingEnabled.

Put a shallow copy of the token without password / tokenData into the payload. The
copy lives on the stack of auditActivateSessionEvent and outlives the synchronous
auditSessionEvent call; no allocation. PolicyId, UserName and EncryptionAlgorithm
are kept for the audit trail. Other token types are unchanged.

The test logs in as user1 and checks both the audit notification and the
AuditActivateSessionEventType event received by a local event MonitoredItem on the
Server object: they name user1 and carry no password. Without the fix both carried
the password.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 17:00:29 +02:00

442 lines
18 KiB
C

/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
#include <open62541/server.h>
#include <open62541/server_config_default.h>
#include <open62541/client.h>
#include <open62541/client_config_default.h>
#include <open62541/client_highlevel.h>
#include <open62541/plugin/accesscontrol_default.h>
#include <open62541/types.h>
#include "test_helpers.h"
#include <stdlib.h>
#include <string.h>
#include <check.h>
/* The body of this test exercises the auditing notification path and uses
* threading. Guard the platform-specific includes AND the body so reduced
* configs without auditing or thread-safe server support compile cleanly. */
#if defined(UA_ENABLE_AUDITING) && UA_MULTITHREADING >= 100
#include <stdio.h>
#include "thread_wrapper.h"
#endif
#if defined(UA_ENABLE_AUDITING) && UA_MULTITHREADING >= 100
static UA_Server *server = NULL;
static UA_atomic(uintptr_t) running = false;
static THREAD_HANDLE server_thread;
/* Counters per audit-event type. Updated from the server thread and from the
* test thread (UA_Server_writeValue triggers the callback synchronously), so
* increments must be atomic. Use the UA_atomic_* helpers from config.h: they
* work for every compiler/config the library builds with, unlike a direct
* <stdatomic.h> include which is unavailable for tcc and for MSVC without
* /std:c11. */
static UA_atomic(size_t) totalAuditCalls = 0;
static UA_atomic(size_t) totalGlobalCalls = 0;
static UA_atomic(size_t) writeAuditCalls = 0;
static UA_atomic(size_t) methodAuditCalls = 0;
static UA_atomic(size_t) sessionCreateCalls = 0;
static UA_atomic(size_t) sessionActivateCalls = 0;
static UA_atomic(size_t) sessionCancelCalls = 0;
static UA_atomic(size_t) channelOpenCalls = 0;
/* Write audits whose payload carries /SourceNode == expectedSourceNode.
* expectedSourceNode is set by the test thread before the write. */
static UA_NodeId expectedSourceNode;
static UA_atomic(size_t) writeSourceNodeMatches = 0;
/* ActivateSession audits with a UserNameIdentityToken: how many named the
* expected user, and how many of those carried a non-empty password. */
static UA_atomic(size_t) activateUserTokens = 0;
static UA_atomic(size_t) activateTokenPasswords = 0;
/* Atomic increment built on UA_atomic_cmpxchg (config.h has no fetch-add) */
static void
counterInc(UA_atomic(size_t) *c) {
size_t expected = UA_atomic_load(c);
for(;;) {
size_t old = expected;
UA_atomic_cmpxchg(c, &expected, old + 1);
if(expected == old)
return;
}
}
/* The same for the AuditActivateSessionEventType events received by a local
* event MonitoredItem on the Server object selecting /UserIdentityToken. */
static UA_atomic(size_t) eventUserTokens = 0;
static UA_atomic(size_t) eventTokenPasswords = 0;
static void
countUserToken(const UA_Variant *v, UA_atomic(size_t) *tokens,
UA_atomic(size_t) *passwords) {
const UA_UserNameIdentityToken *tok = NULL;
if(UA_Variant_hasScalarType(v, &UA_TYPES[UA_TYPES_USERNAMEIDENTITYTOKEN])) {
tok = (const UA_UserNameIdentityToken*)v->data;
} else if(UA_Variant_hasScalarType(v, &UA_TYPES[UA_TYPES_EXTENSIONOBJECT])) {
const UA_ExtensionObject *eo = (const UA_ExtensionObject*)v->data;
if(eo->encoding >= UA_EXTENSIONOBJECT_DECODED &&
eo->content.decoded.type == &UA_TYPES[UA_TYPES_USERNAMEIDENTITYTOKEN])
tok = (const UA_UserNameIdentityToken*)eo->content.decoded.data;
}
const UA_String user1 = UA_STRING_STATIC("user1");
if(!tok || !UA_String_equal(&tok->userName, &user1))
return;
counterInc(tokens);
if(tok->password.length > 0)
counterInc(passwords);
}
static void
activateEventCb(UA_Server *s, UA_UInt32 monId, void *monContext,
const UA_KeyValueMap eventFields) {
(void)s; (void)monId; (void)monContext;
if(eventFields.mapSize > 0)
countUserToken(&eventFields.map[0].value, &eventUserTokens,
&eventTokenPasswords);
}
static void
auditCb(UA_Server *s, UA_ApplicationNotificationType type,
const UA_KeyValueMap payload) {
(void)s;
counterInc(&totalAuditCalls);
switch(type) {
case UA_APPLICATIONNOTIFICATIONTYPE_AUDIT_UPDATE_WRITE: {
counterInc(&writeAuditCalls);
const UA_NodeId *src = (const UA_NodeId*)
UA_KeyValueMap_getScalar(&payload, UA_QUALIFIEDNAME(0, "/SourceNode"),
&UA_TYPES[UA_TYPES_NODEID]);
if(src && UA_NodeId_equal(src, &expectedSourceNode))
counterInc(&writeSourceNodeMatches);
break;
}
case UA_APPLICATIONNOTIFICATIONTYPE_AUDIT_UPDATE_METHOD:
counterInc(&methodAuditCalls); break;
case UA_APPLICATIONNOTIFICATIONTYPE_AUDIT_SECURITY_SESSION_CREATE:
counterInc(&sessionCreateCalls); break;
case UA_APPLICATIONNOTIFICATIONTYPE_AUDIT_SECURITY_SESSION_ACTIVATE: {
counterInc(&sessionActivateCalls);
const UA_UserNameIdentityToken *tok = (const UA_UserNameIdentityToken*)
UA_KeyValueMap_getScalar(&payload, UA_QUALIFIEDNAME(0, "/UserIdentityToken"),
&UA_TYPES[UA_TYPES_USERNAMEIDENTITYTOKEN]);
const UA_String user1 = UA_STRING_STATIC("user1");
if(tok && UA_String_equal(&tok->userName, &user1)) {
counterInc(&activateUserTokens);
if(tok->password.length > 0)
counterInc(&activateTokenPasswords);
}
break;
}
case UA_APPLICATIONNOTIFICATIONTYPE_AUDIT_SECURITY_SESSION_CANCEL:
counterInc(&sessionCancelCalls); break;
case UA_APPLICATIONNOTIFICATIONTYPE_AUDIT_SECURITY_CHANNEL_OPEN:
counterInc(&channelOpenCalls); break;
default:
break;
}
}
static void
globalCb(UA_Server *s, UA_ApplicationNotificationType type,
const UA_KeyValueMap payload) {
(void)s; (void)type; (void)payload;
counterInc(&totalGlobalCalls);
}
THREAD_CALLBACK(serverloop) {
while(UA_atomic_load(&running))
UA_Server_run_iterate(server, true);
return 0;
}
static void resetCounters(void) {
UA_atomic_store(&totalAuditCalls, 0);
UA_atomic_store(&totalGlobalCalls, 0);
UA_atomic_store(&writeAuditCalls, 0);
UA_atomic_store(&methodAuditCalls, 0);
UA_atomic_store(&sessionCreateCalls, 0);
UA_atomic_store(&sessionActivateCalls, 0);
UA_atomic_store(&sessionCancelCalls, 0);
UA_atomic_store(&channelOpenCalls, 0);
UA_atomic_store(&writeSourceNodeMatches, 0);
UA_atomic_store(&activateUserTokens, 0);
UA_atomic_store(&activateTokenPasswords, 0);
UA_atomic_store(&eventUserTokens, 0);
UA_atomic_store(&eventTokenPasswords, 0);
expectedSourceNode = UA_NODEID_NULL;
}
static void setup(void) {
server = UA_Server_newForUnitTest();
ck_assert_ptr_ne(server, NULL);
UA_ServerConfig *cfg = UA_Server_getConfig(server);
cfg->auditingEnabled = true;
cfg->auditWriteUpdateEnabled = true;
cfg->auditMethodUpdateEnabled = true;
cfg->auditNotificationCallback = auditCb;
cfg->globalNotificationCallback = globalCb;
/* Anonymous stays allowed for the other tests; add one username login */
UA_UsernamePasswordLogin login =
{UA_STRING_STATIC("user1"), UA_STRING_STATIC("password")};
UA_AccessControl_default(cfg, true, NULL, 1, &login);
cfg->allowNonePolicyPassword = true;
resetCounters();
UA_Server_run_startup(server);
UA_atomic_store(&running, true);
THREAD_CREATE(server_thread, serverloop);
}
static void teardown(void) {
UA_atomic_store(&running, false);
THREAD_JOIN(server_thread);
UA_Server_run_shutdown(server);
UA_Server_delete(server);
server = NULL;
}
/* Test: writing a value with auditing enabled triggers
* AUDIT_UPDATE_WRITE notification. */
START_TEST(WriteEmitsAuditEvent) {
/* Add a writable variable */
UA_VariableAttributes attr = UA_VariableAttributes_default;
UA_Int32 v = 0;
UA_Variant_setScalar(&attr.value, &v, &UA_TYPES[UA_TYPES_INT32]);
attr.dataType = UA_TYPES[UA_TYPES_INT32].typeId;
attr.accessLevel = UA_ACCESSLEVELMASK_READ | UA_ACCESSLEVELMASK_WRITE;
UA_NodeId id = UA_NODEID_STRING(1, "audit.var");
UA_StatusCode r = UA_Server_addVariableNode(server, id,
UA_NODEID_NUMERIC(0, UA_NS0ID_OBJECTSFOLDER),
UA_NODEID_NUMERIC(0, UA_NS0ID_ORGANIZES),
UA_QUALIFIEDNAME(1, "audit.var"),
UA_NODEID_NUMERIC(0, UA_NS0ID_BASEDATAVARIABLETYPE),
attr, NULL, NULL);
ck_assert_int_eq(r, UA_STATUSCODE_GOOD);
size_t writesBefore = UA_atomic_load(&writeAuditCalls);
size_t globalBefore = UA_atomic_load(&totalGlobalCalls);
UA_Variant newVal;
UA_Int32 nv = 42;
UA_Variant_init(&newVal);
UA_Variant_setScalar(&newVal, &nv, &UA_TYPES[UA_TYPES_INT32]);
r = UA_Server_writeValue(server, id, newVal);
ck_assert_int_eq(r, UA_STATUSCODE_GOOD);
/* Either the dedicated audit callback or the global callback (or both)
* fires for a write update. */
ck_assert(UA_atomic_load(&writeAuditCalls) > writesBefore ||
UA_atomic_load(&totalGlobalCalls) > globalBefore);
} END_TEST
/* Test: the AUDIT_UPDATE_WRITE notification payload carries /SourceNode, the
* NodeId of the written node (the SourceNode of the AuditWriteUpdateEvent). */
START_TEST(WriteAuditPayloadHasSourceNode) {
UA_VariableAttributes attr = UA_VariableAttributes_default;
UA_Int32 v = 0;
UA_Variant_setScalar(&attr.value, &v, &UA_TYPES[UA_TYPES_INT32]);
attr.dataType = UA_TYPES[UA_TYPES_INT32].typeId;
attr.accessLevel = UA_ACCESSLEVELMASK_READ | UA_ACCESSLEVELMASK_WRITE;
UA_NodeId id = UA_NODEID_STRING(1, "audit.var.src");
UA_StatusCode r = UA_Server_addVariableNode(server, id,
UA_NODEID_NUMERIC(0, UA_NS0ID_OBJECTSFOLDER),
UA_NODEID_NUMERIC(0, UA_NS0ID_ORGANIZES),
UA_QUALIFIEDNAME(1, "audit.var.src"),
UA_NODEID_NUMERIC(0, UA_NS0ID_BASEDATAVARIABLETYPE),
attr, NULL, NULL);
ck_assert_int_eq(r, UA_STATUSCODE_GOOD);
expectedSourceNode = id;
size_t matchesBefore = UA_atomic_load(&writeSourceNodeMatches);
UA_Variant newVal;
UA_Int32 nv = 5;
UA_Variant_setScalar(&newVal, &nv, &UA_TYPES[UA_TYPES_INT32]);
r = UA_Server_writeValue(server, id, newVal);
ck_assert_int_eq(r, UA_STATUSCODE_GOOD);
ck_assert_uint_gt(UA_atomic_load(&writeSourceNodeMatches), matchesBefore);
} END_TEST
/* Test: with auditingEnabled=false, no audit event is emitted. */
START_TEST(NoAuditWhenDisabled) {
UA_ServerConfig *cfg = UA_Server_getConfig(server);
cfg->auditingEnabled = false;
UA_VariableAttributes attr = UA_VariableAttributes_default;
UA_Int32 v = 0;
UA_Variant_setScalar(&attr.value, &v, &UA_TYPES[UA_TYPES_INT32]);
attr.dataType = UA_TYPES[UA_TYPES_INT32].typeId;
attr.accessLevel = UA_ACCESSLEVELMASK_READ | UA_ACCESSLEVELMASK_WRITE;
UA_NodeId id = UA_NODEID_STRING(1, "audit.var.off");
UA_StatusCode r = UA_Server_addVariableNode(server, id,
UA_NODEID_NUMERIC(0, UA_NS0ID_OBJECTSFOLDER),
UA_NODEID_NUMERIC(0, UA_NS0ID_ORGANIZES),
UA_QUALIFIEDNAME(1, "audit.var.off"),
UA_NODEID_NUMERIC(0, UA_NS0ID_BASEDATAVARIABLETYPE),
attr, NULL, NULL);
ck_assert_int_eq(r, UA_STATUSCODE_GOOD);
size_t totalBefore = UA_atomic_load(&totalAuditCalls);
size_t globalBefore = UA_atomic_load(&totalGlobalCalls);
UA_Variant newVal;
UA_Int32 nv = 7;
UA_Variant_init(&newVal);
UA_Variant_setScalar(&newVal, &nv, &UA_TYPES[UA_TYPES_INT32]);
r = UA_Server_writeValue(server, id, newVal);
ck_assert_int_eq(r, UA_STATUSCODE_GOOD);
/* No audit callback may fire when auditing is disabled. */
ck_assert_uint_eq(UA_atomic_load(&totalAuditCalls), totalBefore);
ck_assert_uint_eq(UA_atomic_load(&totalGlobalCalls), globalBefore);
/* Restore for teardown */
cfg->auditingEnabled = true;
} END_TEST
/* Test: connecting a client triggers channel-open and session-create/activate
* audit events. */
START_TEST(ClientConnectEmitsSessionAuditEvents) {
UA_Client *client = UA_Client_newForUnitTest();
ck_assert_ptr_ne(client, NULL);
size_t channelBefore = UA_atomic_load(&channelOpenCalls);
size_t createBefore = UA_atomic_load(&sessionCreateCalls);
size_t activateBefore = UA_atomic_load(&sessionActivateCalls);
size_t globalBefore = UA_atomic_load(&totalGlobalCalls);
UA_StatusCode r =
UA_Client_connect(client, "opc.tcp://localhost:4840");
ck_assert_int_eq(r, UA_STATUSCODE_GOOD);
/* Drive the client a bit and let the server thread emit any pending
* audit notifications. We poll up to ~1 second. */
for(int i = 0; i < 100; i++) {
if(UA_atomic_load(&sessionCreateCalls) > createBefore &&
UA_atomic_load(&sessionActivateCalls) > activateBefore)
break;
UA_Client_run_iterate(client, 10);
}
/* SESSION_CREATE and SESSION_ACTIVATE must have fired. CHANNEL_OPEN may
* not fire on a None-security channel; we just check the global cb too. */
ck_assert(UA_atomic_load(&sessionCreateCalls) > createBefore);
ck_assert(UA_atomic_load(&sessionActivateCalls) > activateBefore);
ck_assert(UA_atomic_load(&totalGlobalCalls) > globalBefore);
(void)channelBefore;
UA_Client_disconnect(client);
UA_Client_delete(client);
} END_TEST
/* Test: the ActivateSession audit names the user but omits the password. */
START_TEST(ActivateSessionAuditOmitsPassword) {
#ifdef UA_ENABLE_SUBSCRIPTIONS_EVENTS
/* Also check the generated AuditActivateSessionEventType event */
UA_EventFilter ef;
UA_EventFilter_init(&ef);
ef.selectClauses = (UA_SimpleAttributeOperand*)
UA_Array_new(1, &UA_TYPES[UA_TYPES_SIMPLEATTRIBUTEOPERAND]);
ck_assert_ptr_ne(ef.selectClauses, NULL);
ef.selectClausesSize = 1;
UA_StatusCode res = UA_SimpleAttributeOperand_parse(&ef.selectClauses[0],
UA_STRING("/UserIdentityToken"));
ck_assert_uint_eq(res, UA_STATUSCODE_GOOD);
UA_MonitoredItemCreateResult mon =
UA_Server_createEventMonitoredItem(server, UA_NS0ID(SERVER), ef, NULL,
activateEventCb);
ck_assert_uint_eq(mon.statusCode, UA_STATUSCODE_GOOD);
UA_EventFilter_clear(&ef);
#endif
UA_Client *client = UA_Client_newForUnitTest();
ck_assert_ptr_ne(client, NULL);
UA_StatusCode r = UA_Client_connectUsername(client, "opc.tcp://localhost:4840",
"user1", "password");
ck_assert_int_eq(r, UA_STATUSCODE_GOOD);
for(int i = 0; i < 100 && UA_atomic_load(&activateUserTokens) == 0; i++)
UA_Client_run_iterate(client, 10);
ck_assert_uint_gt(UA_atomic_load(&activateUserTokens), 0);
ck_assert_uint_eq(UA_atomic_load(&activateTokenPasswords), 0);
#ifdef UA_ENABLE_SUBSCRIPTIONS_EVENTS
for(int i = 0; i < 100 && UA_atomic_load(&eventUserTokens) == 0; i++)
UA_Client_run_iterate(client, 10);
ck_assert_uint_gt(UA_atomic_load(&eventUserTokens), 0);
ck_assert_uint_eq(UA_atomic_load(&eventTokenPasswords), 0);
UA_Server_deleteMonitoredItem(server, mon.monitoredItemId);
#endif
UA_Client_disconnect(client);
UA_Client_delete(client);
} END_TEST
/* Test: enabling write-update auditing dynamically and disabling it again
* exercises the conditional code path in UA_Server_writeValue. */
START_TEST(ToggleWriteUpdateFlag) {
UA_ServerConfig *cfg = UA_Server_getConfig(server);
UA_VariableAttributes attr = UA_VariableAttributes_default;
UA_Int32 v = 0;
UA_Variant_setScalar(&attr.value, &v, &UA_TYPES[UA_TYPES_INT32]);
attr.dataType = UA_TYPES[UA_TYPES_INT32].typeId;
attr.accessLevel = UA_ACCESSLEVELMASK_READ | UA_ACCESSLEVELMASK_WRITE;
UA_NodeId id = UA_NODEID_STRING(1, "audit.var.toggle");
UA_StatusCode r = UA_Server_addVariableNode(server, id,
UA_NODEID_NUMERIC(0, UA_NS0ID_OBJECTSFOLDER),
UA_NODEID_NUMERIC(0, UA_NS0ID_ORGANIZES),
UA_QUALIFIEDNAME(1, "audit.var.toggle"),
UA_NODEID_NUMERIC(0, UA_NS0ID_BASEDATAVARIABLETYPE),
attr, NULL, NULL);
ck_assert_int_eq(r, UA_STATUSCODE_GOOD);
/* Disable write-update audit, write should not produce a write audit. */
cfg->auditWriteUpdateEnabled = false;
size_t writesBefore = UA_atomic_load(&writeAuditCalls);
UA_Variant nv; UA_Int32 x = 11;
UA_Variant_init(&nv);
UA_Variant_setScalar(&nv, &x, &UA_TYPES[UA_TYPES_INT32]);
r = UA_Server_writeValue(server, id, nv);
ck_assert_int_eq(r, UA_STATUSCODE_GOOD);
ck_assert_uint_eq(UA_atomic_load(&writeAuditCalls), writesBefore);
/* Re-enable and verify the path is back active. */
cfg->auditWriteUpdateEnabled = true;
x = 12;
UA_Variant_setScalar(&nv, &x, &UA_TYPES[UA_TYPES_INT32]);
r = UA_Server_writeValue(server, id, nv);
ck_assert_int_eq(r, UA_STATUSCODE_GOOD);
} END_TEST
static Suite* testSuite(void) {
Suite *s = suite_create("server auditing");
TCase *tc = tcase_create("basic");
tcase_add_checked_fixture(tc, setup, teardown);
tcase_set_timeout(tc, 60);
tcase_add_test(tc, WriteEmitsAuditEvent);
tcase_add_test(tc, WriteAuditPayloadHasSourceNode);
tcase_add_test(tc, NoAuditWhenDisabled);
tcase_add_test(tc, ClientConnectEmitsSessionAuditEvents);
tcase_add_test(tc, ActivateSessionAuditOmitsPassword);
tcase_add_test(tc, ToggleWriteUpdateFlag);
suite_add_tcase(s, tc);
return s;
}
int main(void) {
SRunner *sr = srunner_create(testSuite());
srunner_set_fork_status(sr, CK_NOFORK);
srunner_run_all(sr, CK_NORMAL);
int failed = srunner_ntests_failed(sr);
srunner_free(sr);
return (failed == 0) ? EXIT_SUCCESS : EXIT_FAILURE;
}
#else /* Auditing disabled or no thread-safe server support */
int main(void) { return EXIT_SUCCESS; }
#endif