| File: src\Shared\SocketPermissionHelper.cs | Web Access |
| Project: src\src\Aspire.Hosting\Aspire.Hosting.csproj (Aspire.Hosting) |
// Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. using System.Net.Sockets; using System.Runtime.Versioning; using System.Security.AccessControl; using System.Security.Principal; namespace Aspire.Shared; /// <summary> /// Restricts filesystem-backed socket endpoints to the current user. /// </summary> internal static class SocketPermissionHelper { // Reuse DirectoryHelper for Unix directory permissions, but keep socket-specific path // validation, Windows owner-only ACLs, and endpoint permissions here. DirectoryHelper // does not apply Windows ACLs or socket-file permissions. private const UnixFileMode OwnerOnlyMode = UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute; /// <summary> /// Creates a dedicated socket directory, repairing existing permissions only for Aspire-owned directories. /// </summary> internal static DirectoryInfo CreateDirectory(string path, bool repairExisting) => CreateDirectory(path, repairExisting, Environment.CurrentDirectory, Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), Path.GetTempPath()); /// <summary> /// Creates or repairs a socket directory using explicitly supplied environment paths. /// </summary> internal static DirectoryInfo CreateDirectory( string path, bool repairExisting, string currentDirectory, string userProfileDirectory, string tempDirectory) { ArgumentException.ThrowIfNullOrEmpty(path); ArgumentException.ThrowIfNullOrEmpty(currentDirectory); ArgumentException.ThrowIfNullOrEmpty(tempDirectory); var directory = new DirectoryInfo(Path.GetFullPath(path, currentDirectory)); var tempRoot = Path.TrimEndingDirectorySeparator(Path.GetFullPath(tempDirectory, currentDirectory)); var profileRoot = string.IsNullOrEmpty(userProfileDirectory) ? null : Path.TrimEndingDirectorySeparator(Path.GetFullPath(userProfileDirectory, currentDirectory)); var comparison = OperatingSystem.IsWindows() ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal; var isFileSystemRoot = directory.Parent is null; var isWorkingDirectory = string.Equals(directory.FullName, Path.TrimEndingDirectorySeparator(currentDirectory), comparison); var isUserProfile = string.Equals(directory.FullName, profileRoot, comparison); var isTemporaryRoot = string.Equals(Path.TrimEndingDirectorySeparator(directory.FullName), tempRoot, comparison); if (isFileSystemRoot || isWorkingDirectory || isUserProfile || isTemporaryRoot) { throw new IOException($"The socket directory '{path}' must be a dedicated directory, not the working directory, user profile, filesystem root, or temporary root."); } // Home and temporary roots are trusted environment paths and may be aliases // (for example /home/alice -> /mnt/home/alice or /var -> /private/var). // Reject links below those bases: .aspire or cli must not redirect permission // changes into an unrelated directory. Paths outside either base are checked to the root. for (var current = directory; current is not null; current = current.Parent) { if (string.Equals(current.FullName, profileRoot, comparison) || string.Equals(current.FullName, tempRoot, comparison)) { var resolvedBase = current.ResolveLinkTarget(returnFinalTarget: true) ?? current; if (!Directory.Exists(resolvedBase.FullName)) { throw new IOException($"The socket directory base '{current.FullName}' must resolve to an existing directory."); } break; } if (current.LinkTarget is not null) { throw new IOException($"The socket directory '{path}' must not traverse a symbolic link."); } } if (OperatingSystem.IsWindows()) { return CreateWindowsDirectory(directory, repairExisting); } // A configured endpoint must not cause chmod on a shared sticky directory such as /var/tmp. if (directory.Exists && (File.GetUnixFileMode(directory.FullName) & UnixFileMode.StickyBit) != 0) { throw new IOException($"The socket directory '{path}' must not be a shared sticky directory."); } if (repairExisting) { return DirectoryHelper.CreateWithOwnerOnlyPermissions(directory.FullName); } // CreateDirectory applies the mode only to a new directory. Do not chmod an // existing override, even if another caller created it concurrently. Directory.CreateDirectory(directory.FullName, OwnerOnlyMode); if (File.GetUnixFileMode(directory.FullName) != OwnerOnlyMode) { throw new IOException($"The configured socket directory '{path}' must have mode 0700. Set its permissions to 0700 or choose a new dedicated directory."); } return directory; } /// <summary> /// Secures the parent directory, binds the socket, and restricts its permissions before listening. /// </summary> internal static void Bind(Socket socket, string socketPath) { ArgumentNullException.ThrowIfNull(socket); ArgumentException.ThrowIfNullOrEmpty(socketPath); var directory = Path.GetDirectoryName(socketPath); if (string.IsNullOrEmpty(directory)) { throw new ArgumentException("The socket path must include a dedicated directory.", nameof(socketPath)); } // The allocator repairs Aspire-owned defaults. A listener must not infer ownership // from the path or rewrite an existing configured directory's permissions. CreateDirectory(directory, repairExisting: false); socket.Bind(new UnixDomainSocketEndPoint(socketPath)); if (!OperatingSystem.IsWindows()) { // The directory is already private, including during the interval between bind and chmod. // Socket mode bits alone are not a portable access boundary on Unix. File.SetUnixFileMode(socketPath, UnixFileMode.UserRead | UnixFileMode.UserWrite); } // Windows socket files inherit the owner-only DACL from their secured parent directory. } [SupportedOSPlatform("windows")] private static DirectoryInfo CreateWindowsDirectory(DirectoryInfo directory, bool repairExisting) { using var identity = WindowsIdentity.GetCurrent(); var user = identity.User ?? throw new UnauthorizedAccessException("The current Windows user has no security identifier."); var security = new DirectorySecurity(); security.SetOwner(user); security.SetAccessRuleProtection(isProtected: true, preserveInheritance: false); security.AddAccessRule(new FileSystemAccessRule( user, FileSystemRights.FullControl, InheritanceFlags.ContainerInherit | InheritanceFlags.ObjectInherit, PropagationFlags.None, AccessControlType.Allow)); // Supply the DACL at creation; existing overrides must be validated without rewriting it. // Inheritable ACEs protect Windows AF_UNIX socket files without calling Unix-only APIs. directory.Create(security); if (repairExisting) { directory.SetAccessControl(security); } else { var existingSecurity = directory.GetAccessControl(); var rules = existingSecurity.GetAccessRules(true, true, typeof(SecurityIdentifier)).Cast<FileSystemAccessRule>().ToArray(); var system = new SecurityIdentifier(WellKnownSidType.LocalSystemSid, null); var isInheritanceDisabled = existingSecurity.AreAccessRulesProtected; var isOwnedByCurrentUser = user.Equals(existingSecurity.GetOwner(typeof(SecurityIdentifier))); // Hex1b can add SYSTEM to a PTY directory after startup. This does not grant // access to other ordinary users and must not prevent subsequent terminals. var hasOnlyAllowedRules = rules.All(rule => rule.AccessControlType == AccessControlType.Allow && (user.Equals(rule.IdentityReference) || system.Equals(rule.IdentityReference))); var hasInheritableFullControl = rules.Any(rule => user.Equals(rule.IdentityReference) && rule.FileSystemRights == FileSystemRights.FullControl && rule.InheritanceFlags == (InheritanceFlags.ContainerInherit | InheritanceFlags.ObjectInherit) && rule.PropagationFlags == PropagationFlags.None); if (!isInheritanceDisabled || !isOwnedByCurrentUser || !hasOnlyAllowedRules || !hasInheritableFullControl) { throw new IOException($"The configured socket directory '{directory.FullName}' must have a protected owner-only ACL with inheritable full control for the current user (SYSTEM is also allowed). Set those permissions or choose a new dedicated directory."); } } return directory; } }