Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
86 changes: 86 additions & 0 deletions Age.Tests/PluginTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,92 @@ public void PluginIdentity_ToString_ReturnsOriginalString()
Assert.Equal(str, identity.ToString());
}

// --- Plugin name validation (security: prevents arbitrary executable paths) ---

[Theory]
[InlineData("pwn/pwn")] // forward slash -> relative path to Process.Start
[InlineData("pwn\\pwn")] // backslash (Windows separator)
[InlineData("a b")] // space
public void PluginRecipient_ExtractPluginName_InvalidChars_Throws(string name)
{
// Valid bech32 (valid checksum), but the name carries a disallowed character.
var malicious = MakePluginRecipient(name);
var ex = Assert.Throws<FormatException>(() => PluginRecipient.ExtractPluginName(malicious));
Assert.Contains("invalid plugin name", ex.Message);
}

[Theory]
[InlineData("pwn/pwn")]
[InlineData("pwn\\pwn")]
public void PluginIdentity_ExtractPluginName_InvalidChars_Throws(string name)
{
var malicious = MakePluginIdentity(name);
var ex = Assert.Throws<FormatException>(() => PluginIdentity.ExtractPluginName(malicious));
Assert.Contains("invalid plugin name", ex.Message);
}

[Fact]
public void PluginRecipient_Constructor_PathSeparatorName_Throws()
{
// The separator must be rejected at construction, before any Process.Start can run.
Assert.Throws<FormatException>(() => new PluginRecipient(MakePluginRecipient("pwn/pwn")));
}

[Theory]
[InlineData("yubikey")]
[InlineData("fido2hmac")]
[InlineData("se-cure_plugin.v2")] // hyphen, underscore, dot, digit are all allowed
public void ExtractPluginName_ValidNames_Accepted(string name)
{
Assert.Equal(name, PluginRecipient.ExtractPluginName(MakePluginRecipient(name)));
}

[Fact]
public void PluginConnection_InvalidName_RefusesToLaunch()
{
// Defense in depth at the process sink itself.
var ex = Assert.Throws<AgePluginException>(() => new PluginConnection("pwn/pwn", "recipient-v1"));
Assert.Contains("invalid name", ex.Message);
}

[Theory]
[InlineData("")] // empty
[InlineData("pwn/pwn")] // forward slash
[InlineData("pwn\\pwn")] // backslash
[InlineData("a b")] // space
[InlineData("a\tb")] // tab
public void PluginNameValidator_RejectsInvalid(string name)
{
Assert.False(PluginNameValidator.IsValid(name));
}

[Theory]
[InlineData("yubikey")]
[InlineData("fido2hmac")]
[InlineData("se-cure_plugin.v2")]
public void PluginNameValidator_AcceptsValid(string name)
{
Assert.True(PluginNameValidator.IsValid(name));
}

[Fact]
public void PluginRecipient_ExtractPluginName_DegenerateHrp_ThrowsFormatException()
{
// HRP decodes to just "age" (no name) — must be a clean FormatException,
// not the ArgumentOutOfRangeException that hrp[4..] would have thrown.
var s = Bech32.Encode("age", new byte[] { 0x01, 0x02, 0x03 });
Assert.Throws<FormatException>(() => PluginRecipient.ExtractPluginName(s));
}

[Fact]
public void PluginIdentity_ExtractPluginName_DegenerateHrp_ThrowsFormatException()
{
// HRP decodes to exactly "age-plugin-" (no name) — must be a clean FormatException,
// not the ArgumentOutOfRangeException that hrp[11..^1] would have thrown.
var s = Bech32.Encode("age-plugin-", new byte[] { 0x01, 0x02, 0x03 });
Assert.Throws<FormatException>(() => PluginIdentity.ExtractPluginName(s));
}

// --- PluginConnection stanza I/O roundtrip ---

[Fact]
Expand Down
5 changes: 5 additions & 0 deletions Age/Plugin/PluginConnection.cs
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,11 @@ internal sealed class PluginConnection : IDisposable
/// </summary>
public PluginConnection(string pluginName, string stateMachine)
{
// Defense in depth: the caller should already have validated the name, but this is
// the sink that turns it into an executable path, so never launch an invalid one.
if (!PluginNameValidator.IsValid(pluginName))
throw new AgePluginException($"refusing to launch plugin with invalid name: '{pluginName}'");

var binaryName = $"age-plugin-{pluginName}";
var startInfo = new ProcessStartInfo
{
Expand Down
34 changes: 34 additions & 0 deletions Age/Plugin/PluginNameValidator.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
namespace Age.Plugin;

/// <summary>
/// Validation for age plugin names. A plugin name becomes part of the
/// <c>age-plugin-&lt;name&gt;</c> executable that is launched via <c>Process.Start</c>,
/// so it must never contain a path separator (or any other character that could turn
/// that filename into a relative or absolute path). The allowed set mirrors the
/// reference implementation's <c>validPluginName</c>.
/// </summary>
internal static class PluginNameValidator
{
private static bool IsAllowed(char c) =>
char.IsAsciiLetterOrDigit(c) || c is '+' or '-' or '.' or '_';

public static bool IsValid(string name)
{
if (name.Length == 0)
return false;

foreach (var c in name)
{
if (!IsAllowed(c))
return false;
}

return true;
}

/// <summary>Returns <paramref name="name"/> if valid, otherwise throws <see cref="FormatException"/>.</summary>
public static string Validate(string name) =>
IsValid(name)
? name
: throw new FormatException($"invalid plugin name: '{name}'");
}
9 changes: 7 additions & 2 deletions Age/Recipients/PluginIdentity.cs
Original file line number Diff line number Diff line change
Expand Up @@ -141,10 +141,15 @@ internal static string ExtractPluginName(string identity)
// Bech32-decode to get HRP. For "AGE-PLUGIN-YUBIKEY-1...", HRP = "age-plugin-yubikey-", name = HRP[11..^1] = "yubikey"
var (hrp, _) = Bech32.Decode(identity);

// skip "age-plugin-" prefix and trailing "-"
return hrp.StartsWith("age-plugin-")
// A plugin identity HRP is "age-plugin-<name>-"; require both affixes (with room
// between them) so the hrp[11..^1] slice can't run out of range on a malformed value.
var name = hrp.StartsWith("age-plugin-") && hrp.EndsWith("-") && hrp.Length > 11
? hrp[11..^1]
: throw new FormatException($"invalid plugin identity HRP: {hrp}");

// The name becomes the age-plugin-<name> executable path, so reject anything
// outside the allowed set (notably path separators) before it reaches Process.Start.
return PluginNameValidator.Validate(name);
}

public override string ToString() =>
Expand Down
9 changes: 7 additions & 2 deletions Age/Recipients/PluginRecipient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -135,10 +135,15 @@ internal static string ExtractPluginName(string recipient)
// Bech32-decode to get HRP. For "age1yubikey1...", HRP = "age1yubikey", name = HRP[4..] = "yubikey"
var (hrp, _) = Bech32.Decode(recipient);

// skip "age" + the "1" separator character encoded in hrp after "age"
return hrp.StartsWith("age")
// A plugin recipient HRP is "age1<name>"; require the "age1" prefix so hrp[4..]
// is always in range (a shorter HRP like "age" would otherwise throw).
var name = hrp.StartsWith("age1")
? hrp[4..]
: throw new FormatException($"invalid plugin recipient HRP: {hrp}");

// The name becomes the age-plugin-<name> executable path, so reject anything
// outside the allowed set (notably path separators) before it reaches Process.Start.
return PluginNameValidator.Validate(name);
}

public override string ToString() =>
Expand Down
Loading