Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Restrict permissions to the dev cert directory #56985

Merged
merged 8 commits into from
Jul 31, 2024
Merged
Show file tree
Hide file tree
Changes from 6 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion src/ProjectTemplates/Shared/DevelopmentCertificate.cs
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ private static string EnsureDevelopmentCertificates(string certificatePath, stri
var manager = CertificateManager.Instance;
var certificate = manager.CreateAspNetCoreHttpsDevelopmentCertificate(now, now.AddYears(1));
var certificateThumbprint = certificate.Thumbprint;
CertificateManager.ExportCertificate(certificate, path: certificatePath, includePrivateKey: true, certificatePassword, CertificateKeyExportFormat.Pfx);
manager.ExportCertificate(certificate, path: certificatePath, includePrivateKey: true, certificatePassword, CertificateKeyExportFormat.Pfx);

return certificateThumbprint;
}
Expand Down
21 changes: 19 additions & 2 deletions src/Shared/CertificateGeneration/CertificateManager.cs
Original file line number Diff line number Diff line change
Expand Up @@ -323,6 +323,14 @@ public EnsureCertificateResult EnsureAspNetCoreHttpsDevelopmentCertificate(
{
try
{
// If the user specified a non-existent directory, we don't want to be responsible
// for setting the permissions appropriately, so we'll bail.
var exportDir = Path.GetDirectoryName(path);
if (!string.IsNullOrEmpty(exportDir) && !Directory.Exists(exportDir))
{
throw new InvalidOperationException($"The directory '{exportDir}' does not exist.");
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should the error provide a friendly pointer that users should choose permissions carefully?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

"When creating it, think carefully about who should be able to read it?"

}

ExportCertificate(certificate, path, includePrivateKey, password, keyExportFormat);
}
catch (Exception e)
Expand Down Expand Up @@ -484,7 +492,13 @@ public void CleanupHttpsCertificates()

protected abstract IList<X509Certificate2> GetCertificatesToRemove(StoreName storeName, StoreLocation storeLocation);

internal static void ExportCertificate(X509Certificate2 certificate, string path, bool includePrivateKey, string? password, CertificateKeyExportFormat format)
protected abstract void CreateDirectoryWithPermissions(string directoryPath);

/// <remarks>
/// Will create directories to make it possible to write to <paramref name="path"/>.
/// If you don't want that, check for existence before calling this method.
/// </remarks>
internal void ExportCertificate(X509Certificate2 certificate, string path, bool includePrivateKey, string? password, CertificateKeyExportFormat format)
{
if (Log.IsEnabled())
{
Expand All @@ -500,7 +514,7 @@ internal static void ExportCertificate(X509Certificate2 certificate, string path
if (!string.IsNullOrEmpty(targetDirectoryPath))
{
Log.CreateExportCertificateDirectory(targetDirectoryPath);
Directory.CreateDirectory(targetDirectoryPath);
CreateDirectoryWithPermissions(targetDirectoryPath);
}

byte[] bytes;
Expand Down Expand Up @@ -1230,6 +1244,9 @@ public sealed class CertificateManagerEventSource : EventSource
[Event(111, Level = EventLevel.LogAlways, Message = "For OpenSSL trust to take effect, '{0}' must be listed in the {2} environment variable. " +
"See https://aka.ms/dev-certs-trust for more information.")]
internal void UnixSuggestSettingEnvironmentVariableWithoutExample(string certDir, string envVarName) => WriteEvent(111, certDir, envVarName);

[Event(112, Level = EventLevel.Warning, Message = "Directory '{0}' may be readable by other users.")]
internal void DirectoryPermissionsNotSecure(string directoryPath) => WriteEvent(112, directoryPath);
}

internal sealed class UserCancelledTrustException : Exception
Expand Down
28 changes: 24 additions & 4 deletions src/Shared/CertificateGeneration/MacOSCertificateManager.cs
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ namespace Microsoft.AspNetCore.Certificates.Generation;
/// </remarks>
internal sealed class MacOSCertificateManager : CertificateManager
{
private const UnixFileMode DirectoryPermissions = UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute;

// User keychain. Guard with quotes when using in command lines since users may have set
// their user profile (HOME) directory to a non-standard path that includes whitespace.
private static readonly string MacOSUserKeychain = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile) + "/Library/Keychains/login.keychain-db";
Expand Down Expand Up @@ -93,6 +95,7 @@ protected override TrustLevel TrustCertificateCore(X509Certificate2 publicCertif
var tmpFile = Path.GetTempFileName();
try
{
// We can't guarantee that the temp file is in a directory with sensible permissions, but we're not exporting the private key
ExportCertificate(publicCertificate, tmpFile, includePrivateKey: false, password: null, CertificateKeyExportFormat.Pfx);
if (Log.IsEnabled())
{
Expand Down Expand Up @@ -134,9 +137,7 @@ internal override void CorrectCertificateState(X509Certificate2 candidate)
{
try
{
// Ensure that the directory exists before writing to the file.
Directory.CreateDirectory(MacOSUserHttpsCertificateLocation);

// This path is in a well-known folder, so we trust the permissions.
var certificatePath = GetCertificateFilePath(candidate);
ExportCertificate(candidate, certificatePath, includePrivateKey: true, null, CertificateKeyExportFormat.Pfx);
}
Expand All @@ -152,6 +153,7 @@ public override TrustLevel GetTrustLevel(X509Certificate2 certificate)
var tmpFile = Path.GetTempFileName();
try
{
// We can't guarantee that the temp file is in a directory with sensible permissions, but we're not exporting the private key
ExportCertificate(certificate, tmpFile, includePrivateKey: false, password: null, CertificateKeyExportFormat.Pem);

using var checkTrustProcess = Process.Start(new ProcessStartInfo(
Expand Down Expand Up @@ -316,7 +318,7 @@ protected override X509Certificate2 SaveCertificateCore(X509Certificate2 certifi
}

// Ensure that the directory exists before writing to the file.
Directory.CreateDirectory(MacOSUserHttpsCertificateLocation);
CreateDirectoryWithPermissions(MacOSUserHttpsCertificateLocation);

File.WriteAllBytes(GetCertificateFilePath(certificate), certBytes);
}
Expand Down Expand Up @@ -474,4 +476,22 @@ protected override void RemoveCertificateFromUserStoreCore(X509Certificate2 cert
RemoveCertificateFromKeychain(MacOSUserKeychain, certificate);
}
}

protected override void CreateDirectoryWithPermissions(string directoryPath)
{
#pragma warning disable CA1416 // Validate platform compatibility (not supported on Windows)
var dirInfo = new DirectoryInfo(directoryPath);
if (dirInfo.Exists)
{
if ((dirInfo.UnixFileMode & ~DirectoryPermissions) != 0)
{
Log.DirectoryPermissionsNotSecure(dirInfo.FullName);
}
}
else
{
Directory.CreateDirectory(directoryPath, DirectoryPermissions);
}
#pragma warning restore CA1416 // Validate platform compatibility
}
}
30 changes: 26 additions & 4 deletions src/Shared/CertificateGeneration/UnixCertificateManager.cs
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ namespace Microsoft.AspNetCore.Certificates.Generation;
/// </remarks>
internal sealed partial class UnixCertificateManager : CertificateManager
{
private const UnixFileMode DirectoryPermissions = UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute;

/// <summary>The name of an environment variable consumed by OpenSSL to locate certificates.</summary>
private const string OpenSslCertificateDirectoryVariableName = "SSL_CERT_DIR";

Expand Down Expand Up @@ -74,7 +76,8 @@ public override TrustLevel GetTrustLevel(X509Certificate2 certificate)
Log.UnixNotTrustedByDotnet();
}

var nickname = GetCertificateNickname(certificate);
// Will become the name of the file on disk and the nickname in the NSS DBs
var certificateNickname = GetCertificateNickname(certificate);

var sslCertDirString = Environment.GetEnvironmentVariable(OpenSslCertificateDirectoryVariableName);
if (string.IsNullOrEmpty(sslCertDirString))
Expand All @@ -88,7 +91,7 @@ public override TrustLevel GetTrustLevel(X509Certificate2 certificate)
var sslCertDirs = sslCertDirString.Split(Path.PathSeparator);
foreach (var sslCertDir in sslCertDirs)
{
var certPath = Path.Combine(sslCertDir, nickname + ".pem");
var certPath = Path.Combine(sslCertDir, certificateNickname + ".pem");
if (File.Exists(certPath))
{
var candidate = X509CertificateLoader.LoadCertificateFromFile(certPath);
Expand Down Expand Up @@ -125,7 +128,7 @@ public override TrustLevel GetTrustLevel(X509Certificate2 certificate)
{
foreach (var nssDb in nssDbs)
{
if (IsCertificateInNssDb(nickname, nssDb))
if (IsCertificateInNssDb(certificateNickname, nssDb))
{
sawTrustSuccess = true;
}
Expand All @@ -138,6 +141,7 @@ public override TrustLevel GetTrustLevel(X509Certificate2 certificate)
}
}

// Success & Failure => Partial; Success => Full; Failure => None
return sawTrustSuccess
? sawTrustFailure
? TrustLevel.Partial
Expand Down Expand Up @@ -244,7 +248,7 @@ protected override TrustLevel TrustCertificateCore(X509Certificate2 certificate)
if (needToExport)
{
// Security: we don't need the private key for trust, so we don't export it.
// Note that this will create directories as needed.
// Note that this will create directories as needed. We control `certPath`, so the permissions should be fine.
ExportCertificate(certificate, certPath, includePrivateKey: false, password: null, CertificateKeyExportFormat.Pem);
}

Expand Down Expand Up @@ -449,6 +453,24 @@ protected override IList<X509Certificate2> GetCertificatesToRemove(StoreName sto
return ListCertificates(StoreName.My, StoreLocation.CurrentUser, isValid: false, requireExportable: false);
}

protected override void CreateDirectoryWithPermissions(string directoryPath)
{
#pragma warning disable CA1416 // Validate platform compatibility (not supported on Windows)
var dirInfo = new DirectoryInfo(directoryPath);
if (dirInfo.Exists)
{
if ((dirInfo.UnixFileMode & ~DirectoryPermissions) != 0)
{
Log.DirectoryPermissionsNotSecure(dirInfo.FullName);
}
}
else
{
Directory.CreateDirectory(directoryPath, DirectoryPermissions);
}
#pragma warning restore CA1416 // Validate platform compatibility
}

private static string GetChromiumNssDb(string homeDirectory)
{
return Path.Combine(homeDirectory, ".pki", "nssdb");
Expand Down
39 changes: 39 additions & 0 deletions src/Shared/CertificateGeneration/WindowsCertificateManager.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,10 @@
using System.Collections.Generic;
using System.Linq;
using System.Runtime.Versioning;
using System.Security.AccessControl;
using System.Security.Cryptography;
using System.Security.Cryptography.X509Certificates;
using System.Security.Principal;

namespace Microsoft.AspNetCore.Certificates.Generation;

Expand Down Expand Up @@ -126,4 +128,41 @@ protected override IList<X509Certificate2> GetCertificatesToRemove(StoreName sto
{
return ListCertificates(storeName, storeLocation, isValid: false);
}

protected override void CreateDirectoryWithPermissions(string directoryPath)
{
var dirInfo = new DirectoryInfo(directoryPath);

if (!dirInfo.Exists)
{
// We trust the default permissions on Windows enough not to apply custom ACLs.
// We'll warn below if things seem really off.
dirInfo.Create();
}

var currentUser = WindowsIdentity.GetCurrent();
var currentUserSid = currentUser.User;
var systemSid = new SecurityIdentifier(WellKnownSidType.LocalSystemSid, domainSid: null);
var adminGroupSid = new SecurityIdentifier(WellKnownSidType.BuiltinAdministratorsSid, domainSid: null);

var dirSecurity = dirInfo.GetAccessControl();
var accessRules = dirSecurity.GetAccessRules(true, true, typeof(SecurityIdentifier));

foreach (FileSystemAccessRule rule in accessRules)
{
var idRef = rule.IdentityReference;
if (rule.AccessControlType == AccessControlType.Allow &&
!idRef.Equals(currentUserSid) &&
!idRef.Equals(systemSid) &&
!idRef.Equals(adminGroupSid))
{
// This is just a heuristic - determining whether the cumulative effect of the rules
// is to allow access to anyone other than the current user, system, or administrators
// is very complicated. We're not going to do anything but log, so an approximation
// is fine.
Log.DirectoryPermissionsNotSecure(dirInfo.FullName);
break;
}
}
}
}
23 changes: 23 additions & 0 deletions src/Tools/FirstRunCertGenerator/test/CertificateManagerTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -352,6 +352,29 @@ public void EnsureCreateHttpsCertificate_CanExportTheCertInPemFormat_WithoutPass
Assert.Equal(httpsCertificate.GetCertHashString(), exportedCertificate.GetCertHashString());
}

[ConditionalFact]
[SkipOnHelix("https://github.com/dotnet/aspnetcore/issues/6720", Queues = "All.OSX")]
public void EnsureCreateHttpsCertificate_CannotExportToNonExistentDirectory()
{
// Arrange
const string CertificateName = nameof(EnsureCreateHttpsCertificate_CannotExportToNonExistentDirectory) + ".pem";

_fixture.CleanupCertificates();

var now = DateTimeOffset.UtcNow;
now = new DateTimeOffset(now.Year, now.Month, now.Day, now.Hour, now.Minute, now.Second, 0, now.Offset);
var creation = _manager.EnsureAspNetCoreHttpsDevelopmentCertificate(now, now.AddYears(1), path: null, trust: false, isInteractive: false);
Output.WriteLine(creation.ToString());
ListCertificates();

// Act
var result = _manager
.EnsureAspNetCoreHttpsDevelopmentCertificate(now, now.AddYears(1), Path.Combine("NoSuchDirectory", CertificateName));

// Assert
Assert.Equal(EnsureCertificateResult.ErrorExportingTheCertificate, result);
}

[Fact]
public void EnsureCreateHttpsCertificate_ReturnsExpiredCertificateIfVersionIsIncorrect()
{
Expand Down
Loading