Files
UA-.NETStandard/tests/Opc.Ua.Core.Diagnostics.Tests/Capture/SessionFolderValidationTests.cs
Marc Schier 64d2834b2f Confine capture artifact paths to the configured base folder (#4247)
## 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
2026-08-13 08:20:33 +00:00

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();
}
}
}
}