mirror of
https://github.com/OPCFoundation/UA-.NETStandard.git
synced 2026-10-29 07:07:24 +08:00
Fix three high-severity audit findings in new 2.0 pub sub/GDS code (#3954)
# Description Fixes three high-severity, remotely-relevant security weaknesses identified by a full-codebase security audit. Each fix is minimal and surgical, and ships with regression tests. ### 1. PubSub inbound message-security fail-open for non-UADP (JSON) frames `PubSubConnection` nested the inbound signature/encryption/replay enforcement **inside** the `UadpDecoder.TryReadOuterPrefix(...)` branch. Because that check returns `false` for a JSON frame, a reader configured for `Sign`/`SignAndEncrypt` over a JSON transport silently accepted attacker-forged, unauthenticated frames, delivering forged DataSet/MetaData messages to application sinks. The send path already fail-*closes* the identical case. **Fix:** drop any non-UADP frame fail-closed on a secured reader before decode (`else if (RequiresInboundSecurity)`), mirroring the send-side refusal. ### 2. Client schema-import SSRF / NetNTLM credential leak When loading complex types, the client falls back to the server's type dictionary and resolves each `Import`/`SchemaLocation` against the local file system with no sanitization. A malicious server could supply a UNC path (e.g. `\\attacker\share\x`), triggering an outbound SMB/NetNTLM handshake (hash theft / SSRF) or arbitrary local file read. This bypassed the existing XXE hardening because imports are followed by manual file-system code, not the XML resolver. **Fix:** added a deny-all `NullFileSystem` and wired it into the in-memory/import-table schema validator constructors, so network-supplied dictionaries never touch the local file system. The XML loader now prefers the in-memory import table before any (untrusted) location, so legitimate in-memory resolution is unchanged. ### 3. GDS AuthorizationService JWT impersonation / privilege escalation `FinishRequestToken` derived the JWT subject from the **unauthenticated** client-supplied `userIdentityToken` (never verifying `userTokenSignature`) and embedded the client's `requestedRoles` verbatim. Any caller able to open an encrypted channel — even anonymous — could obtain a GDS-signed token asserting `sub=admin`, `roles=[SecurityAdmin, ...]`, impersonating any principal to resource servers that trust GDS-issued JWTs. **Fix:** - The token subject is now bound to the identity that was **authenticated on the session** when the request was started; the client token is no longer trusted for the subject. - Requested roles are gated by a new **fail-closed** `AuthorizationServiceOptions.AuthorizeRoles` hook. With no callback configured, **no roles** are granted; the callback can only ever narrow the requested set. This removes privilege escalation via arbitrary role requests. - The obsolete single-call `RequestAccessToken` path no longer trusts the client-supplied token: it issues an unprivileged **anonymous**-subject token with no roles. Use `StartRequestToken`/`FinishRequestToken` for authenticated identity binding. ## Related Issues No public issue is linked — these findings were identified by an internal security audit and fixed directly. Maintainers may open tracking issues if desired. ## Verification - All affected projects build with **0 warnings / 0 errors** (net10.0). - Regression tests added and passing: - PubSub: `SecuredReaderRejectsNonUadpJsonFrameAsync` (+ existing receive-security suite, 6/6). - Schema/SSRF: `NullFileSystemTests` (6/6), `ImportTableValidatorDoesNotResolveServerSuppliedFilesystemLocation` (BSD, 3/3), source-gen schema validation (6 passed), full ComplexTypes client-load suite (2214/2214). - GDS: `FinishDoesNotGrantRequestedRolesWithoutAuthorization`, `FinishBindsSubjectToAuthenticatedIdentityNotRequestToken`, plus `LegacyRequestAccessTokenIssuesAnonymousUnprivilegedToken` and `AuthorizeRoles` intersection/empty edge cases (AuthorizationService unit 15/15, RefreshToken integration 3/3). _Review feedback from the automated reviewer has been addressed (NullFileSystem singleton constructor + header year, PubSub drop logged at Debug to avoid log amplification, and the obsolete `RequestAccessToken` path simplified to a uniform anonymous subject); all review threads resolved._
This commit is contained in:
@@ -89,5 +89,22 @@ namespace Opc.Ua.Gds.Server.Hosting
|
||||
/// audience and requested scopes.
|
||||
/// </summary>
|
||||
public Func<IUserIdentity?, string, IReadOnlyList<string>, bool>? AccessControl { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets an optional authorization callback that decides which of
|
||||
/// the roles requested during FinishRequestToken may be embedded into
|
||||
/// the issued token. It receives the identity that was authenticated on
|
||||
/// the session when the request was started, the audience and the
|
||||
/// requested roles, and returns the subset of roles to grant.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// When this callback is not set no roles are granted (fail-closed).
|
||||
/// A caller can therefore never obtain a token asserting privileged
|
||||
/// roles (such as SecurityAdmin) that the operator has not explicitly
|
||||
/// authorized, which prevents privilege escalation through arbitrary
|
||||
/// role requests. The returned roles are always intersected with the
|
||||
/// requested roles, so the callback can only ever narrow the set.
|
||||
/// </remarks>
|
||||
public Func<IUserIdentity?, string, IReadOnlyList<string>, IReadOnlyList<string>>? AuthorizeRoles { get; set; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -89,15 +89,33 @@ namespace Opc.Ua.Gds.Server.Identity
|
||||
|
||||
/// <inheritdoc/>
|
||||
[Obsolete("Use StartRequestTokenAsync + FinishRequestTokenAsync for Part 12 v1.05 compliance.")]
|
||||
public async ValueTask<string> RequestAccessTokenAsync(
|
||||
public ValueTask<string> RequestAccessTokenAsync(
|
||||
UserIdentityToken identityToken,
|
||||
string resourceId,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
return RequestAccessTokenAsync(identityToken, resourceId, null, ct);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Issues a token whose subject is bound to the identity the server
|
||||
/// authenticated on the calling session rather than to the
|
||||
/// (unauthenticated) request token.
|
||||
/// </summary>
|
||||
[Obsolete("Use StartRequestTokenAsync + FinishRequestTokenAsync for Part 12 v1.05 compliance.")]
|
||||
public async ValueTask<string> RequestAccessTokenAsync(
|
||||
UserIdentityToken identityToken,
|
||||
string resourceId,
|
||||
IUserIdentity? callerIdentity,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
ValidateAudience(resourceId);
|
||||
string[] scopes = NormalizeScopes(m_options.DefaultScopes);
|
||||
// The subject is taken from the identity that the server
|
||||
// authenticated on the session, never from the client-supplied
|
||||
// request token, which carries no verified proof-of-possession.
|
||||
AccessToken token = await IssueAsync(
|
||||
GetSubject(identityToken, null),
|
||||
ResolveSubject(callerIdentity),
|
||||
resourceId,
|
||||
scopes,
|
||||
[],
|
||||
@@ -142,7 +160,8 @@ namespace Opc.Ua.Gds.Server.Identity
|
||||
resourceId,
|
||||
policyId ?? string.Empty,
|
||||
scopes,
|
||||
callerIdentity?.DisplayName ?? string.Empty,
|
||||
ResolveSubject(callerIdentity),
|
||||
callerIdentity,
|
||||
DateTime.UtcNow.AddMinutes(5));
|
||||
|
||||
return new ValueTask<(ByteString serviceData, Guid requestId)>((ByteString.Empty, requestId));
|
||||
@@ -171,10 +190,20 @@ namespace Opc.Ua.Gds.Server.Identity
|
||||
"AuthorizationService token request expired.");
|
||||
}
|
||||
|
||||
string[] roles = requestedRoles.IsEmpty
|
||||
? []
|
||||
: [.. requestedRoles.ToArray()!.Where(role => !string.IsNullOrWhiteSpace(role))];
|
||||
string subject = GetSubject(userIdentityToken, request.Subject);
|
||||
// Bind the subject to the identity that was authenticated on the
|
||||
// session when the request was started. The client-supplied
|
||||
// userIdentityToken (and its userTokenSignature) are NOT trusted
|
||||
// here: they carry no verified proof-of-possession, so honoring
|
||||
// them would let any caller mint a token impersonating an
|
||||
// arbitrary principal.
|
||||
string subject = request.Subject;
|
||||
|
||||
// Authorize the requested roles against the authenticated caller.
|
||||
// Without an explicit authorization callback no roles are granted
|
||||
// (fail-closed), which prevents a caller from escalating privileges
|
||||
// by requesting arbitrary roles such as SecurityAdmin.
|
||||
string[] roles = AuthorizeRoles(request, requestedRoles);
|
||||
|
||||
AccessToken token = await IssueAsync(
|
||||
subject,
|
||||
request.ResourceId,
|
||||
@@ -351,25 +380,50 @@ namespace Opc.Ua.Gds.Server.Identity
|
||||
}
|
||||
}
|
||||
|
||||
private static string GetSubject(UserIdentityToken? identityToken, string? fallback)
|
||||
/// <summary>
|
||||
/// Resolves the JWT subject from the identity the server authenticated
|
||||
/// on the calling session. The client-supplied request token is never
|
||||
/// used, so a caller cannot choose an arbitrary subject.
|
||||
/// </summary>
|
||||
private static string ResolveSubject(IUserIdentity? callerIdentity)
|
||||
{
|
||||
if (identityToken is UserNameIdentityToken userName && !string.IsNullOrEmpty(userName.UserName))
|
||||
string? displayName = callerIdentity?.DisplayName;
|
||||
return string.IsNullOrEmpty(displayName) ? "anonymous" : displayName!;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Determines which of the requested roles may be embedded into the
|
||||
/// issued token for the authenticated caller. Fail-closed: no roles are
|
||||
/// granted unless an authorization callback approves them, and the
|
||||
/// approved set is always intersected with the requested roles so the
|
||||
/// callback can never introduce roles the caller did not request.
|
||||
/// </summary>
|
||||
private string[] AuthorizeRoles(RequestRecord request, ArrayOf<string> requestedRoles)
|
||||
{
|
||||
if (requestedRoles.IsEmpty || m_options.AuthorizeRoles == null)
|
||||
{
|
||||
return userName.UserName;
|
||||
return [];
|
||||
}
|
||||
if (identityToken is X509IdentityToken x509 && x509.CertificateData.Length != 0)
|
||||
|
||||
string[] requested = [.. requestedRoles.ToArray()!
|
||||
.Where(role => !string.IsNullOrWhiteSpace(role))
|
||||
.Distinct(StringComparer.Ordinal)];
|
||||
if (requested.Length == 0)
|
||||
{
|
||||
return Convert.ToBase64String(x509.CertificateData.ToArray());
|
||||
return [];
|
||||
}
|
||||
if (!string.IsNullOrEmpty(fallback))
|
||||
|
||||
IReadOnlyList<string> granted = m_options.AuthorizeRoles(
|
||||
request.Caller, request.ResourceId, requested);
|
||||
if (granted == null || granted.Count == 0)
|
||||
{
|
||||
return fallback!;
|
||||
return [];
|
||||
}
|
||||
if (!string.IsNullOrEmpty(identityToken?.PolicyId))
|
||||
{
|
||||
return identityToken!.PolicyId!;
|
||||
}
|
||||
return "anonymous";
|
||||
|
||||
var requestedSet = new HashSet<string>(requested, StringComparer.Ordinal);
|
||||
return [.. granted
|
||||
.Where(role => !string.IsNullOrWhiteSpace(role) && requestedSet.Contains(role))
|
||||
.Distinct(StringComparer.Ordinal)];
|
||||
}
|
||||
|
||||
private static string[] DecodeScopes(ByteString requestorData)
|
||||
@@ -420,6 +474,7 @@ namespace Opc.Ua.Gds.Server.Identity
|
||||
string PolicyId,
|
||||
string[] Scopes,
|
||||
string Subject,
|
||||
IUserIdentity? Caller,
|
||||
DateTime ExpiresAtUtc);
|
||||
|
||||
private sealed record IssuedTokenRecord(
|
||||
|
||||
@@ -894,6 +894,33 @@ namespace Opc.Ua.PubSub.Connections
|
||||
framePayload = unwrapped.Value;
|
||||
}
|
||||
}
|
||||
else if (RequiresInboundSecurity)
|
||||
{
|
||||
// Fail-closed: the frame is not a UADP NetworkMessage
|
||||
// (e.g. a JSON frame, for which no message-security
|
||||
// wrapper exists), so it cannot be authenticated or
|
||||
// decrypted to satisfy the reader's configured
|
||||
// SecurityMode. Dropping it here prevents an attacker
|
||||
// from delivering forged, unsecured non-UADP frames to
|
||||
// application sinks on a secured reader. Mirrors the
|
||||
// send-side refusal to publish unsecured non-UADP
|
||||
// messages on a connection configured for security.
|
||||
RecordSecurityFailure(
|
||||
StatusCodes.BadSecurityModeRejected,
|
||||
"Inbound non-UADP frame cannot satisfy the reader's "
|
||||
+ "configured SecurityMode.");
|
||||
// Logged at Debug: this is a per-frame hot path and a
|
||||
// flood of non-UADP frames (hostile or misconfigured)
|
||||
// must not amplify into high-volume warning logs. The
|
||||
// security-failure counter incremented above is the
|
||||
// rate-safe operational signal.
|
||||
m_logger.LogDebug(
|
||||
"Dropping non-UADP inbound frame on connection "
|
||||
+ "'{Connection}' requiring {Mode}.",
|
||||
Name,
|
||||
m_requiredSecurityMode);
|
||||
continue;
|
||||
}
|
||||
|
||||
PubSubNetworkMessage? message;
|
||||
try
|
||||
|
||||
@@ -59,7 +59,7 @@ namespace Opc.Ua.Schema.Binary
|
||||
/// Intializes the object with a import table.
|
||||
/// </summary>
|
||||
public BinarySchemaValidator(IReadOnlyDictionary<string, byte[]> importTable)
|
||||
: base(null, null, AndStandardTypeImports(importTable))
|
||||
: base(NullFileSystem.Instance, null, AndStandardTypeImports(importTable))
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
@@ -178,22 +178,28 @@ namespace Opc.Ua.Schema
|
||||
/// <exception cref="FileNotFoundException"></exception>
|
||||
protected XmlSchema Load(string? path, string? namespaceUri, ValidationEventHandler handler)
|
||||
{
|
||||
// check if path specified in the file table.
|
||||
if (namespaceUri != null)
|
||||
{
|
||||
// check if path specified in the file table.
|
||||
// An explicit namespace-to-location mapping (trusted
|
||||
// configuration) takes precedence and overrides any
|
||||
// supplied location.
|
||||
if (m_namespaceUriToLocationMapping.TryGetValue(namespaceUri, out string? location))
|
||||
{
|
||||
path = location;
|
||||
}
|
||||
// Otherwise prefer an in-memory import for the namespace over a
|
||||
// (potentially untrusted) schema location before falling back
|
||||
// to the file system. This prevents a peer-supplied import
|
||||
// location from being resolved against the local file system
|
||||
// when the namespace can be satisfied from memory.
|
||||
else if (m_importFiles.TryGetValue(namespaceUri, out byte[]? schemaBuffer))
|
||||
{
|
||||
using var istrm = new MemoryStream(schemaBuffer);
|
||||
return Load(istrm, handler);
|
||||
}
|
||||
|
||||
if (path == null)
|
||||
{
|
||||
if (m_importFiles.TryGetValue(namespaceUri, out byte[]? schemaBuffer))
|
||||
{
|
||||
using var istrm = new MemoryStream(schemaBuffer);
|
||||
return Load(istrm, handler);
|
||||
}
|
||||
throw new FileNotFoundException(CoreUtils.Format(
|
||||
"Missing schema location for namespace {0}", namespaceUri));
|
||||
}
|
||||
|
||||
@@ -58,7 +58,7 @@ namespace Opc.Ua.Schema.Xml
|
||||
/// Intializes the object with a import table.
|
||||
/// </summary>
|
||||
public XmlSchemaValidator(IReadOnlyDictionary<string, byte[]> importTable)
|
||||
: base(null, null, importTable)
|
||||
: base(NullFileSystem.Instance, null, importTable)
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
/* ========================================================================
|
||||
* Copyright (c) 2005-2026 The OPC Foundation, Inc. All rights reserved.
|
||||
*
|
||||
* OPC Foundation MIT License 1.00
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person
|
||||
* obtaining a copy of this software and associated documentation
|
||||
* files (the "Software"), to deal in the Software without
|
||||
* restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
* copies of the Software, and to permit persons to whom the
|
||||
* Software is furnished to do so, subject to the following
|
||||
* conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be
|
||||
* included in all copies or substantial portions of the Software.
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
* EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
|
||||
* OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
||||
* NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
|
||||
* HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
* WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
* FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
|
||||
* OTHER DEALINGS IN THE SOFTWARE.
|
||||
*
|
||||
* The complete license agreement can be found here:
|
||||
* http://opcfoundation.org/License/MIT/1.00/
|
||||
* ======================================================================*/
|
||||
|
||||
using System.IO;
|
||||
|
||||
namespace Opc.Ua
|
||||
{
|
||||
/// <summary>
|
||||
/// A deny-all file system that never resolves any path. It is used when a
|
||||
/// component must load resources exclusively from an in-memory table and
|
||||
/// must never touch the local file system - for example when validating a
|
||||
/// type dictionary received from an untrusted network peer, where an
|
||||
/// attacker-supplied import location (such as a UNC path) would otherwise
|
||||
/// trigger an outbound SMB connection or arbitrary local file access.
|
||||
/// </summary>
|
||||
public sealed class NullFileSystem : IFileSystem
|
||||
{
|
||||
/// <summary>
|
||||
/// Get singleton instance
|
||||
/// </summary>
|
||||
public static readonly NullFileSystem Instance = new();
|
||||
|
||||
private NullFileSystem()
|
||||
{
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public bool Exists(string path, bool isDirectory)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public void Delete(string path, bool isDirectory)
|
||||
{
|
||||
throw new FileNotFoundException(
|
||||
"The null file system does not provide access to any path.", path);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public Stream OpenRead(string path)
|
||||
{
|
||||
throw new FileNotFoundException(
|
||||
"The null file system does not provide access to any path.", path);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public Stream OpenWrite(string path)
|
||||
{
|
||||
throw new FileNotFoundException(
|
||||
"The null file system does not provide access to any path.", path);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public System.DateTime GetLastWriteTime(string path)
|
||||
{
|
||||
throw new FileNotFoundException(
|
||||
"The null file system does not provide access to any path.", path);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public long GetLength(string path)
|
||||
{
|
||||
throw new FileNotFoundException(
|
||||
"The null file system does not provide access to any path.", path);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -27,9 +27,13 @@
|
||||
* http://opcfoundation.org/License/MIT/1.00/
|
||||
* ======================================================================*/
|
||||
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using System.Xml.Linq;
|
||||
using NUnit.Framework;
|
||||
using Opc.Ua.Schema.Binary;
|
||||
using Opc.Ua.Schema.Bsd;
|
||||
|
||||
namespace Opc.Ua.Schema.Tests
|
||||
@@ -126,6 +130,50 @@ namespace Opc.Ua.Schema.Tests
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void ImportTableValidatorDoesNotResolveServerSuppliedFilesystemLocation()
|
||||
{
|
||||
// Security regression: a type dictionary received over the network is
|
||||
// validated with the in-memory import-table constructor. A malicious
|
||||
// server can place an arbitrary file-system path (for example a UNC
|
||||
// path that triggers an SMB/NetNTLM handshake) into an <Import>
|
||||
// Location. That location must never be resolved against the local
|
||||
// file system, even when the referenced file actually exists.
|
||||
string evilPath = Path.GetTempFileName();
|
||||
try
|
||||
{
|
||||
File.WriteAllText(
|
||||
evilPath,
|
||||
"<opc:TypeDictionary xmlns:opc=\"http://opcfoundation.org/BinarySchema/\" "
|
||||
+ "TargetNamespace=\"urn:test:evil\">"
|
||||
+ "<opc:StructuredType Name=\"Evil\">"
|
||||
+ "<opc:Field Name=\"Y\" TypeName=\"opc:Int32\" /></opc:StructuredType>"
|
||||
+ "</opc:TypeDictionary>");
|
||||
|
||||
string mainSchema =
|
||||
"<opc:TypeDictionary xmlns:opc=\"http://opcfoundation.org/BinarySchema/\" "
|
||||
+ "TargetNamespace=\"urn:test:main\">"
|
||||
+ "<opc:Import Namespace=\"urn:test:evil\" Location=\""
|
||||
+ System.Security.SecurityElement.Escape(evilPath) + "\" />"
|
||||
+ "<opc:StructuredType Name=\"Main\">"
|
||||
+ "<opc:Field Name=\"X\" TypeName=\"opc:Int32\" /></opc:StructuredType>"
|
||||
+ "</opc:TypeDictionary>";
|
||||
|
||||
var validator = new BinarySchemaValidator(new Dictionary<string, byte[]>());
|
||||
using var stream = new MemoryStream(Encoding.UTF8.GetBytes(mainSchema));
|
||||
|
||||
// The import cannot be resolved because the file system is never
|
||||
// consulted; validation fails closed instead of reading the file.
|
||||
Assert.That(
|
||||
() => validator.Validate(stream),
|
||||
Throws.InstanceOf<FileNotFoundException>());
|
||||
}
|
||||
finally
|
||||
{
|
||||
File.Delete(evilPath);
|
||||
}
|
||||
}
|
||||
|
||||
private static DefaultSchemaProvider CreateProvider(params UaTypeDescription[] types)
|
||||
{
|
||||
var registry = new DataTypeDefinitionRegistry();
|
||||
|
||||
+6
-1
@@ -241,11 +241,16 @@ namespace Opc.Ua.Gds.Tests.AuthorizationService
|
||||
|
||||
private static AuthorizationServiceOptions CreateOptions(Certificate certificate)
|
||||
{
|
||||
return new AuthorizationServiceOptions
|
||||
var options = new AuthorizationServiceOptions
|
||||
{
|
||||
IssuerUri = Issuer,
|
||||
SigningCertificate = new CertificateIdentifier { Thumbprint = certificate.Thumbprint }
|
||||
};
|
||||
// These tests exercise refresh mechanics, so the operator authorizes
|
||||
// the requested roles; the fail-closed default (no roles) is covered
|
||||
// by StartRequestTokenTests.
|
||||
options.AuthorizeRoles = (identity, audience, requestedRoles) => requestedRoles;
|
||||
return options;
|
||||
}
|
||||
|
||||
private static InMemoryAccessTokenProvider CreateProvider(
|
||||
|
||||
@@ -53,6 +53,8 @@ namespace Opc.Ua.Gds.Tests.AuthorizationService
|
||||
private const string Issuer = "urn:opcua:test:gds";
|
||||
private const string Audience = "urn:opcua:test:server";
|
||||
private static readonly string[] s_requestedRoles = ["operator"];
|
||||
private static readonly string[] s_securityAdminRole = ["SecurityAdmin"];
|
||||
private static readonly string[] s_operatorAndAdminRoles = ["operator", "SecurityAdmin"];
|
||||
|
||||
[Test]
|
||||
public async Task StartThenFinishIssuesJwtAcceptedByJwtAuthenticator()
|
||||
@@ -69,6 +71,9 @@ namespace Opc.Ua.Gds.Tests.AuthorizationService
|
||||
};
|
||||
options.AllowedAudiences.Add(Audience);
|
||||
options.DefaultScopes.Add("read");
|
||||
// The operator explicitly authorizes the requested roles for the
|
||||
// authenticated caller; without this callback no roles are granted.
|
||||
options.AuthorizeRoles = (identity, audience, requestedRoles) => requestedRoles;
|
||||
|
||||
var issuer = new CertificateJwtIssuer(options, certificateProvider, NUnitTelemetryContext.Create());
|
||||
var provider = new InMemoryAccessTokenProvider(issuer, options);
|
||||
@@ -113,6 +118,236 @@ namespace Opc.Ua.Gds.Tests.AuthorizationService
|
||||
Assert.That(claims.Roles, Does.Contain("operator"));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task FinishDoesNotGrantRequestedRolesWithoutAuthorization()
|
||||
{
|
||||
using Certificate certificate = CertificateBuilder
|
||||
.Create("CN=GDS JWT Signing, O=OPC Foundation")
|
||||
.SetECCurve(ECCurve.NamedCurves.nistP256)
|
||||
.CreateForECDsa();
|
||||
using var certificateProvider = new InProcessCertificateProvider(certificate);
|
||||
var options = new AuthorizationServiceOptions
|
||||
{
|
||||
IssuerUri = Issuer,
|
||||
SigningCertificate = new CertificateIdentifier { Thumbprint = certificate.Thumbprint }
|
||||
};
|
||||
options.AllowedAudiences.Add(Audience);
|
||||
options.DefaultScopes.Add("read");
|
||||
// No AuthorizeRoles callback is configured: the provider must be
|
||||
// fail-closed and grant no roles at all.
|
||||
|
||||
var issuer = new CertificateJwtIssuer(options, certificateProvider, NUnitTelemetryContext.Create());
|
||||
var provider = new InMemoryAccessTokenProvider(issuer, options);
|
||||
var manager = new AuthorizationServiceManager(provider, issuer, options);
|
||||
|
||||
(_, Guid requestId) = await manager
|
||||
.StartRequestTokenAsync(
|
||||
Audience,
|
||||
"jwt",
|
||||
ByteString.From(Encoding.UTF8.GetBytes("read")),
|
||||
new UserIdentity("operator", []))
|
||||
.ConfigureAwait(false);
|
||||
|
||||
AccessTokenResult tokenResult = await manager
|
||||
.FinishRequestTokenAsync(
|
||||
requestId,
|
||||
s_securityAdminRole.ToArrayOf(),
|
||||
new UserNameIdentityToken { UserName = "operator" },
|
||||
new SignatureData())
|
||||
.ConfigureAwait(false);
|
||||
|
||||
IIdentityClaims claims = await AuthenticateAsync(certificate, tokenResult.AccessToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
Assert.That(claims.Subject, Is.EqualTo("operator"));
|
||||
Assert.That(claims.Roles, Is.Empty,
|
||||
"A requested role must never be granted unless the operator explicitly authorizes it.");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task FinishBindsSubjectToAuthenticatedIdentityNotRequestToken()
|
||||
{
|
||||
using Certificate certificate = CertificateBuilder
|
||||
.Create("CN=GDS JWT Signing, O=OPC Foundation")
|
||||
.SetECCurve(ECCurve.NamedCurves.nistP256)
|
||||
.CreateForECDsa();
|
||||
using var certificateProvider = new InProcessCertificateProvider(certificate);
|
||||
var options = new AuthorizationServiceOptions
|
||||
{
|
||||
IssuerUri = Issuer,
|
||||
SigningCertificate = new CertificateIdentifier { Thumbprint = certificate.Thumbprint }
|
||||
};
|
||||
options.AllowedAudiences.Add(Audience);
|
||||
options.DefaultScopes.Add("read");
|
||||
|
||||
var issuer = new CertificateJwtIssuer(options, certificateProvider, NUnitTelemetryContext.Create());
|
||||
var provider = new InMemoryAccessTokenProvider(issuer, options);
|
||||
var manager = new AuthorizationServiceManager(provider, issuer, options);
|
||||
|
||||
(_, Guid requestId) = await manager
|
||||
.StartRequestTokenAsync(
|
||||
Audience,
|
||||
"jwt",
|
||||
ByteString.From(Encoding.UTF8.GetBytes("read")),
|
||||
new UserIdentity("authenticated-user", []))
|
||||
.ConfigureAwait(false);
|
||||
|
||||
// The client attempts to spoof a privileged subject through the
|
||||
// request token; the issued token must ignore it.
|
||||
AccessTokenResult tokenResult = await manager
|
||||
.FinishRequestTokenAsync(
|
||||
requestId,
|
||||
Array.Empty<string>().ToArrayOf(),
|
||||
new UserNameIdentityToken { UserName = "admin" },
|
||||
new SignatureData())
|
||||
.ConfigureAwait(false);
|
||||
|
||||
IIdentityClaims claims = await AuthenticateAsync(certificate, tokenResult.AccessToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
Assert.That(claims.Subject, Is.EqualTo("authenticated-user"),
|
||||
"The subject must be bound to the identity authenticated on the session, "
|
||||
+ "not to the client-supplied request token.");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task LegacyRequestAccessTokenIssuesAnonymousUnprivilegedToken()
|
||||
{
|
||||
using Certificate certificate = CreateSigningCertificate();
|
||||
using var certificateProvider = new InProcessCertificateProvider(certificate);
|
||||
var options = new AuthorizationServiceOptions
|
||||
{
|
||||
IssuerUri = Issuer,
|
||||
SigningCertificate = new CertificateIdentifier { Thumbprint = certificate.Thumbprint }
|
||||
};
|
||||
options.AllowedAudiences.Add(Audience);
|
||||
options.DefaultScopes.Add("read");
|
||||
|
||||
var issuer = new CertificateJwtIssuer(options, certificateProvider, NUnitTelemetryContext.Create());
|
||||
var provider = new InMemoryAccessTokenProvider(issuer, options);
|
||||
|
||||
#pragma warning disable CS0618 // exercising the obsolete single-call wire method on purpose
|
||||
string jwt = await provider
|
||||
.RequestAccessTokenAsync(new UserNameIdentityToken { UserName = "admin" }, Audience)
|
||||
.ConfigureAwait(false);
|
||||
#pragma warning restore CS0618
|
||||
|
||||
IIdentityClaims claims = await AuthenticateAsync(certificate, jwt).ConfigureAwait(false);
|
||||
Assert.That(claims.Subject, Is.EqualTo("anonymous"),
|
||||
"The obsolete single-call RequestAccessToken must not trust the client-supplied "
|
||||
+ "token for the subject.");
|
||||
Assert.That(claims.Roles, Is.Empty,
|
||||
"The obsolete single-call RequestAccessToken must not grant roles.");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task FinishGrantsOnlyTheIntersectionOfAuthorizedAndRequestedRoles()
|
||||
{
|
||||
using Certificate certificate = CreateSigningCertificate();
|
||||
using var certificateProvider = new InProcessCertificateProvider(certificate);
|
||||
var options = new AuthorizationServiceOptions
|
||||
{
|
||||
IssuerUri = Issuer,
|
||||
SigningCertificate = new CertificateIdentifier { Thumbprint = certificate.Thumbprint }
|
||||
};
|
||||
options.AllowedAudiences.Add(Audience);
|
||||
options.DefaultScopes.Add("read");
|
||||
// The callback tries to grant an extra, unrequested role; it must be
|
||||
// dropped because the granted set is intersected with the request.
|
||||
options.AuthorizeRoles = (identity, audience, requestedRoles) => s_operatorAndAdminRoles;
|
||||
|
||||
var issuer = new CertificateJwtIssuer(options, certificateProvider, NUnitTelemetryContext.Create());
|
||||
var provider = new InMemoryAccessTokenProvider(issuer, options);
|
||||
var manager = new AuthorizationServiceManager(provider, issuer, options);
|
||||
|
||||
(_, Guid requestId) = await manager
|
||||
.StartRequestTokenAsync(
|
||||
Audience,
|
||||
"jwt",
|
||||
ByteString.From(Encoding.UTF8.GetBytes("read")),
|
||||
new UserIdentity("operator", []))
|
||||
.ConfigureAwait(false);
|
||||
|
||||
AccessTokenResult tokenResult = await manager
|
||||
.FinishRequestTokenAsync(
|
||||
requestId,
|
||||
s_requestedRoles.ToArrayOf(),
|
||||
new UserNameIdentityToken { UserName = "operator" },
|
||||
new SignatureData())
|
||||
.ConfigureAwait(false);
|
||||
|
||||
IIdentityClaims claims = await AuthenticateAsync(certificate, tokenResult.AccessToken)
|
||||
.ConfigureAwait(false);
|
||||
Assert.That(claims.Roles, Does.Contain("operator"));
|
||||
Assert.That(claims.Roles, Does.Not.Contain("SecurityAdmin"),
|
||||
"A role the caller did not request must never be granted, even if the "
|
||||
+ "authorization callback returns it.");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task FinishGrantsNoRolesWhenAuthorizationReturnsEmpty()
|
||||
{
|
||||
using Certificate certificate = CreateSigningCertificate();
|
||||
using var certificateProvider = new InProcessCertificateProvider(certificate);
|
||||
var options = new AuthorizationServiceOptions
|
||||
{
|
||||
IssuerUri = Issuer,
|
||||
SigningCertificate = new CertificateIdentifier { Thumbprint = certificate.Thumbprint }
|
||||
};
|
||||
options.AllowedAudiences.Add(Audience);
|
||||
options.DefaultScopes.Add("read");
|
||||
options.AuthorizeRoles = (identity, audience, requestedRoles) => [];
|
||||
|
||||
var issuer = new CertificateJwtIssuer(options, certificateProvider, NUnitTelemetryContext.Create());
|
||||
var provider = new InMemoryAccessTokenProvider(issuer, options);
|
||||
var manager = new AuthorizationServiceManager(provider, issuer, options);
|
||||
|
||||
(_, Guid requestId) = await manager
|
||||
.StartRequestTokenAsync(
|
||||
Audience,
|
||||
"jwt",
|
||||
ByteString.From(Encoding.UTF8.GetBytes("read")),
|
||||
new UserIdentity("operator", []))
|
||||
.ConfigureAwait(false);
|
||||
|
||||
AccessTokenResult tokenResult = await manager
|
||||
.FinishRequestTokenAsync(
|
||||
requestId,
|
||||
s_requestedRoles.ToArrayOf(),
|
||||
new UserNameIdentityToken { UserName = "operator" },
|
||||
new SignatureData())
|
||||
.ConfigureAwait(false);
|
||||
|
||||
IIdentityClaims claims = await AuthenticateAsync(certificate, tokenResult.AccessToken)
|
||||
.ConfigureAwait(false);
|
||||
Assert.That(claims.Roles, Is.Empty,
|
||||
"An authorization callback that returns no roles must yield a token with no roles.");
|
||||
}
|
||||
|
||||
private static Certificate CreateSigningCertificate()
|
||||
{
|
||||
return CertificateBuilder
|
||||
.Create("CN=GDS JWT Signing, O=OPC Foundation")
|
||||
.SetECCurve(ECCurve.NamedCurves.nistP256)
|
||||
.CreateForECDsa();
|
||||
}
|
||||
|
||||
private static async Task<IIdentityClaims> AuthenticateAsync(Certificate certificate, string jwt)
|
||||
{
|
||||
ECDsa verifier = certificate.GetECDsaPublicKey();
|
||||
using var resolver = new StaticIssuerKeyResolver(
|
||||
Issuer,
|
||||
[new IssuerVerificationKey(certificate.Thumbprint, verifier, "ES256")]);
|
||||
AuthenticationResult result = await new JwtAuthenticator(resolver, Audience, TimeSpan.Zero)
|
||||
.AuthenticateAsync(CreateContext(jwt))
|
||||
.ConfigureAwait(false);
|
||||
|
||||
Assert.That(result.Outcome, Is.EqualTo(AuthenticationOutcome.Accepted));
|
||||
var claims = result.Identity as IIdentityClaims;
|
||||
Assert.That(claims, Is.Not.Null);
|
||||
return claims!;
|
||||
}
|
||||
|
||||
private static AuthenticationContext CreateContext(string jwt)
|
||||
{
|
||||
return new AuthenticationContext(
|
||||
|
||||
@@ -87,6 +87,36 @@ namespace Opc.Ua.PubSub.Tests.Connections
|
||||
"Forged plaintext frame must be dropped before decode.");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task SecuredReaderRejectsNonUadpJsonFrameAsync()
|
||||
{
|
||||
// A reader configured for message security must drop a non-UADP
|
||||
// (JSON) frame: there is no JSON message-security wrapper, so the
|
||||
// frame cannot be authenticated and must never reach the decoder.
|
||||
// Before the fix the inbound security gate lived inside the UADP
|
||||
// branch, so a JSON frame bypassed it entirely and was delivered
|
||||
// to application sinks unauthenticated.
|
||||
(UadpSecurityWrapper _, UadpSecurityWrapper subscriber) =
|
||||
CreateMatchingWrapperPair(tokenId: 1U);
|
||||
|
||||
byte[] json = System.Text.Encoding.UTF8.GetBytes(
|
||||
"{\"MessageId\":\"forged\",\"MessageType\":\"ua-data\"}");
|
||||
var transport = new ProgrammableTransport([json]);
|
||||
var decoder = new RecordingDecoder();
|
||||
|
||||
await using PubSubConnection conn = NewConnection(
|
||||
transport, decoder, subscriber,
|
||||
MessageSecurityMode.SignAndEncrypt);
|
||||
|
||||
await conn.EnableAsync().ConfigureAwait(false);
|
||||
await transport.WaitUntilDrainedAsync().ConfigureAwait(false);
|
||||
await conn.DisableAsync().ConfigureAwait(false);
|
||||
|
||||
Assert.That(decoder.CallCount, Is.Zero,
|
||||
"A non-UADP (JSON) frame must be dropped by the inbound security "
|
||||
+ "gate on a secured reader before decode.");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task SecuredReaderAcceptsSecuredFrameAsync()
|
||||
{
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
/* ========================================================================
|
||||
* Copyright (c) 2005-2026 The OPC Foundation, Inc. All rights reserved.
|
||||
*
|
||||
* OPC Foundation MIT License 1.00
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person
|
||||
* obtaining a copy of this software and associated documentation
|
||||
* files (the "Software"), to deal in the Software without
|
||||
* restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
* copies of the Software, and to permit persons to whom the
|
||||
* Software is furnished to do so, subject to the following
|
||||
* conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be
|
||||
* included in all copies or substantial portions of the Software.
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
* EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
|
||||
* OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
||||
* NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
|
||||
* HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
* WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
* FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
|
||||
* OTHER DEALINGS IN THE SOFTWARE.
|
||||
*
|
||||
* The complete license agreement can be found here:
|
||||
* http://opcfoundation.org/License/MIT/1.00/
|
||||
* ======================================================================*/
|
||||
|
||||
using System.IO;
|
||||
using NUnit.Framework;
|
||||
|
||||
namespace Opc.Ua.Types.Tests.Utils.FileSystem
|
||||
{
|
||||
/// <summary>
|
||||
/// Tests for the deny-all <see cref="NullFileSystem"/>.
|
||||
/// </summary>
|
||||
[TestFixture]
|
||||
[Category("FileSystem")]
|
||||
[SetCulture("en-us")]
|
||||
[SetUICulture("en-us")]
|
||||
[Parallelizable]
|
||||
public class NullFileSystemTests
|
||||
{
|
||||
[Test]
|
||||
public void ExistsAlwaysReturnsFalse()
|
||||
{
|
||||
Assert.That(NullFileSystem.Instance.Exists("anything", isDirectory: false), Is.False);
|
||||
Assert.That(NullFileSystem.Instance.Exists("anything", isDirectory: true), Is.False);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void OpenReadThrows()
|
||||
{
|
||||
Assert.That(
|
||||
() => NullFileSystem.Instance.OpenRead(@"\\attacker\share\x"),
|
||||
Throws.InstanceOf<FileNotFoundException>());
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void OpenWriteThrows()
|
||||
{
|
||||
Assert.That(
|
||||
() => NullFileSystem.Instance.OpenWrite("x"),
|
||||
Throws.InstanceOf<FileNotFoundException>());
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void DeleteThrows()
|
||||
{
|
||||
Assert.That(
|
||||
() => NullFileSystem.Instance.Delete("x", isDirectory: false),
|
||||
Throws.InstanceOf<FileNotFoundException>());
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void GetLengthThrows()
|
||||
{
|
||||
Assert.That(
|
||||
() => NullFileSystem.Instance.GetLength("x"),
|
||||
Throws.InstanceOf<FileNotFoundException>());
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void GetLastWriteTimeThrows()
|
||||
{
|
||||
Assert.That(
|
||||
() => NullFileSystem.Instance.GetLastWriteTime("x"),
|
||||
Throws.InstanceOf<FileNotFoundException>());
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user