[release/v7.6.7] Tolerate IO/JSON errors reading the per-user powershell.config.json (#28084)

This commit is contained in:
Aditya Patwardhan authored and GitHub committed 2026-09-24 10:14:50 -07:00
1 parent e8bfcaa532
commit b613dd796f
3 files changed
+176 -4

No files matched your search

@@ -5,6 +5,7 @@ using System;
using System.Collections.Generic;
using System.IO;
using System.Management.Automation.Internal;
using System.Security;
using System.Text;
using System.Threading;
@@ -411,6 +412,23 @@ namespace System.Management.Automation.Configuration
configData = serializer.Deserialize<JObject>(jsonReader) ?? emptyConfig;
}
catch (Exception exc) when (
scope == ConfigScope.CurrentUser &&
(exc is IOException || exc is UnauthorizedAccessException || exc is SecurityException || exc is JsonException))
{
// The per-user configuration file is unreadable (e.g. OneDrive cloud file provider
// not running, permission denied, ACL denied, or corrupt/malformed JSON). Rather
// than failing pwsh startup, fall back to defaults and emit a warning so the user
// knows the file was skipped. See https://github.com/PowerShell/PowerShell/issues/27370.
//
// We intentionally do NOT apply this fallback to ConfigScope.AllUsers: that file is
// admin-owned and on non-Windows platforms is the only source for security-relevant
// policies (ScriptBlockLogging, Transcription, ConsoleSessionConfiguration, etc.).
// If we cannot prove the admin's intent we must fail closed.
TryWriteConfigWarning(StringUtil.Format(PSConfigurationStrings.CanNotReadConfigurationFile, fileName, exc.Message));
configData = emptyConfig;
}
catch (Exception exc)
{
throw PSTraceSource.NewInvalidOperationException(exc, PSConfigurationStrings.CanNotConfigurationFile, args: fileName);
@@ -435,12 +453,47 @@ namespace System.Management.Automation.Configuration
if (configData != emptyConfig && configData.TryGetValue(key, StringComparison.OrdinalIgnoreCase, out JToken jToken))
{
return jToken.ToObject<T>(serializer) ?? defaultValue;
try
{
return jToken.ToObject<T>(serializer) ?? defaultValue;
}
catch (JsonException exc) when (scope == ConfigScope.CurrentUser)
{
// The per-user JSON parsed successfully, but the value at <key> can't be
// materialized as T (wrong type, malformed sub-document, etc.). Fall back
// to the caller's default for this setting and emit a warning, mirroring
// the file-level fallback above. AllUsers stays fail-closed for
// security-relevant policies.
// See https://github.com/PowerShell/PowerShell/issues/27370.
TryWriteConfigWarning(StringUtil.Format(PSConfigurationStrings.CanNotReadConfigurationValue, key, fileName, exc.Message));
return defaultValue;
}
}
return defaultValue;
}
/// <summary>
/// Best-effort emission of a warning when the per-user configuration file (or one of its
/// values) can't be read. <see cref="ReadValueFromFile"/> is invoked very early in pwsh
/// startup -- before any host, runspace, or PowerShell warning stream exists -- so we
/// write directly to <see cref="Console.Error"/>. This matches existing startup-time
/// stderr writes in ConsoleHost (e.g. CannotLoadPSReadline). Centralizing this here
/// keeps formatting consistent and gives future hosts a single seam to swap the channel.
/// Failures inside the writer itself are swallowed so logging issues never block startup.
/// </summary>
private static void TryWriteConfigWarning(string message)
{
try
{
Console.Error.WriteLine("WARNING: " + message);
}
catch
{
// Best-effort warning; never let logging failures block startup.
}
}
private static FileStream OpenFileStreamWithRetry(string fullPath, FileMode mode, FileAccess access, FileShare share)
{
const int MaxTries = 5;
@@ -120,4 +120,10 @@
<data name="CanNotConfigurationFile" xml:space="preserve">
<value>PowerShell has stopped working because of a security issue: Cannot read the configuration file: {0}</value>
</data>
<data name="CanNotReadConfigurationFile" xml:space="preserve">
<value>Cannot read the PowerShell configuration file '{0}': {1}. Falling back to default settings.</value>
</data>
<data name="CanNotReadConfigurationValue" xml:space="preserve">
<value>Cannot read setting '{0}' from PowerShell configuration file '{1}': {2}. Falling back to default for this setting.</value>
</data>
</root>
+116 -3
View File
@@ -382,6 +382,28 @@ namespace PSTests.Sequential
CreateBrokenConfigFile(currentUserConfigFile);
}
public void SetupBrokenCurrentUserConfigOnly()
{
CleanupConfigFiles();
// Only the per-user config file is broken; the system-wide file is absent.
// Used to verify the #27370 fail-open path for ConfigScope.CurrentUser.
CreateBrokenConfigFile(currentUserConfigFile);
}
public void SetupTypeMismatchedCurrentUserConfig()
{
CleanupConfigFiles();
// Per-user config is syntactically valid JSON, but PowerShellPolicies has the
// wrong nested shape: ScriptExecution should be an object but is a string.
// This triggers JsonSerializationException inside jToken.ToObject<T>() and
// exercises the per-key fail-open path for #27370.
File.WriteAllText(
currentUserConfigFile,
"{ \"PowerShellPolicies\": { \"ScriptExecution\": \"not-an-object\" } }");
}
private static void CreateBrokenConfigFile(string fileName)
{
File.WriteAllText(fileName, "[abbra");
@@ -978,14 +1000,105 @@ namespace PSTests.Sequential
fixture.CompareConsoleSessionConfiguration(consoleSessionConfiguration, null);
}
[Fact, Priority(11)]
public void PowerShellConfig_GetPowerShellPolicies_BrokenSystemConfig()
[Fact]
[Priority(11)]
public void PowerShellConfig_BrokenSystemConfig_Throws_BrokenCurrentUserConfig_FallsBack()
{
fixture.SetupConfigFile5();
fixture.ForceReadingFromFile();
// Broken system-wide config still hard-fails (admin-owned, security-relevant).
Assert.Throws<System.Management.Automation.PSInvalidOperationException>(() => PowerShellConfig.Instance.GetPowerShellPolicies(ConfigScope.AllUsers));
Assert.Throws<System.Management.Automation.PSInvalidOperationException>(() => PowerShellConfig.Instance.GetPowerShellPolicies(ConfigScope.CurrentUser));
// Broken per-user config falls back to defaults (#27370).
PowerShellPolicies currentUserPolicies = PowerShellConfig.Instance.GetPowerShellPolicies(ConfigScope.CurrentUser);
Assert.Null(currentUserPolicies);
}
[Fact]
[Priority(12)]
public void PowerShellConfig_BrokenCurrentUserConfig_FallsBackToDefaults()
{
fixture.SetupBrokenCurrentUserConfigOnly();
fixture.ForceReadingFromFile();
// ExecutionPolicy returns null (caller falls back to GP / registry / Restricted).
string execPolicy = PowerShellConfig.Instance.GetExecutionPolicy(ConfigScope.CurrentUser, "Microsoft.PowerShell");
Assert.Null(execPolicy);
// Module path returns null (caller uses built-in defaults).
string modulePath = PowerShellConfig.Instance.GetModulePath(ConfigScope.CurrentUser);
Assert.Null(modulePath);
// Experimental features list is empty.
string[] features = PowerShellConfig.Instance.GetExperimentalFeatures();
Assert.Empty(features);
}
[Fact]
[Priority(13)]
public void PowerShellConfig_TypeMismatchInCurrentUserConfig_FallsBackToDefaults()
{
// Verifies the per-key fail-open path: the file parses as JSON, but a value
// can't be materialized as its target type. Without this fallback the
// JsonSerializationException would propagate out of GetPolicySetting and
// crash startup just like #27370.
fixture.SetupTypeMismatchedCurrentUserConfig();
fixture.ForceReadingFromFile();
PowerShellPolicies policies = PowerShellConfig.Instance.GetPowerShellPolicies(ConfigScope.CurrentUser);
Assert.Null(policies);
}
[Fact]
[Priority(14)]
public void PowerShellConfig_BrokenCurrentUserConfig_EmitsWarningToStderr()
{
// A regression in the warning path would still let startup succeed (this is the
// whole point of the fail-open) -- but the user would silently lose their config
// with no indication. This test pins the user-visible warning so that doesn't
// happen quietly.
fixture.SetupBrokenCurrentUserConfigOnly();
fixture.ForceReadingFromFile();
string stderr = CaptureStderr(() =>
PowerShellConfig.Instance.GetExecutionPolicy(ConfigScope.CurrentUser, "Microsoft.PowerShell"));
Assert.Contains("WARNING:", stderr);
Assert.Contains("Falling back to default settings", stderr);
}
[Fact]
[Priority(15)]
public void PowerShellConfig_TypeMismatchInCurrentUserConfig_EmitsWarningToStderr()
{
// Pin the user-visible warning for the per-key fail-open path as well.
fixture.SetupTypeMismatchedCurrentUserConfig();
fixture.ForceReadingFromFile();
string stderr = CaptureStderr(() =>
PowerShellConfig.Instance.GetPowerShellPolicies(ConfigScope.CurrentUser));
Assert.Contains("WARNING:", stderr);
Assert.Contains("PowerShellPolicies", stderr);
Assert.Contains("Falling back to default for this setting", stderr);
}
private static string CaptureStderr(Action action)
{
var captured = new StringWriter();
TextWriter originalError = Console.Error;
try
{
Console.SetError(captured);
action();
}
finally
{
Console.SetError(originalError);
}
return captured.ToString();
}
}
}