b3021e7c5f
Script file access (design docs/plans/2026-09-28-script-file-access-design.md): Files.ReadTextAsync / ReadLinesAsync / ExistsAsync on site ScriptGlobals (+ Instance.Files), backed by the site-singleton ScriptFileReader. The one host-mediated exception to the System.IO ban - scripts still never name a System.IO type. OFF by default: ScadaBridge:SiteRuntime:ScriptFileAccess:AllowedRoots (empty = every call throws Disabled) + MaxFileBytes (1 MiB, validated 1..64 MiB, roots must be absolute). Guard: fully qualified, no device paths / ADS, canonical path strictly inside a root (directory-boundary match), no reparse point on the file or any directory below the root, size cap before and during the read. Errors are one Commons type, ScriptFileAccessException(+ScriptFileAccessFailure); Commons.Types.Scripts joins the default script imports on every surface so it binds by short name. Mirrored on ScriptCompileSurface (CompileFiles, parity-pinned) and the Test Run sandbox (binds, refuses Disabled). Inbound (central) scripts do not get it. Rig: docker/recipes (sample RecipeValueName,RecipeValue files incl. an invalid one) bind-mounted read-only at /app/recipes on both site-a nodes with AllowedRoots [/app/recipes]. The TestMesMachine SimDelmiaRecipe simulator now reads + parses the downloaded file, converts each value to its recipe tag's type and loads it all-or-nothing (14 new TestDelmiaReceiver attributes); verify-mes-test-machine.sh group A covers accept / re-download / invalid lines / NotFound / OutsideAllowedRoots, VERIFY_GROUPS selects groups (rig: AD).
199 lines
9.6 KiB
C#
199 lines
9.6 KiB
C#
using System.Text;
|
|
using Microsoft.Extensions.Logging;
|
|
using Microsoft.Extensions.Options;
|
|
using ZB.MOM.WW.ScadaBridge.Commons.Types.Scripts;
|
|
|
|
namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Scripts;
|
|
|
|
/// <summary>
|
|
/// Host-side, read-only file access behind the script <c>Files</c> global (design 2026-09-28).
|
|
/// Scripts never touch <c>System.IO</c> themselves; every path they pass is validated here
|
|
/// against <see cref="ScriptFileAccessOptions.AllowedRoots"/>.
|
|
/// </summary>
|
|
public interface IScriptFileReader
|
|
{
|
|
/// <summary>Reads the whole file as text (BOM-detected, UTF-8 by default).</summary>
|
|
/// <param name="path">Absolute path under an allowed root.</param>
|
|
/// <param name="cancellationToken">Cancels the read.</param>
|
|
/// <returns>The file's text.</returns>
|
|
Task<string> ReadTextAsync(string path, CancellationToken cancellationToken);
|
|
|
|
/// <summary>True when the path names an existing file under an allowed root; false when it does not exist.</summary>
|
|
/// <param name="path">Absolute path under an allowed root.</param>
|
|
/// <param name="cancellationToken">Cancels the check.</param>
|
|
/// <returns>Whether the file exists.</returns>
|
|
Task<bool> ExistsAsync(string path, CancellationToken cancellationToken);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Default <see cref="IScriptFileReader"/>. The guard, in order: access enabled (at least one
|
|
/// root) → path well-formed and fully qualified (no device <c>\\?\</c>/<c>\\.\</c> paths, no
|
|
/// alternate data streams) → canonical path strictly inside an allowed root (directory-boundary
|
|
/// match, so root <c>C:\recipes</c> does not admit <c>C:\recipes2\x</c>) → no symbolic link,
|
|
/// junction or other reparse point on the file or any directory between the root and the file
|
|
/// (so a link planted inside a root cannot point outside it) → size under
|
|
/// <see cref="ScriptFileAccessOptions.MaxFileBytes"/>, enforced again while reading in case the
|
|
/// file grows. Every refusal and I/O failure is a <see cref="ScriptFileAccessException"/>.
|
|
/// </summary>
|
|
public sealed class ScriptFileReader : IScriptFileReader
|
|
{
|
|
private readonly IOptionsMonitor<SiteRuntimeOptions> _options;
|
|
private readonly ILogger<ScriptFileReader> _logger;
|
|
|
|
/// <summary>Initializes a new instance.</summary>
|
|
/// <param name="options">Site runtime options (read per call, so a config reload applies).</param>
|
|
/// <param name="logger">Logger for refusals and reads.</param>
|
|
public ScriptFileReader(IOptionsMonitor<SiteRuntimeOptions> options, ILogger<ScriptFileReader> logger)
|
|
{
|
|
_options = options;
|
|
_logger = logger;
|
|
}
|
|
|
|
private static StringComparison PathComparison =>
|
|
OperatingSystem.IsWindows() ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal;
|
|
|
|
/// <inheritdoc />
|
|
public async Task<string> ReadTextAsync(string path, CancellationToken cancellationToken)
|
|
{
|
|
var (full, root, settings) = Resolve(path);
|
|
var info = new FileInfo(full);
|
|
if (!info.Exists)
|
|
throw Fail(ScriptFileAccessFailure.NotFound, $"File not found: '{full}'.");
|
|
EnsureNoLinks(info, root);
|
|
if (info.Length > settings.MaxFileBytes)
|
|
throw Fail(ScriptFileAccessFailure.TooLarge,
|
|
$"File '{full}' is {info.Length} bytes; the limit is {settings.MaxFileBytes} (ScriptFileAccess:MaxFileBytes).");
|
|
|
|
try
|
|
{
|
|
using var buffer = new MemoryStream();
|
|
await using (var stream = new FileStream(full, FileMode.Open, FileAccess.Read,
|
|
FileShare.ReadWrite | FileShare.Delete, bufferSize: 16 * 1024, useAsync: true))
|
|
{
|
|
var chunk = new byte[16 * 1024];
|
|
int n;
|
|
while ((n = await stream.ReadAsync(chunk, cancellationToken).ConfigureAwait(false)) > 0)
|
|
{
|
|
if (buffer.Length + n > settings.MaxFileBytes)
|
|
throw Fail(ScriptFileAccessFailure.TooLarge,
|
|
$"File '{full}' grew past the {settings.MaxFileBytes}-byte limit while it was being read.");
|
|
buffer.Write(chunk, 0, n);
|
|
}
|
|
}
|
|
buffer.Position = 0;
|
|
using var reader = new StreamReader(buffer, Encoding.UTF8, detectEncodingFromByteOrderMarks: true);
|
|
var text = await reader.ReadToEndAsync(cancellationToken).ConfigureAwait(false);
|
|
_logger.LogDebug("Script file read {Path} ({Bytes} bytes)", full, buffer.Length);
|
|
return text;
|
|
}
|
|
catch (ScriptFileAccessException) { throw; }
|
|
catch (OperationCanceledException) { throw; }
|
|
catch (FileNotFoundException ex)
|
|
{
|
|
throw Fail(ScriptFileAccessFailure.NotFound, $"File not found: '{full}'.", ex);
|
|
}
|
|
catch (DirectoryNotFoundException ex)
|
|
{
|
|
throw Fail(ScriptFileAccessFailure.NotFound, $"File not found: '{full}'.", ex);
|
|
}
|
|
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
|
|
{
|
|
throw Fail(ScriptFileAccessFailure.IoError, $"Could not read '{full}': {ex.Message}", ex);
|
|
}
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public Task<bool> ExistsAsync(string path, CancellationToken cancellationToken)
|
|
{
|
|
cancellationToken.ThrowIfCancellationRequested();
|
|
var (full, root, _) = Resolve(path);
|
|
var info = new FileInfo(full);
|
|
if (!info.Exists)
|
|
return Task.FromResult(false);
|
|
EnsureNoLinks(info, root);
|
|
return Task.FromResult(true);
|
|
}
|
|
|
|
private (string Full, string Root, ScriptFileAccessOptions Settings) Resolve(string path)
|
|
{
|
|
var settings = _options.CurrentValue.ScriptFileAccess ?? new ScriptFileAccessOptions();
|
|
var roots = settings.AllowedRoots.Where(r => !string.IsNullOrWhiteSpace(r)).ToList();
|
|
if (roots.Count == 0)
|
|
throw Fail(ScriptFileAccessFailure.Disabled,
|
|
"Script file access is disabled on this node (ScadaBridge:SiteRuntime:ScriptFileAccess:AllowedRoots is empty).");
|
|
|
|
if (string.IsNullOrWhiteSpace(path) || path.IndexOf('\0') >= 0)
|
|
throw Fail(ScriptFileAccessFailure.InvalidPath, "A file path is required.");
|
|
path = path.Trim();
|
|
if (path.StartsWith(@"\\?\", StringComparison.Ordinal) || path.StartsWith(@"\\.\", StringComparison.Ordinal)
|
|
|| path.StartsWith("//?/", StringComparison.Ordinal) || path.StartsWith("//./", StringComparison.Ordinal))
|
|
throw Fail(ScriptFileAccessFailure.InvalidPath, $"Device paths are not allowed: '{path}'.");
|
|
if (!Path.IsPathFullyQualified(path))
|
|
throw Fail(ScriptFileAccessFailure.InvalidPath, $"The path must be absolute: '{path}'.");
|
|
|
|
string full;
|
|
try
|
|
{
|
|
full = Path.GetFullPath(path);
|
|
}
|
|
catch (Exception ex) when (ex is ArgumentException or NotSupportedException or PathTooLongException)
|
|
{
|
|
throw Fail(ScriptFileAccessFailure.InvalidPath, $"Invalid path '{path}': {ex.Message}", ex);
|
|
}
|
|
// NTFS alternate data streams ("file.txt:hidden") — only the drive-letter colon is legal.
|
|
if (OperatingSystem.IsWindows() && full.IndexOf(':', 2) >= 0)
|
|
throw Fail(ScriptFileAccessFailure.InvalidPath, $"Alternate data streams are not allowed: '{path}'.");
|
|
|
|
foreach (var configured in roots)
|
|
{
|
|
var root = NormalizeRoot(configured);
|
|
if (full.Length > root.Length && full.StartsWith(root, PathComparison))
|
|
return (full, root, settings);
|
|
}
|
|
|
|
_logger.LogWarning("Script file access refused: '{Path}' is outside the allowed roots ({Roots})",
|
|
full, string.Join(", ", roots));
|
|
throw Fail(ScriptFileAccessFailure.OutsideAllowedRoots,
|
|
$"'{full}' is outside the allowed roots (ScadaBridge:SiteRuntime:ScriptFileAccess:AllowedRoots).");
|
|
}
|
|
|
|
/// <summary>Canonical root with exactly one trailing separator, for a directory-boundary prefix match.</summary>
|
|
private static string NormalizeRoot(string configured)
|
|
{
|
|
var root = Path.GetFullPath(configured.Trim());
|
|
return Path.EndsInDirectorySeparator(root) ? root : root + Path.DirectorySeparatorChar;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Refuses a reparse point (symlink / junction / mount) on the file or on any directory strictly
|
|
/// below the root: a link inside an allowed root could otherwise point anywhere. The root itself
|
|
/// may be a link — an administrator configured it.
|
|
/// </summary>
|
|
private void EnsureNoLinks(FileInfo file, string root)
|
|
{
|
|
if (IsLink(file))
|
|
throw LinkRefused(file.FullName);
|
|
for (var dir = file.Directory; dir != null; dir = dir.Parent)
|
|
{
|
|
var withSep = Path.EndsInDirectorySeparator(dir.FullName) ? dir.FullName : dir.FullName + Path.DirectorySeparatorChar;
|
|
if (withSep.Length <= root.Length)
|
|
break;
|
|
if (IsLink(dir))
|
|
throw LinkRefused(dir.FullName);
|
|
}
|
|
}
|
|
|
|
private static bool IsLink(FileSystemInfo info) =>
|
|
info.LinkTarget != null || (info.Exists && info.Attributes.HasFlag(FileAttributes.ReparsePoint));
|
|
|
|
private ScriptFileAccessException LinkRefused(string at)
|
|
{
|
|
_logger.LogWarning("Script file access refused: '{Path}' is a symbolic link / reparse point", at);
|
|
return Fail(ScriptFileAccessFailure.LinkNotAllowed,
|
|
$"'{at}' is a symbolic link, junction or reparse point; links below an allowed root are not followed.");
|
|
}
|
|
|
|
private static ScriptFileAccessException Fail(ScriptFileAccessFailure failure, string message, Exception? inner = null)
|
|
=> new(failure, message, inner);
|
|
}
|