fix(secrets): harden SecretName (reject rooted paths, fail-fast on default) + keep SecretDecryptionException sealed
This commit is contained in:
@@ -16,8 +16,9 @@ public readonly record struct SecretName
|
|||||||
/// <param name="value">The raw name to normalize and validate.</param>
|
/// <param name="value">The raw name to normalize and validate.</param>
|
||||||
/// <exception cref="ArgumentException">
|
/// <exception cref="ArgumentException">
|
||||||
/// The value is null, empty, or whitespace; contains the path-traversal sequence
|
/// The value is null, empty, or whitespace; contains the path-traversal sequence
|
||||||
/// <c>..</c>; or contains any character outside <c>[a-z0-9._/-]</c> (after trim and
|
/// <c>..</c>; is rooted (starts or ends with <c>/</c>) or contains an empty path
|
||||||
/// lowercasing).
|
/// segment (<c>//</c>); or contains any character outside <c>[a-z0-9._/-]</c> (after
|
||||||
|
/// trim and lowercasing).
|
||||||
/// </exception>
|
/// </exception>
|
||||||
public SecretName(string value)
|
public SecretName(string value)
|
||||||
{
|
{
|
||||||
@@ -37,6 +38,14 @@ public readonly record struct SecretName
|
|||||||
nameof(value));
|
nameof(value));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (normalized.StartsWith('/') || normalized.EndsWith('/') ||
|
||||||
|
normalized.Contains("//", StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
throw new ArgumentException(
|
||||||
|
$"Secret name must not be rooted or contain an empty path segment: it may not start or end with '/' or contain '//' (was '{value}').",
|
||||||
|
nameof(value));
|
||||||
|
}
|
||||||
|
|
||||||
foreach (char c in normalized)
|
foreach (char c in normalized)
|
||||||
{
|
{
|
||||||
if (!AllowedChars.Contains(c, StringComparison.Ordinal))
|
if (!AllowedChars.Contains(c, StringComparison.Ordinal))
|
||||||
@@ -47,17 +56,22 @@ public readonly record struct SecretName
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Value = normalized;
|
_value = normalized;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private readonly string? _value;
|
||||||
|
|
||||||
/// <summary>The normalized secret name (trimmed, lowercased, allow-list validated).</summary>
|
/// <summary>The normalized secret name (trimmed, lowercased, allow-list validated).</summary>
|
||||||
public string Value { get; }
|
/// <exception cref="InvalidOperationException">The instance is the uninitialized <c>default</c> struct value.</exception>
|
||||||
|
public string Value => _value ?? throw new InvalidOperationException("SecretName is uninitialized (default struct value).");
|
||||||
|
|
||||||
/// <summary>Returns the normalized <see cref="Value"/>.</summary>
|
/// <summary>Returns the normalized <see cref="Value"/>.</summary>
|
||||||
/// <returns>The normalized secret name.</returns>
|
/// <returns>The normalized secret name.</returns>
|
||||||
|
/// <exception cref="InvalidOperationException">The instance is the uninitialized <c>default</c> struct value.</exception>
|
||||||
public override string ToString() => Value;
|
public override string ToString() => Value;
|
||||||
|
|
||||||
/// <summary>Implicitly converts a <see cref="SecretName"/> to its underlying string value.</summary>
|
/// <summary>Implicitly converts a <see cref="SecretName"/> to its underlying string value.</summary>
|
||||||
/// <param name="name">The secret name to convert.</param>
|
/// <param name="name">The secret name to convert.</param>
|
||||||
|
/// <exception cref="InvalidOperationException">The instance is the uninitialized <c>default</c> struct value.</exception>
|
||||||
public static implicit operator string(SecretName name) => name.Value;
|
public static implicit operator string(SecretName name) => name.Value;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,4 +18,22 @@ public class SecretNameTests
|
|||||||
[InlineData("../escape")]
|
[InlineData("../escape")]
|
||||||
public void RejectsInvalid(string input)
|
public void RejectsInvalid(string input)
|
||||||
=> Assert.Throws<ArgumentException>(() => new SecretName(input));
|
=> Assert.Throws<ArgumentException>(() => new SecretName(input));
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("/etc/passwd")] // rooted / absolute
|
||||||
|
[InlineData("/")] // rooted (starts and ends with '/')
|
||||||
|
[InlineData("sql//foo")] // empty path segment
|
||||||
|
[InlineData("sql/foo/")] // trailing '/'
|
||||||
|
public void RejectsRootedOrEmptySegment(string input)
|
||||||
|
=> Assert.Throws<ArgumentException>(() => new SecretName(input));
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void DefaultInstance_ThrowsOnValue()
|
||||||
|
=> Assert.Throws<InvalidOperationException>(() => _ = default(SecretName).Value);
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ValidNestedName_RoundTrips()
|
||||||
|
=> Assert.Equal(
|
||||||
|
"sql/historiangw/historian-password",
|
||||||
|
new SecretName("sql/historiangw/historian-password").Value);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user