Skip to content
Draft
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
ο»Ώusing System.ComponentModel.DataAnnotations;
using System.Text.Json.Serialization;

namespace Bit.Services.Pam.Api.Models.Request;

/// <summary>
/// Base class for access conditions. Each condition is discriminated by the <c>kind</c> field,
/// which may appear at any position in the object β€” the server enables
/// <see cref="System.Text.Json.JsonSerializerOptions.AllowOutOfOrderMetadataProperties"/> (see
/// <c>AddPamServices</c>) to match the SDK's serde tagging, which accepts the tag anywhere.
/// Supported kinds: <c>human_approval</c> (bare object, no payload) and <c>ip_allowlist</c>
/// (requires a non-empty <c>cidrs</c> list of canonical, host-bit-free CIDR strings). An unknown
/// kind β€” and any unknown member within a known kind β€” is rejected at deserialization time with
/// <see cref="System.Text.Json.JsonException"/> (fail-closed, surfaced as a 400 by request
/// binding): silently dropping an unrecognized constraint would enforce the condition more
/// loosely than the caller intended.
///
/// <para>Deliberately concrete, not abstract: a payload with no <c>kind</c> binds to this base
/// type instead of throwing <see cref="NotSupportedException"/> β€” which would escape request
/// binding as a 500 β€” and is then rejected by <see cref="AccessRuleRequestModel.Validate"/> with
/// a structured 400. Do not instantiate it outside of that binding fallback.</para>
/// </summary>
[JsonPolymorphic(TypeDiscriminatorPropertyName = "kind")]
[JsonDerivedType(typeof(HumanApprovalConditionModel), "human_approval")]
[JsonDerivedType(typeof(IpAllowlistConditionModel), "ip_allowlist")]
[JsonUnmappedMemberHandling(JsonUnmappedMemberHandling.Disallow)]
public class AccessConditionModel { }

/// <summary>
/// A condition requiring human approval before access is granted.
/// No additional payload β€” the presence of this condition in the rule's list is sufficient.
/// </summary>
[JsonUnmappedMemberHandling(JsonUnmappedMemberHandling.Disallow)]
public class HumanApprovalConditionModel : AccessConditionModel { }

/// <summary>
/// A condition restricting access to requests originating from one of the listed CIDR ranges.
/// The <c>cidrs</c> list must contain between 1 and 100 entries; each entry must be a canonical,
/// host-bit-free CIDR in the form <c>address/prefix</c>.
/// </summary>
[JsonUnmappedMemberHandling(JsonUnmappedMemberHandling.Disallow)]
public class IpAllowlistConditionModel : AccessConditionModel, IValidatableObject
{
/// <summary>
/// Between 1 and 100 CIDR ranges (e.g. <c>10.0.0.0/8</c>, <c>2001:db8::/32</c>).
/// Each entry must be a canonical, host-bit-free CIDR string.
/// </summary>
[Required]
[MinLength(1, ErrorMessage = "An IP allowlist condition must contain at least one CIDR range.")]
[MaxLength(100, ErrorMessage = "An IP allowlist condition may contain at most 100 CIDR ranges.")]
public List<string> Cidrs { get; set; } = [];

/// <inheritdoc />
public IEnumerable<ValidationResult> Validate(ValidationContext validationContext)
{
for (var i = 0; i < Cidrs.Count; i++)
{
var cidr = Cidrs[i];
// The length cap blocks degenerate-but-parseable forms (arbitrarily zero-padded
// prefixes like "10.0.0.0/000...08") from being persisted; every legitimate CIDR fits
// in 50 characters. The failing entry is identified by index only β€” user input must
// stay out of error messages and logs.
if (cidr is { Length: > 256 } || !CidrValidator.IsValid(cidr))
{
yield return new ValidationResult(
$"Cidrs[{i}] is not a valid canonical CIDR range.",
[$"{nameof(Cidrs)}[{i}]"]);
}
}
}
}
Original file line number Diff line number Diff line change
@@ -1,9 +1,27 @@
ο»Ώusing System.ComponentModel.DataAnnotations;
using System.Text.Json;

namespace Bit.Services.Pam.Api.Models.Request;

public class AccessRuleRequestModel
public class AccessRuleRequestModel : IValidatableObject
{
// Conditions are carried on the wire as verbatim JSON and stored verbatim, so the generated
// SDK clients bind them as an opaque value rather than a typed union (openapi-generator turns a
// oneOf into a lossy untagged enum). Server-side we still validate fail-closed by decoding that
// JSON a second time into the typed AccessConditionModel union in Validate() β€” see below. These
// options mirror the binding defaults (camelCase) and, like the SDK's serde tagging, accept the
// `kind` discriminator at any position in the object.
private static readonly JsonSerializerOptions ConditionsSerializerOptions =
new(JsonSerializerDefaults.Web)
{
AllowOutOfOrderMetadataProperties = true,
};

/// <summary>
/// The maximum number of conditions a single rule may carry.
/// </summary>
private const int MaxConditions = 10;

/// <summary>
/// The rule's display name, shown wherever rules are listed and managed. Required; up to 256 characters.
/// </summary>
Expand All @@ -23,12 +41,17 @@ public class AccessRuleRequestModel
public bool Enabled { get; set; } = true;

/// <summary>
/// The condition tree that decides how access is granted under this rule β€” for example requiring human
/// approval, or restricting to certain times of day or source IPs. Sent as a JSON object and stored verbatim;
/// an empty or null value means the rule imposes no conditions.
/// The conditions that govern when this access rule permits a request β€” for example requiring human
/// approval, or restricting to certain source IPs. Carried and stored as verbatim JSON: an array of
/// kind-tagged objects (<c>{"kind":"human_approval"}</c>, <c>{"kind":"ip_allowlist","cidrs":[...]}</c>).
/// The array itself is required (an explicitly empty array means the rule imposes no conditions); its
/// contents are validated fail-closed server-side by decoding each entry into a typed condition union.
/// Supported kinds: <c>human_approval</c> (bare object, no payload) and <c>ip_allowlist</c> (a non-empty
/// <c>cidrs</c> list of canonical, host-bit-free CIDR strings). Unknown kinds, unknown members, and
/// malformed payloads are rejected. Maximum 10 conditions.
/// </summary>
[Required]
public object Conditions { get; set; } = null!;
public JsonElement? Conditions { get; set; }

/// <summary>
/// When true, the rule enforces a per-cipher singleton (at most one active lease per cipher across all users).
Expand Down Expand Up @@ -65,4 +88,92 @@ public class AccessRuleRequestModel
/// </summary>
[Required]
public IEnumerable<Guid> Collections { get; set; } = null!;

/// <inheritdoc />
public IEnumerable<ValidationResult> Validate(ValidationContext validationContext)
{
// A null/absent conditions field is handled by [Required]; nothing more to do here.
if (Conditions is not { } conditions || conditions.ValueKind == JsonValueKind.Null)
{
yield break;
}

if (conditions.ValueKind != JsonValueKind.Array)
{
yield return new ValidationResult(
"Conditions must be an array of condition objects.",
[nameof(Conditions)]);
yield break;
}

if (conditions.GetArrayLength() > MaxConditions)
{
yield return new ValidationResult(
$"A rule may have at most {MaxConditions} conditions.",
[nameof(Conditions)]);
}

var index = 0;
foreach (var element in conditions.EnumerateArray())
{
var member = $"{nameof(Conditions)}[{index}]";
index++;

AccessConditionModel? condition = null;
var decodeFailed = false;
try
{
// The second decode: verbatim JSON -> typed union. Unknown kinds, unknown members,
// non-object elements, and non-string discriminators all fail closed here rather
// than being silently accepted (which would enforce a condition more loosely than
// the caller intended).
condition = element.Deserialize<AccessConditionModel>(ConditionsSerializerOptions);
}
catch (JsonException)
{
decodeFailed = true;
}

if (decodeFailed)
{
// The offending input is identified by index only β€” user input must not appear in
// error messages or logs.
yield return new ValidationResult($"{member} is not a valid condition.", [member]);
continue;
}

// A JSON null element decodes to null.
if (condition is null)
{
yield return new ValidationResult($"{member} must not be null.", [member]);
continue;
}

// A payload with no `kind` binds to the concrete base type (see AccessConditionModel);
// reject it rather than accepting a meaningless no-op condition.
if (condition.GetType() == typeof(AccessConditionModel))
{
yield return new ValidationResult(
$"{member} must include a 'kind' discriminator.",
[member]);
continue;
}

// Recurse into the decoded condition β€” Validator does not walk nested objects β€” and
// prefix member names with the index so callers can identify which item failed.
var conditionContext = new ValidationContext(condition);
var conditionResults = new List<ValidationResult>();
if (!Validator.TryValidateObject(condition, conditionContext, conditionResults, validateAllProperties: true))
{
foreach (var result in conditionResults)
{
var memberNames = result.MemberNames
.Select(m => $"{member}.{m}")
.DefaultIfEmpty(member)
.ToArray();
yield return new ValidationResult(result.ErrorMessage, memberNames);
}
}
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
ο»Ώusing System.Globalization;
using System.Net;
using System.Net.Sockets;

namespace Bit.Services.Pam.Api.Models.Request;

/// <summary>
/// Validates CIDR range strings using the same rules as the Bitwarden SDK's <c>is_valid_cidr</c>:
/// canonical address form, a plain decimal prefix in the valid range for the address family, and
/// no host bits set.
///
/// <para><b>IPv4</b>: the address must round-trip through <see cref="IPAddress.ToString"/> unchanged
/// (rejects leading-zero octets, hex octets, and partial addresses).</para>
///
/// <para><b>IPv6</b>: any RFC-4291 textual form that <see cref="IPAddress.TryParse(string?,out IPAddress?)"/> accepts is
/// valid as long as it contains no zone ID (<c>%</c>), no bracketed form (<c>[</c>/<c>]</c>), and
/// is not an IPv4-mapped address (<c>::ffff:a.b.c.d</c>) β€” the SDK rejects mapped addresses as
/// ambiguous with the native IPv4 range. Leading zeros in hextets and uncompressed forms such as
/// <c>2001:0db8::/32</c> or <c>2001:db8:0:0:0:0:0:0/32</c> are accepted β€” matching Rust's
/// <c>Ipv6Addr::from_str</c>, which compares by value.</para>
/// </summary>
internal static class CidrValidator
{
/// <summary>
/// Returns <see langword="true"/> when <paramref name="value"/> is a valid CIDR range:
/// <c>address/prefix</c> where the address parses for its family (see class-level rules),
/// the prefix is a plain decimal integer in the valid range (0–32 for IPv4, 0–128 for IPv6),
/// and no host bits are set (e.g. <c>10.0.0.0/8</c> is valid; <c>10.0.0.1/8</c> is not).
/// A <see langword="null"/> value returns <see langword="false"/>.
/// </summary>
public static bool IsValid(string? value)
{
// JSON binding can produce null list entries; treat them as invalid rather than throwing.
if (value is null)
{
return false;
}

// Split on the first '/' only β€” mirrors Rust's split_once('/').
var slashIndex = value.IndexOf('/');
if (slashIndex < 0)
{
return false;
}

var addrPart = value[..slashIndex];
var prefixPart = value[(slashIndex + 1)..];

// Empty address or empty prefix are invalid.
if (addrPart.Length == 0 || prefixPart.Length == 0)
{
return false;
}

// Parse prefix. byte covers 0–255, sufficient for both IPv4 (max 32) and IPv6 (max 128).
// NumberStyles.None rejects signs, whitespace, and any non-digit (e.g. the "8/8" prefix
// part of "10.0.0.0/8/8") while still accepting leading zeros such as "08" β€” matching the
// Rust SDK's u8 parsing (test prefix_with_leading_zero_is_valid).
if (!byte.TryParse(prefixPart, NumberStyles.None, CultureInfo.InvariantCulture, out var prefix))
{
return false;
}

// Reject IPv6 zone IDs (e.g. "fe80::1%eth0") before calling TryParse. Rust's
// Ipv6Addr::from_str rejects zone IDs; the server must match.
if (addrPart.Contains('%'))
{
return false;
}

// Reject bracketed IPv6 forms (e.g. "[::1]") β€” Rust rejects those too.
if (addrPart.Contains('[') || addrPart.Contains(']'))
{
return false;
}

if (!IPAddress.TryParse(addrPart, out var ip))
{
return false;
}

return ip.AddressFamily switch
{
// IPv4: round-trip through ToString() to reject non-canonical forms such as
// leading-zero octets ("010.0.0.0") and partial addresses ("1.2.3"). IPAddress.Parse
// accepts these on some platforms; the round-tripped string differs from the input.
AddressFamily.InterNetwork =>
string.Equals(ip.ToString(), addrPart, StringComparison.OrdinalIgnoreCase)
&& prefix <= 32
&& NoHostBits(ip.GetAddressBytes(), 32 - prefix),

// IPv6: skip the round-trip check. Rust's Ipv6Addr::from_str accepts any RFC-4291
// textual form (leading zeros in hextets, uncompressed, etc.) and compares by value,
// so "2001:0db8::/32" and "2001:db8:0:0:0:0:0:0/32" are valid there. Requiring a
// round-trip here would make the server reject inputs the SDK accepts, causing a
// client-passes-then-server-400 failure mode. Zone IDs and bracketed forms are already
// rejected above before reaching TryParse. IPv4-mapped addresses (::ffff:a.b.c.d) are
// rejected to match the SDK (to_ipv4_mapped().is_none(), test
// ipv4_mapped_ipv6_is_invalid) β€” the mapped form is ambiguous with the native IPv4
// range, and accepting it here would let rules exist that SDK clients refuse to edit.
AddressFamily.InterNetworkV6 =>
!ip.IsIPv4MappedToIPv6
&& prefix <= 128
&& NoHostBits(ip.GetAddressBytes(), 128 - prefix),

_ => false,
};
}

/// <summary>
/// Returns <see langword="true"/> when the low <paramref name="hostBits"/> bits of the
/// big-endian address (4 bytes for IPv4, 16 for IPv6) are all zero.
/// </summary>
private static bool NoHostBits(byte[] addressBytes, int hostBits)
{
// Walk from the last byte backward, checking that the low hostBits bits are all zero.
var remaining = hostBits;
for (var i = addressBytes.Length - 1; i >= 0 && remaining > 0; i--)
{
var bitsInByte = Math.Min(remaining, 8);
var mask = (byte)((1 << bitsInByte) - 1);
if ((addressBytes[i] & mask) != 0)
{
return false;
}
remaining -= bitsInByte;
}
return true;
}
}
4 changes: 4 additions & 0 deletions bitwarden_license/src/Services/Pam/Pam.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -28,4 +28,8 @@
<ProjectReference Include="..\..\..\..\src\HttpExtensions\HttpExtensions.csproj" />
</ItemGroup>

<ItemGroup>
<InternalsVisibleTo Include="Pam.Test" />
</ItemGroup>

</Project>
Loading
Loading