Skip to content

Commit 1470e00

Browse files
authored
Restrict permissions to the dev cert directory (#56985)
* Create directories with secure permissions If we're creating it, make it 700. If it already exists, warn if it's not 700. * Don't create a directory specified by the user
1 parent d3e244c commit 1470e00

File tree

6 files changed

+133
-11
lines changed

6 files changed

+133
-11
lines changed

src/ProjectTemplates/Shared/DevelopmentCertificate.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ private static string EnsureDevelopmentCertificates(string certificatePath, stri
3535
var manager = CertificateManager.Instance;
3636
var certificate = manager.CreateAspNetCoreHttpsDevelopmentCertificate(now, now.AddYears(1));
3737
var certificateThumbprint = certificate.Thumbprint;
38-
CertificateManager.ExportCertificate(certificate, path: certificatePath, includePrivateKey: true, certificatePassword, CertificateKeyExportFormat.Pfx);
38+
manager.ExportCertificate(certificate, path: certificatePath, includePrivateKey: true, certificatePassword, CertificateKeyExportFormat.Pfx);
3939

4040
return certificateThumbprint;
4141
}

src/Shared/CertificateGeneration/CertificateManager.cs

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -323,6 +323,14 @@ public EnsureCertificateResult EnsureAspNetCoreHttpsDevelopmentCertificate(
323323
{
324324
try
325325
{
326+
// If the user specified a non-existent directory, we don't want to be responsible
327+
// for setting the permissions appropriately, so we'll bail.
328+
var exportDir = Path.GetDirectoryName(path);
329+
if (!string.IsNullOrEmpty(exportDir) && !Directory.Exists(exportDir))
330+
{
331+
throw new InvalidOperationException($"The directory '{exportDir}' does not exist. Choose permissions carefully when creating it.");
332+
}
333+
326334
ExportCertificate(certificate, path, includePrivateKey, password, keyExportFormat);
327335
}
328336
catch (Exception e)
@@ -484,7 +492,13 @@ public void CleanupHttpsCertificates()
484492

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

487-
internal static void ExportCertificate(X509Certificate2 certificate, string path, bool includePrivateKey, string? password, CertificateKeyExportFormat format)
495+
protected abstract void CreateDirectoryWithPermissions(string directoryPath);
496+
497+
/// <remarks>
498+
/// Will create directories to make it possible to write to <paramref name="path"/>.
499+
/// If you don't want that, check for existence before calling this method.
500+
/// </remarks>
501+
internal void ExportCertificate(X509Certificate2 certificate, string path, bool includePrivateKey, string? password, CertificateKeyExportFormat format)
488502
{
489503
if (Log.IsEnabled())
490504
{
@@ -500,7 +514,7 @@ internal static void ExportCertificate(X509Certificate2 certificate, string path
500514
if (!string.IsNullOrEmpty(targetDirectoryPath))
501515
{
502516
Log.CreateExportCertificateDirectory(targetDirectoryPath);
503-
Directory.CreateDirectory(targetDirectoryPath);
517+
CreateDirectoryWithPermissions(targetDirectoryPath);
504518
}
505519

506520
byte[] bytes;
@@ -1230,6 +1244,9 @@ public sealed class CertificateManagerEventSource : EventSource
12301244
[Event(111, Level = EventLevel.LogAlways, Message = "For OpenSSL trust to take effect, '{0}' must be listed in the {2} environment variable. " +
12311245
"See https://aka.ms/dev-certs-trust for more information.")]
12321246
internal void UnixSuggestSettingEnvironmentVariableWithoutExample(string certDir, string envVarName) => WriteEvent(111, certDir, envVarName);
1247+
1248+
[Event(112, Level = EventLevel.Warning, Message = "Directory '{0}' may be readable by other users.")]
1249+
internal void DirectoryPermissionsNotSecure(string directoryPath) => WriteEvent(112, directoryPath);
12331250
}
12341251

12351252
internal sealed class UserCancelledTrustException : Exception

src/Shared/CertificateGeneration/MacOSCertificateManager.cs

Lines changed: 24 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@ namespace Microsoft.AspNetCore.Certificates.Generation;
1818
/// </remarks>
1919
internal sealed class MacOSCertificateManager : CertificateManager
2020
{
21+
private const UnixFileMode DirectoryPermissions = UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute;
22+
2123
// User keychain. Guard with quotes when using in command lines since users may have set
2224
// their user profile (HOME) directory to a non-standard path that includes whitespace.
2325
private static readonly string MacOSUserKeychain = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile) + "/Library/Keychains/login.keychain-db";
@@ -93,6 +95,7 @@ protected override TrustLevel TrustCertificateCore(X509Certificate2 publicCertif
9395
var tmpFile = Path.GetTempFileName();
9496
try
9597
{
98+
// We can't guarantee that the temp file is in a directory with sensible permissions, but we're not exporting the private key
9699
ExportCertificate(publicCertificate, tmpFile, includePrivateKey: false, password: null, CertificateKeyExportFormat.Pfx);
97100
if (Log.IsEnabled())
98101
{
@@ -134,9 +137,7 @@ internal override void CorrectCertificateState(X509Certificate2 candidate)
134137
{
135138
try
136139
{
137-
// Ensure that the directory exists before writing to the file.
138-
Directory.CreateDirectory(MacOSUserHttpsCertificateLocation);
139-
140+
// This path is in a well-known folder, so we trust the permissions.
140141
var certificatePath = GetCertificateFilePath(candidate);
141142
ExportCertificate(candidate, certificatePath, includePrivateKey: true, null, CertificateKeyExportFormat.Pfx);
142143
}
@@ -152,6 +153,7 @@ public override TrustLevel GetTrustLevel(X509Certificate2 certificate)
152153
var tmpFile = Path.GetTempFileName();
153154
try
154155
{
156+
// We can't guarantee that the temp file is in a directory with sensible permissions, but we're not exporting the private key
155157
ExportCertificate(certificate, tmpFile, includePrivateKey: false, password: null, CertificateKeyExportFormat.Pem);
156158

157159
using var checkTrustProcess = Process.Start(new ProcessStartInfo(
@@ -316,7 +318,7 @@ protected override X509Certificate2 SaveCertificateCore(X509Certificate2 certifi
316318
}
317319

318320
// Ensure that the directory exists before writing to the file.
319-
Directory.CreateDirectory(MacOSUserHttpsCertificateLocation);
321+
CreateDirectoryWithPermissions(MacOSUserHttpsCertificateLocation);
320322

321323
File.WriteAllBytes(GetCertificateFilePath(certificate), certBytes);
322324
}
@@ -474,4 +476,22 @@ protected override void RemoveCertificateFromUserStoreCore(X509Certificate2 cert
474476
RemoveCertificateFromKeychain(MacOSUserKeychain, certificate);
475477
}
476478
}
479+
480+
protected override void CreateDirectoryWithPermissions(string directoryPath)
481+
{
482+
#pragma warning disable CA1416 // Validate platform compatibility (not supported on Windows)
483+
var dirInfo = new DirectoryInfo(directoryPath);
484+
if (dirInfo.Exists)
485+
{
486+
if ((dirInfo.UnixFileMode & ~DirectoryPermissions) != 0)
487+
{
488+
Log.DirectoryPermissionsNotSecure(dirInfo.FullName);
489+
}
490+
}
491+
else
492+
{
493+
Directory.CreateDirectory(directoryPath, DirectoryPermissions);
494+
}
495+
#pragma warning restore CA1416 // Validate platform compatibility
496+
}
477497
}

src/Shared/CertificateGeneration/UnixCertificateManager.cs

Lines changed: 26 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,8 @@ namespace Microsoft.AspNetCore.Certificates.Generation;
2020
/// </remarks>
2121
internal sealed partial class UnixCertificateManager : CertificateManager
2222
{
23+
private const UnixFileMode DirectoryPermissions = UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute;
24+
2325
/// <summary>The name of an environment variable consumed by OpenSSL to locate certificates.</summary>
2426
private const string OpenSslCertificateDirectoryVariableName = "SSL_CERT_DIR";
2527

@@ -74,7 +76,8 @@ public override TrustLevel GetTrustLevel(X509Certificate2 certificate)
7476
Log.UnixNotTrustedByDotnet();
7577
}
7678

77-
var nickname = GetCertificateNickname(certificate);
79+
// Will become the name of the file on disk and the nickname in the NSS DBs
80+
var certificateNickname = GetCertificateNickname(certificate);
7881

7982
var sslCertDirString = Environment.GetEnvironmentVariable(OpenSslCertificateDirectoryVariableName);
8083
if (string.IsNullOrEmpty(sslCertDirString))
@@ -88,7 +91,7 @@ public override TrustLevel GetTrustLevel(X509Certificate2 certificate)
8891
var sslCertDirs = sslCertDirString.Split(Path.PathSeparator);
8992
foreach (var sslCertDir in sslCertDirs)
9093
{
91-
var certPath = Path.Combine(sslCertDir, nickname + ".pem");
94+
var certPath = Path.Combine(sslCertDir, certificateNickname + ".pem");
9295
if (File.Exists(certPath))
9396
{
9497
var candidate = X509CertificateLoader.LoadCertificateFromFile(certPath);
@@ -125,7 +128,7 @@ public override TrustLevel GetTrustLevel(X509Certificate2 certificate)
125128
{
126129
foreach (var nssDb in nssDbs)
127130
{
128-
if (IsCertificateInNssDb(nickname, nssDb))
131+
if (IsCertificateInNssDb(certificateNickname, nssDb))
129132
{
130133
sawTrustSuccess = true;
131134
}
@@ -138,6 +141,7 @@ public override TrustLevel GetTrustLevel(X509Certificate2 certificate)
138141
}
139142
}
140143

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

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

456+
protected override void CreateDirectoryWithPermissions(string directoryPath)
457+
{
458+
#pragma warning disable CA1416 // Validate platform compatibility (not supported on Windows)
459+
var dirInfo = new DirectoryInfo(directoryPath);
460+
if (dirInfo.Exists)
461+
{
462+
if ((dirInfo.UnixFileMode & ~DirectoryPermissions) != 0)
463+
{
464+
Log.DirectoryPermissionsNotSecure(dirInfo.FullName);
465+
}
466+
}
467+
else
468+
{
469+
Directory.CreateDirectory(directoryPath, DirectoryPermissions);
470+
}
471+
#pragma warning restore CA1416 // Validate platform compatibility
472+
}
473+
452474
private static string GetChromiumNssDb(string homeDirectory)
453475
{
454476
return Path.Combine(homeDirectory, ".pki", "nssdb");

src/Shared/CertificateGeneration/WindowsCertificateManager.cs

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,10 @@
55
using System.Collections.Generic;
66
using System.Linq;
77
using System.Runtime.Versioning;
8+
using System.Security.AccessControl;
89
using System.Security.Cryptography;
910
using System.Security.Cryptography.X509Certificates;
11+
using System.Security.Principal;
1012

1113
namespace Microsoft.AspNetCore.Certificates.Generation;
1214

@@ -126,4 +128,41 @@ protected override IList<X509Certificate2> GetCertificatesToRemove(StoreName sto
126128
{
127129
return ListCertificates(storeName, storeLocation, isValid: false);
128130
}
131+
132+
protected override void CreateDirectoryWithPermissions(string directoryPath)
133+
{
134+
var dirInfo = new DirectoryInfo(directoryPath);
135+
136+
if (!dirInfo.Exists)
137+
{
138+
// We trust the default permissions on Windows enough not to apply custom ACLs.
139+
// We'll warn below if things seem really off.
140+
dirInfo.Create();
141+
}
142+
143+
var currentUser = WindowsIdentity.GetCurrent();
144+
var currentUserSid = currentUser.User;
145+
var systemSid = new SecurityIdentifier(WellKnownSidType.LocalSystemSid, domainSid: null);
146+
var adminGroupSid = new SecurityIdentifier(WellKnownSidType.BuiltinAdministratorsSid, domainSid: null);
147+
148+
var dirSecurity = dirInfo.GetAccessControl();
149+
var accessRules = dirSecurity.GetAccessRules(true, true, typeof(SecurityIdentifier));
150+
151+
foreach (FileSystemAccessRule rule in accessRules)
152+
{
153+
var idRef = rule.IdentityReference;
154+
if (rule.AccessControlType == AccessControlType.Allow &&
155+
!idRef.Equals(currentUserSid) &&
156+
!idRef.Equals(systemSid) &&
157+
!idRef.Equals(adminGroupSid))
158+
{
159+
// This is just a heuristic - determining whether the cumulative effect of the rules
160+
// is to allow access to anyone other than the current user, system, or administrators
161+
// is very complicated. We're not going to do anything but log, so an approximation
162+
// is fine.
163+
Log.DirectoryPermissionsNotSecure(dirInfo.FullName);
164+
break;
165+
}
166+
}
167+
}
129168
}

src/Tools/FirstRunCertGenerator/test/CertificateManagerTests.cs

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -352,6 +352,30 @@ public void EnsureCreateHttpsCertificate_CanExportTheCertInPemFormat_WithoutPass
352352
Assert.Equal(httpsCertificate.GetCertHashString(), exportedCertificate.GetCertHashString());
353353
}
354354

355+
[ConditionalFact]
356+
[SkipOnHelix("https://github.com/dotnet/aspnetcore/issues/6720", Queues = "All.OSX")]
357+
public void EnsureCreateHttpsCertificate_CannotExportToNonExistentDirectory()
358+
{
359+
// Arrange
360+
const string CertificateName = nameof(EnsureCreateHttpsCertificate_CannotExportToNonExistentDirectory) + ".pem";
361+
362+
_fixture.CleanupCertificates();
363+
364+
var now = DateTimeOffset.UtcNow;
365+
now = new DateTimeOffset(now.Year, now.Month, now.Day, now.Hour, now.Minute, now.Second, 0, now.Offset);
366+
var creation = _manager.EnsureAspNetCoreHttpsDevelopmentCertificate(now, now.AddYears(1), path: null, trust: false, isInteractive: false);
367+
Output.WriteLine(creation.ToString());
368+
ListCertificates();
369+
370+
// Act
371+
// Export the certificate (same method, but this time with an output path)
372+
var result = _manager
373+
.EnsureAspNetCoreHttpsDevelopmentCertificate(now, now.AddYears(1), Path.Combine("NoSuchDirectory", CertificateName));
374+
375+
// Assert
376+
Assert.Equal(EnsureCertificateResult.ErrorExportingTheCertificate, result);
377+
}
378+
355379
[Fact]
356380
public void EnsureCreateHttpsCertificate_ReturnsExpiredCertificateIfVersionIsIncorrect()
357381
{

0 commit comments

Comments
 (0)