mirror of
https://github.com/OPCFoundation/UA-.NETStandard.git
synced 2026-10-21 00:56:28 +08:00
64d2834b2f
## What `CaptureSessionManager.StartAsync` validates the caller-supplied `SessionFolder` against the configured capture base folder, then copies `PcapFilePath` and `KeyLogFilePath` from the same request through **without validating them**. Both reach the filesystem: | Property | Sink | Effect | | --- | --- | --- | | `PcapFilePath` (write) | `PcapFileWriter.OpenCurrentFile` — `FileMode.Create`, after `EnsureParentDirectoryExists` | truncates and overwrites the named file, creating parent directories | | `PcapFilePath` (read) | `ReplayCaptureSource` → `PcapFormatter.FormatAsync` → `File.ReadAllBytesAsync` | returns the named file's bytes to the caller | | `KeyLogFilePath` | `OpenOrCreate` + seek to end | appends key material to the named file | `InProcessCaptureSource.ResolveArtifactPath` returns a rooted path unchanged and `Path.Combine`s a relative one without normalizing, so `..` traversal is not contained either. Where those values arrive from outside the process — the MCP `start_capture` tool passes a `StartCaptureRequest` straight through — a caller can name any path the process can open, in either direction. ## Why this looks like an oversight The containment already exists and is applied consistently everywhere else in this package. `PacketDecodeTools.DecodePcapWithKeysAsync` and `PacketReplayTools.ReplayPcapAsync` both run caller paths through `McpCapturePath.ResolveAndValidate(path, allowedRoot)` before use. `start_capture` is the one entry point that skips it. ## Change Adds `ValidateAndResolveArtifactPath` beside the existing `ValidateAndResolveSessionFolder` and applies it to both artifact properties. Relative paths are resolved against the validated session folder, preserving the `StartCaptureRequest` contract, and the result is then confined to the configured capture base folder. Containment comparisons are case-insensitive on Windows and case-sensitive on non-Windows platforms. Null and empty paths pass through untouched so sources keep deriving their default names inside the session folder. ## Tests Five added to `SessionFolderValidationTests`: - rejects a rooted `PcapFilePath` outside the base folder - rejects `..` traversal in `PcapFilePath` - rejects a rooted `KeyLogFilePath` outside the base folder - accepts artifact paths inside the base folder and resolves relative paths beneath the session folder - rejects a case-variant sibling path on Linux Each was verified to **fail without the change**. MCP capture, decode, and replay fixtures now place valid session artifacts beneath the configured capture root while preserving explicit outside-root rejection coverage. `SessionFolderValidationTests` (11/11) and the complete `Opc.Ua.Tools.Tests` suite (430/430) pass locally. ## Note This has security relevance for hosts that expose the MCP diagnostics tools, so it was also reported to `securityteam@opcfoundation.org` per `SECURITY.md`. Happy to close this and handle it privately instead if you would prefer. --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: b8ab3763-b9bd-46ae-b0ef-045db26e5782
361 lines
16 KiB
C#
361 lines
16 KiB
C#
/* ========================================================================
|
|
* 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;
|
|
using System.Collections.Generic;
|
|
using System.IO;
|
|
using System.Threading;
|
|
using System.Threading.Tasks;
|
|
using Microsoft.Extensions.Logging;
|
|
using NUnit.Framework;
|
|
using Opc.Ua.Pcap.Capture;
|
|
using Opc.Ua.Pcap.DependencyInjection;
|
|
using Opc.Ua.Pcap.Models;
|
|
|
|
namespace Opc.Ua.Pcap.Tests.Capture
|
|
{
|
|
/// <summary>
|
|
/// Verifies capture session folder validation rejects paths outside the
|
|
/// configured base folder before artifacts are created.
|
|
/// </summary>
|
|
[TestFixture]
|
|
public sealed class SessionFolderValidationTests : TempDirectoryFixture
|
|
{
|
|
/// <summary>
|
|
/// Verifies parent directory traversal cannot escape the base folder.
|
|
/// </summary>
|
|
[Test]
|
|
public async Task StartAsyncRejectsParentTraversal()
|
|
{
|
|
var factory = new RecordingSourceFactory();
|
|
await using var manager = new CaptureSessionManager(factory, TempDirectory);
|
|
|
|
Assert.That(
|
|
async () => await manager.StartAsync(
|
|
new StartCaptureRequest { SessionFolder = Path.Combine("..", "..", "etc") },
|
|
CancellationToken.None).ConfigureAwait(false),
|
|
Throws.TypeOf<ArgumentException>()
|
|
.With.Property("ParamName").EqualTo("folder"));
|
|
Assert.That(factory.SessionFolders, Is.Empty);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Verifies absolute paths outside the configured base folder are
|
|
/// rejected.
|
|
/// </summary>
|
|
[Test]
|
|
public async Task StartAsyncRejectsAbsolutePathOutsideBaseFolder()
|
|
{
|
|
var factory = new RecordingSourceFactory();
|
|
string outsideFolder = OperatingSystem.IsWindows()
|
|
? Path.Combine(Path.GetPathRoot(TempDirectory) ?? "C:\\", "Windows", "foo")
|
|
: "/etc/foo";
|
|
await using var manager = new CaptureSessionManager(factory, TempDirectory);
|
|
|
|
Assert.That(
|
|
async () => await manager.StartAsync(
|
|
new StartCaptureRequest { SessionFolder = outsideFolder },
|
|
CancellationToken.None).ConfigureAwait(false),
|
|
Throws.TypeOf<ArgumentException>()
|
|
.With.Property("ParamName").EqualTo("folder"));
|
|
Assert.That(factory.SessionFolders, Is.Empty);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Verifies path containment remains case-sensitive on Unix.
|
|
/// </summary>
|
|
[Test]
|
|
[Platform("Linux")]
|
|
public async Task StartAsyncRejectsCaseVariantArtifactPathOutsideBaseFolder()
|
|
{
|
|
var factory = new RecordingSourceFactory();
|
|
string fullBaseFolder = Path.GetFullPath(TempDirectory);
|
|
string folderName = Path.GetFileName(fullBaseFolder);
|
|
string caseVariant = folderName.ToUpperInvariant();
|
|
if (caseVariant == folderName)
|
|
{
|
|
caseVariant = folderName.ToLowerInvariant();
|
|
}
|
|
|
|
Assert.That(caseVariant, Is.Not.EqualTo(folderName));
|
|
string outsidePath = Path.Combine(
|
|
Path.GetDirectoryName(fullBaseFolder)!,
|
|
caseVariant,
|
|
"evil.pcap");
|
|
await using var manager = new CaptureSessionManager(factory, TempDirectory);
|
|
|
|
Assert.That(
|
|
async () => await manager.StartAsync(
|
|
new StartCaptureRequest { PcapFilePath = outsidePath },
|
|
CancellationToken.None).ConfigureAwait(false),
|
|
Throws.TypeOf<ArgumentException>()
|
|
.With.Property("ParamName").EqualTo("PcapFilePath"));
|
|
Assert.That(factory.SessionFolders, Is.Empty);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Verifies relative session folders are resolved beneath the
|
|
/// configured base folder.
|
|
/// </summary>
|
|
[Test]
|
|
public async Task StartAsyncAcceptsRelativeSubFolderWithinBase()
|
|
{
|
|
var factory = new RecordingSourceFactory();
|
|
await using var manager = new CaptureSessionManager(factory, TempDirectory);
|
|
|
|
CaptureSession session = await manager.StartAsync(
|
|
new StartCaptureRequest { SessionFolder = "my-session-folder" },
|
|
CancellationToken.None).ConfigureAwait(false);
|
|
|
|
string expectedFolder = Path.Combine(Path.GetFullPath(TempDirectory), "my-session-folder");
|
|
Assert.That(session.SessionFolder, Is.EqualTo(expectedFolder));
|
|
Assert.That(session.Request.SessionFolder, Is.EqualTo(expectedFolder));
|
|
Assert.That(factory.SessionFolders, Is.EqualTo([expectedFolder]));
|
|
Assert.That(Directory.Exists(expectedFolder), Is.True);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Verifies absolute paths inside the configured base folder remain
|
|
/// valid.
|
|
/// </summary>
|
|
[Test]
|
|
public async Task StartAsyncAcceptsAbsolutePathInsideBase()
|
|
{
|
|
var factory = new RecordingSourceFactory();
|
|
string requestedFolder = Path.Combine(TempDirectory, "subdir");
|
|
await using var manager = new CaptureSessionManager(factory, TempDirectory);
|
|
|
|
CaptureSession session = await manager.StartAsync(
|
|
new StartCaptureRequest { SessionFolder = requestedFolder },
|
|
CancellationToken.None).ConfigureAwait(false);
|
|
|
|
string expectedFolder = Path.GetFullPath(requestedFolder);
|
|
Assert.That(session.SessionFolder, Is.EqualTo(expectedFolder));
|
|
Assert.That(session.Request.SessionFolder, Is.EqualTo(expectedFolder));
|
|
Assert.That(factory.SessionFolders, Is.EqualTo([expectedFolder]));
|
|
Assert.That(Directory.Exists(expectedFolder), Is.True);
|
|
}
|
|
|
|
/// <summary>
|
|
/// The pcap file path reaches File.Create on the write side and
|
|
/// File.ReadAllBytesAsync on the read side, so an unvalidated value
|
|
/// lets a caller overwrite or read any file the process can touch.
|
|
/// </summary>
|
|
[Test]
|
|
public async Task StartAsyncRejectsPcapFilePathOutsideBaseFolder()
|
|
{
|
|
var factory = new RecordingSourceFactory();
|
|
string outside = OperatingSystem.IsWindows()
|
|
? Path.Combine(Path.GetPathRoot(TempDirectory) ?? "C:\\", "Windows", "evil.pcap")
|
|
: "/etc/evil.pcap";
|
|
await using var manager = new CaptureSessionManager(factory, TempDirectory);
|
|
|
|
Assert.That(
|
|
async () => await manager.StartAsync(
|
|
new StartCaptureRequest { PcapFilePath = outside },
|
|
CancellationToken.None).ConfigureAwait(false),
|
|
Throws.TypeOf<ArgumentException>()
|
|
.With.Property("ParamName").EqualTo("PcapFilePath"));
|
|
Assert.That(factory.SessionFolders, Is.Empty);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Relative traversal must not escape either, since the path is
|
|
/// combined with the base folder rather than normalized.
|
|
/// </summary>
|
|
[Test]
|
|
public async Task StartAsyncRejectsPcapFilePathParentTraversal()
|
|
{
|
|
var factory = new RecordingSourceFactory();
|
|
await using var manager = new CaptureSessionManager(factory, TempDirectory);
|
|
|
|
Assert.That(
|
|
async () => await manager.StartAsync(
|
|
new StartCaptureRequest
|
|
{
|
|
PcapFilePath = Path.Combine("..", "..", "evil.pcap")
|
|
},
|
|
CancellationToken.None).ConfigureAwait(false),
|
|
Throws.TypeOf<ArgumentException>()
|
|
.With.Property("ParamName").EqualTo("PcapFilePath"));
|
|
Assert.That(factory.SessionFolders, Is.Empty);
|
|
}
|
|
|
|
/// <summary>
|
|
/// The key log file is appended to, so an unvalidated value writes key
|
|
/// material into an arbitrary file.
|
|
/// </summary>
|
|
[Test]
|
|
public async Task StartAsyncRejectsKeyLogFilePathOutsideBaseFolder()
|
|
{
|
|
var factory = new RecordingSourceFactory();
|
|
string outside = OperatingSystem.IsWindows()
|
|
? Path.Combine(Path.GetPathRoot(TempDirectory) ?? "C:\\", "Windows", "keys.log")
|
|
: "/etc/keys.log";
|
|
await using var manager = new CaptureSessionManager(factory, TempDirectory);
|
|
|
|
Assert.That(
|
|
async () => await manager.StartAsync(
|
|
new StartCaptureRequest { KeyLogFilePath = outside },
|
|
CancellationToken.None).ConfigureAwait(false),
|
|
Throws.TypeOf<ArgumentException>()
|
|
.With.Property("ParamName").EqualTo("KeyLogFilePath"));
|
|
Assert.That(factory.SessionFolders, Is.Empty);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Artifact paths inside the base folder must still be accepted, and
|
|
/// are resolved to full paths.
|
|
/// </summary>
|
|
[Test]
|
|
public async Task StartAsyncAcceptsArtifactPathsInsideBaseFolder()
|
|
{
|
|
var factory = new RecordingSourceFactory();
|
|
await using var manager = new CaptureSessionManager(factory, TempDirectory);
|
|
|
|
CaptureSession session = await manager.StartAsync(
|
|
new StartCaptureRequest
|
|
{
|
|
SessionFolder = "capture-session",
|
|
PcapFilePath = "capture.pcap",
|
|
KeyLogFilePath = Path.Combine(TempDirectory, "keys.log")
|
|
},
|
|
CancellationToken.None).ConfigureAwait(false);
|
|
|
|
Assert.That(session.Request.PcapFilePath, Is.EqualTo(
|
|
Path.Combine(Path.GetFullPath(TempDirectory), "capture-session", "capture.pcap")));
|
|
Assert.That(session.Request.KeyLogFilePath, Is.EqualTo(
|
|
Path.GetFullPath(Path.Combine(TempDirectory, "keys.log"))));
|
|
}
|
|
|
|
/// <summary>
|
|
/// Verifies null and empty session folders use the generated
|
|
/// per-session folder beneath the configured base folder.
|
|
/// </summary>
|
|
[Test]
|
|
public async Task StartAsyncAcceptsNullOrEmptySessionFolder()
|
|
{
|
|
var factory = new RecordingSourceFactory();
|
|
await using var manager = new CaptureSessionManager(factory, TempDirectory);
|
|
|
|
CaptureSession nullSessionFolder = await manager.StartAsync(
|
|
new StartCaptureRequest(),
|
|
CancellationToken.None).ConfigureAwait(false);
|
|
CaptureSession emptySessionFolder = await manager.StartAsync(
|
|
new StartCaptureRequest { SessionFolder = string.Empty },
|
|
CancellationToken.None).ConfigureAwait(false);
|
|
|
|
string fullBaseFolder = Path.GetFullPath(TempDirectory);
|
|
string expectedNullFolder = Path.Combine(fullBaseFolder, nullSessionFolder.Id);
|
|
string expectedEmptyFolder = Path.Combine(fullBaseFolder, emptySessionFolder.Id);
|
|
Assert.That(nullSessionFolder.SessionFolder, Is.EqualTo(expectedNullFolder));
|
|
Assert.That(emptySessionFolder.SessionFolder, Is.EqualTo(expectedEmptyFolder));
|
|
Assert.That(factory.SessionFolders, Is.EqualTo([expectedNullFolder, expectedEmptyFolder]));
|
|
}
|
|
|
|
/// <summary>
|
|
/// Verifies a relative base folder is resolved once rather than being
|
|
/// duplicated when the generated session folder is validated.
|
|
/// </summary>
|
|
[Test]
|
|
public async Task StartAsyncWithRelativeBaseFolderDoesNotDuplicatePath()
|
|
{
|
|
var factory = new RecordingSourceFactory();
|
|
string relativeBase = "relative-pcap-" + Path.GetRandomFileName();
|
|
string absoluteBase = Path.GetFullPath(relativeBase);
|
|
Assert.That(Path.IsPathRooted(relativeBase), Is.False);
|
|
|
|
try
|
|
{
|
|
await using var manager = new CaptureSessionManager(factory, relativeBase);
|
|
CaptureSession session = await manager.StartAsync(
|
|
new StartCaptureRequest(),
|
|
CancellationToken.None).ConfigureAwait(false);
|
|
|
|
string expectedFolder = Path.Combine(absoluteBase, session.Id);
|
|
Assert.That(session.SessionFolder, Is.EqualTo(expectedFolder));
|
|
Assert.That(factory.SessionFolders, Is.EqualTo([expectedFolder]));
|
|
}
|
|
finally
|
|
{
|
|
if (Directory.Exists(absoluteBase))
|
|
{
|
|
Directory.Delete(absoluteBase, recursive: true);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Verifies the convenience constructor uses the same secure default
|
|
/// folder as dependency-injection registration.
|
|
/// </summary>
|
|
[Test]
|
|
public async Task ConvenienceConstructorUsesSecureDefaultBaseFolder()
|
|
{
|
|
var factory = new RecordingSourceFactory();
|
|
await using var manager = new CaptureSessionManager(factory);
|
|
|
|
CaptureSession session = await manager.StartAsync(
|
|
new StartCaptureRequest(),
|
|
CancellationToken.None).ConfigureAwait(false);
|
|
|
|
Assert.That(
|
|
session.SessionFolder,
|
|
Is.EqualTo(Path.Combine(PcapOptions.DefaultBaseFolder, session.Id)));
|
|
}
|
|
|
|
/// <summary>
|
|
/// Documents the symlink escape scenario for future canonical-path
|
|
/// hardening.
|
|
/// </summary>
|
|
[Test]
|
|
[Platform("Linux,MacOSX")]
|
|
public void StartAsyncRejectsBaseFolderEscapeViaSymlink()
|
|
{
|
|
Assert.Ignore(
|
|
"Best-effort symlink escape coverage is skipped because the current guard validates " +
|
|
"normalized paths with Path.GetFullPath and does not canonicalize symlink targets.");
|
|
}
|
|
|
|
private sealed class RecordingSourceFactory : ICaptureSourceFactory
|
|
{
|
|
public List<string> SessionFolders { get; } = [];
|
|
|
|
public ICaptureSource Create(
|
|
CaptureSourceKind kind,
|
|
string sessionFolder,
|
|
ILoggerFactory loggerFactory)
|
|
{
|
|
SessionFolders.Add(sessionFolder);
|
|
return new InMemoryCaptureSource();
|
|
}
|
|
}
|
|
}
|
|
}
|