Skip to content

Security

Gustavo Viana edited this page Aug 7, 2026 · 7 revisions

Security

Two guarantees ship with every hub: path sandboxing and a runtime read-only wrapper.

Sandbox

Every DirectoryEntry carries a RootPath set by the hub. The driver rejects any operation whose resolved path falls outside that root.

var hub = new LocalFileHub(@"C:\data");

hub.Root.CreateFile("inside.txt");            // OK
hub.Root.CreateDirectory("2026/01/invoices"); // OK — nested paths create intermediates
hub.Root.OpenDirectory("../../Windows");      // FileHubException  (".." blocked)
hub.Root.CreateFile("../escape.txt");         // ArgumentException ("/" in a leaf name)
hub.Root.OpenFile("..\\..\\etc\\passwd");     // FileHubException

How it's enforced

  1. ValidateName(name) applies a portable rule set (PathUtil.ValidateName, same on every OS): rejects null, empty, ., .., path separators (/, \) and control characters. The Local driver additionally rejects the characters the host OS forbids (PathUtil.ValidateLocalName, via Path.GetInvalidFileNameChars()).
  2. ResolveSafePath(relative) calls Path.GetFullPath on Path + "/" + relative, then EnsureWithinRoot.
  3. On net8.0, EnsureNoSymlinkEscape resolves any symlink target and blocks it if the target is outside the root.
  4. LocalDirectory enumeration skips any entry with FileAttributes.ReparsePoint (symlinks, junctions, mount points) on both TFMs.

Nested paths

CreateDirectory("a/b/c") and TryOpenDirectory("a/b/c", out _) split on / and \ via PathUtil.SplitAndValidateSegments, which validates every segment before any backend call. Absolute paths, ., and .. segments throw FileHubException.

Per-driver

Driver Sandbox? Enforcement
Local Yes ResolveSafePath + symlink filter (net8.0 also resolves link targets).
Memory N/A No ambient filesystem; traversal is impossible by construction.
OCI Yes rootPath is a prefix; every object name is checked against it via PathUtil.

Tenant pattern

var hub = new LocalFileHub($@"C:\tenants\{tenantId}");
// Every op below is confined to that tenant directory —
// user-supplied names can't escape even with ".." tricks.
hub.Root.CreateFile(userSuppliedName);

Read-only

Every entry has an IsReadOnly flag. When true, writes throw FileHubException before any I/O.

var ro = hub.Root.OpenDirectory("config").AsReadOnly();

ro.OpenFile("settings.json").ReadAllText();  // OK
ro.CreateFile("new.txt");                    // FileHubException
ro.OpenFile("settings.json").SetText("x");   // FileHubException

What writes throw

On a read-only FileEntry On a read-only DirectoryEntry
GetWriteStream, SetText, SetBytes CreateFile, CreateDirectory
Delete, Rename, MoveTo Delete, Delete(name), DeleteIfExists
— Rename, MoveTo, CopyTo

CopyTo on a file is allowed — it's a read on the source.

Propagation

Anything the read-only wrapper returns is also read-only — you can't escape by navigating:

var ro = hub.Root.AsReadOnly();

ro.OpenFile("a.txt");          // read-only
ro.OpenDirectory("logs");      // read-only
ro.OpenFile("a.txt").Parent;   // read-only
ro.GetFiles().First();         // read-only

AsReadOnly() on an already read-only entry returns the same instance (no double wrap).

When to use it

  • Expose config/assets to callers without trusting them not to mutate.
  • Hand a snapshot to plugin/unknown code.
  • Enforce "pure read" semantics during an inspection pass.
void RunInspector(DirectoryEntry input) { /* plugin logic */ }
RunInspector(hub.Root.OpenDirectory("incoming").AsReadOnly());

Signed upload URLs

Object-storage drivers (S3, OCI) implement ISignedUploadable: a directory can mint a time-limited pre-signed PUT URL that a remote client uploads straight to, without the backend ever touching the bytes. The two providers differ in a security-relevant way when you pass FileWriteOptions (ContentType / CacheControl / Metadata):

Provider Options passed Behavior
S3 header-binding options The options are baked into the pre-signed URL signature. The client's PUT must send matching Content-Type / Cache-Control / user-metadata headers, or S3 rejects it with a signature mismatch. This lets the backend constrain what the client is allowed to upload.
OCI header-binding options A pre-authenticated request (PAR) cannot bind request headers. The driver throws NotSupportedException rather than hand back an unconstrained URL the caller would wrongly believe is header-constrained.
OCI no options / empty options Returns a normal upload URL.

The takeaway: do not assume header constraints carry across providers. On OCI, enforce required headers server-side after the upload completes. See Amazon S3 and Oracle Object Storage.

Access-denied surfaces uniformly

A 401 / 403 from a backend surfaces as UnauthorizedAccessException on every driver — S3 and OCI included, and the Local driver propagates the host OS's UnauthorizedAccessException unwrapped. Callers can catch that one type regardless of backend to detect a permissions failure.

For driver authors

  • First statement of every write: ThrowIfReadOnly().
  • Validate every caller-supplied leaf name with ValidateName.
  • Resolve nested paths through NestedPath.TrySplit or the driver's own prefix combiner — never concatenate user input into the backend's path directly.
  • See Custom Drivers.

Clone this wiki locally