diff --git a/bitwarden_license/src/Services/Pam/Api/Models/Request/AccessConditionModel.cs b/bitwarden_license/src/Services/Pam/Api/Models/Request/AccessConditionModel.cs new file mode 100644 index 000000000000..d3b130491042 --- /dev/null +++ b/bitwarden_license/src/Services/Pam/Api/Models/Request/AccessConditionModel.cs @@ -0,0 +1,71 @@ +using System.ComponentModel.DataAnnotations; +using System.Text.Json.Serialization; + +namespace Bit.Services.Pam.Api.Models.Request; + +/// +/// Base class for access conditions. Each condition is discriminated by the kind field, +/// which may appear at any position in the object — the server enables +/// (see +/// AddPamServices) to match the SDK's serde tagging, which accepts the tag anywhere. +/// Supported kinds: human_approval (bare object, no payload) and ip_allowlist +/// (requires a non-empty cidrs 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 +/// (fail-closed, surfaced as a 400 by request +/// binding): silently dropping an unrecognized constraint would enforce the condition more +/// loosely than the caller intended. +/// +/// Deliberately concrete, not abstract: a payload with no kind binds to this base +/// type instead of throwing — which would escape request +/// binding as a 500 — and is then rejected by with +/// a structured 400. Do not instantiate it outside of that binding fallback. +/// +[JsonPolymorphic(TypeDiscriminatorPropertyName = "kind")] +[JsonDerivedType(typeof(HumanApprovalConditionModel), "human_approval")] +[JsonDerivedType(typeof(IpAllowlistConditionModel), "ip_allowlist")] +[JsonUnmappedMemberHandling(JsonUnmappedMemberHandling.Disallow)] +public class AccessConditionModel { } + +/// +/// A condition requiring human approval before access is granted. +/// No additional payload — the presence of this condition in the rule's list is sufficient. +/// +[JsonUnmappedMemberHandling(JsonUnmappedMemberHandling.Disallow)] +public class HumanApprovalConditionModel : AccessConditionModel { } + +/// +/// A condition restricting access to requests originating from one of the listed CIDR ranges. +/// The cidrs list must contain between 1 and 100 entries; each entry must be a canonical, +/// host-bit-free CIDR in the form address/prefix. +/// +[JsonUnmappedMemberHandling(JsonUnmappedMemberHandling.Disallow)] +public class IpAllowlistConditionModel : AccessConditionModel, IValidatableObject +{ + /// + /// Between 1 and 100 CIDR ranges (e.g. 10.0.0.0/8, 2001:db8::/32). + /// Each entry must be a canonical, host-bit-free CIDR string. + /// + [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 Cidrs { get; set; } = []; + + /// + public IEnumerable 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}]"]); + } + } + } +} diff --git a/bitwarden_license/src/Services/Pam/Api/Models/Request/AccessRuleRequestModel.cs b/bitwarden_license/src/Services/Pam/Api/Models/Request/AccessRuleRequestModel.cs index 40e8458d0236..51f20b2d986f 100644 --- a/bitwarden_license/src/Services/Pam/Api/Models/Request/AccessRuleRequestModel.cs +++ b/bitwarden_license/src/Services/Pam/Api/Models/Request/AccessRuleRequestModel.cs @@ -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, + }; + + /// + /// The maximum number of conditions a single rule may carry. + /// + private const int MaxConditions = 10; + /// /// The rule's display name, shown wherever rules are listed and managed. Required; up to 256 characters. /// @@ -23,12 +41,17 @@ public class AccessRuleRequestModel public bool Enabled { get; set; } = true; /// - /// 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 ({"kind":"human_approval"}, {"kind":"ip_allowlist","cidrs":[...]}). + /// 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: human_approval (bare object, no payload) and ip_allowlist (a non-empty + /// cidrs list of canonical, host-bit-free CIDR strings). Unknown kinds, unknown members, and + /// malformed payloads are rejected. Maximum 10 conditions. /// [Required] - public object Conditions { get; set; } = null!; + public JsonElement? Conditions { get; set; } /// /// When true, the rule enforces a per-cipher singleton (at most one active lease per cipher across all users). @@ -65,4 +88,92 @@ public class AccessRuleRequestModel /// [Required] public IEnumerable Collections { get; set; } = null!; + + /// + public IEnumerable 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(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(); + 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); + } + } + } + } } diff --git a/bitwarden_license/src/Services/Pam/Api/Models/Request/CidrValidator.cs b/bitwarden_license/src/Services/Pam/Api/Models/Request/CidrValidator.cs new file mode 100644 index 000000000000..7d2585651ade --- /dev/null +++ b/bitwarden_license/src/Services/Pam/Api/Models/Request/CidrValidator.cs @@ -0,0 +1,130 @@ +using System.Globalization; +using System.Net; +using System.Net.Sockets; + +namespace Bit.Services.Pam.Api.Models.Request; + +/// +/// Validates CIDR range strings using the same rules as the Bitwarden SDK's is_valid_cidr: +/// canonical address form, a plain decimal prefix in the valid range for the address family, and +/// no host bits set. +/// +/// IPv4: the address must round-trip through unchanged +/// (rejects leading-zero octets, hex octets, and partial addresses). +/// +/// IPv6: any RFC-4291 textual form that accepts is +/// valid as long as it contains no zone ID (%), no bracketed form ([/]), and +/// is not an IPv4-mapped address (::ffff:a.b.c.d) — the SDK rejects mapped addresses as +/// ambiguous with the native IPv4 range. Leading zeros in hextets and uncompressed forms such as +/// 2001:0db8::/32 or 2001:db8:0:0:0:0:0:0/32 are accepted — matching Rust's +/// Ipv6Addr::from_str, which compares by value. +/// +internal static class CidrValidator +{ + /// + /// Returns when is a valid CIDR range: + /// address/prefix 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. 10.0.0.0/8 is valid; 10.0.0.1/8 is not). + /// A value returns . + /// + 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, + }; + } + + /// + /// Returns when the low bits of the + /// big-endian address (4 bytes for IPv4, 16 for IPv6) are all zero. + /// + 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; + } +} diff --git a/bitwarden_license/src/Services/Pam/Pam.csproj b/bitwarden_license/src/Services/Pam/Pam.csproj index 264ff0119b1a..229518764fd1 100644 --- a/bitwarden_license/src/Services/Pam/Pam.csproj +++ b/bitwarden_license/src/Services/Pam/Pam.csproj @@ -28,4 +28,8 @@ + + + + diff --git a/bitwarden_license/test/Services/Pam.Test/Api/Models/AccessConditionModelTests.cs b/bitwarden_license/test/Services/Pam.Test/Api/Models/AccessConditionModelTests.cs new file mode 100644 index 000000000000..7018432f4793 --- /dev/null +++ b/bitwarden_license/test/Services/Pam.Test/Api/Models/AccessConditionModelTests.cs @@ -0,0 +1,352 @@ +using System.ComponentModel.DataAnnotations; +using System.Text.Json; +using Bit.Services.Pam.Api.Models.Request; +using Xunit; + +namespace Bit.Services.Pam.Test.Api.Models; + +/// +/// Tests for the hierarchy: serialization round-trips, +/// polymorphic deserialization, and the fail-closed "double decode" validation path in +/// , where verbatim-JSON conditions are decoded into the typed +/// union and validated server-side. +/// +public class AccessConditionModelTests +{ + // The same options AccessRuleRequestModel decodes conditions with: Web defaults (camelCase) + // plus AllowOutOfOrderMetadataProperties so the 'kind' discriminator is accepted at any + // position, matching the SDK's serde tagging. + private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web) + { + AllowOutOfOrderMetadataProperties = true, + }; + + // ------------------------------------------------------------------------- + // Serialization tests + // ------------------------------------------------------------------------- + + [Fact] + public void HumanApproval_SerializesTo_BareKindOnly() + { + AccessConditionModel condition = new HumanApprovalConditionModel(); + + var json = JsonSerializer.Serialize(condition, JsonOptions); + + Assert.Equal("""{"kind":"human_approval"}""", json); + } + + [Fact] + public void IpAllowlist_SerializesTo_KindAndCidrs() + { + AccessConditionModel condition = new IpAllowlistConditionModel + { + Cidrs = ["10.0.0.0/8"] + }; + + var json = JsonSerializer.Serialize(condition, JsonOptions); + + Assert.Equal("""{"kind":"ip_allowlist","cidrs":["10.0.0.0/8"]}""", json); + } + + // ------------------------------------------------------------------------- + // Deserialization tests (the typed union, decoded in isolation) + // ------------------------------------------------------------------------- + + [Fact] + public void Deserialize_UnknownKind_ThrowsJsonException() + { + // An unrecognized discriminator value is rejected fail-closed. + Assert.Throws(() => + JsonSerializer.Deserialize( + """{"kind":"unknown_thing"}""", JsonOptions)); + } + + [Fact] + public void Deserialize_KindNotFirstProperty_Succeeds() + { + // serde's internally-tagged enums accept the tag at any position, so the server must too. + var condition = JsonSerializer.Deserialize( + """{"cidrs":["10.0.0.0/8"],"kind":"ip_allowlist"}""", JsonOptions); + + var ipAllowlist = Assert.IsType(condition); + var cidr = Assert.Single(ipAllowlist.Cidrs); + Assert.Equal("10.0.0.0/8", cidr); + } + + [Fact] + public void Deserialize_MissingKind_BindsToBaseType() + { + // Binding falls back to the concrete base instead of throwing; the base instance is then + // rejected by AccessRuleRequestModel.Validate (see MissingKindCondition_FailsValidation). + var condition = JsonSerializer.Deserialize("{}", JsonOptions); + + Assert.NotNull(condition); + Assert.Equal(typeof(AccessConditionModel), condition.GetType()); + } + + [Theory] + // No 'kind' means the payload binds as the (member-less) base type, so every property is an + // unmapped member and the payload fails to decode. + [InlineData("""{"cidrs":["10.0.0.0/8"]}""")] + // The discriminator matches case-sensitively, mirroring the SDK's serde tag, so a PascalCase + // "Kind" is an unknown member, not a discriminator. + [InlineData("""{"Kind":"human_approval"}""")] + public void Deserialize_MissingKindWithMembers_ThrowsJsonException(string json) + { + Assert.Throws(() => + JsonSerializer.Deserialize(json, JsonOptions)); + } + + [Theory] + [InlineData("""{"kind":null}""")] + [InlineData("""{"kind":42}""")] + public void Deserialize_NonStringKind_ThrowsJsonException(string json) + { + Assert.Throws(() => + JsonSerializer.Deserialize(json, JsonOptions)); + } + + [Fact] + public void Deserialize_NonObjectCondition_ThrowsJsonException() + { + Assert.Throws(() => + JsonSerializer.Deserialize( + "\"human_approval\"", JsonOptions)); + } + + [Theory] + // Unknown members are rejected fail-closed ([JsonUnmappedMemberHandling(Disallow)]): silently + // dropping an unrecognized constraint would enforce the condition more loosely than intended. + [InlineData("""{"kind":"human_approval","extra":"rejected"}""")] + [InlineData("""{"kind":"ip_allowlist","cidrs":["10.0.0.0/8"],"expires":"2026-08-01"}""")] + public void Deserialize_UnknownMember_ThrowsJsonException(string json) + { + Assert.Throws(() => + JsonSerializer.Deserialize(json, JsonOptions)); + } + + // ------------------------------------------------------------------------- + // Request validation: the "double decode" path + // ------------------------------------------------------------------------- + + [Fact] + public void EmptyConditionsArray_PassesValidation() + { + var (isValid, results) = Validate(RequestWith()); + + Assert.True(isValid, string.Join(", ", results.Select(r => r.ErrorMessage))); + } + + [Fact] + public void MissingConditions_FailsValidation() + { + // A request that omits the conditions field entirely must fail [Required] — an omitted + // field and an explicitly empty array are distinct contract states. + var (isValid, results) = Validate(RequestWithoutConditions()); + + Assert.False(isValid); + Assert.Contains(results, r => r.MemberNames.Contains(nameof(AccessRuleRequestModel.Conditions))); + } + + [Fact] + public void NonArrayConditions_FailsValidation() + { + var (isValid, results) = Validate(RequestWithRawConditions("""{"kind":"human_approval"}""")); + + Assert.False(isValid); + Assert.Contains(results, r => r.MemberNames.Contains(nameof(AccessRuleRequestModel.Conditions))); + } + + [Fact] + public void MissingKindCondition_FailsValidation() + { + // A payload with no 'kind' binds to the concrete base type; validation must reject it. + var (isValid, results) = Validate(RequestWithRawConditions("[{}]")); + + Assert.False(isValid); + Assert.Contains(results, r => r.MemberNames.Contains("Conditions[0]")); + } + + [Fact] + public void UnknownKindCondition_FailsValidation() + { + // The core fail-closed guarantee: a kind the server does not model is rejected, never + // silently accepted/dropped. + var (isValid, results) = Validate(RequestWithRawConditions("""[{"kind":"time_of_day"}]""")); + + Assert.False(isValid); + Assert.Contains(results, r => r.MemberNames.Contains("Conditions[0]")); + } + + [Fact] + public void UnknownMemberCondition_FailsValidation() + { + var (isValid, results) = Validate( + RequestWithRawConditions("""[{"kind":"human_approval","extra":"rejected"}]""")); + + Assert.False(isValid); + Assert.Contains(results, r => r.MemberNames.Contains("Conditions[0]")); + } + + [Fact] + public void KindNotFirstProperty_PassesValidation() + { + var (isValid, results) = Validate( + RequestWithRawConditions("""[{"cidrs":["10.0.0.0/8"],"kind":"ip_allowlist"}]""")); + + Assert.True(isValid, string.Join(", ", results.Select(r => r.ErrorMessage))); + } + + [Fact] + public void NullConditionElement_FailsValidation() + { + var (isValid, results) = Validate(RequestWithRawConditions("[null]")); + + Assert.False(isValid); + Assert.Contains(results, r => r.MemberNames.Contains("Conditions[0]")); + } + + [Fact] + public void NullCidrEntry_NestedInRequest_FailsValidation() + { + var (isValid, results) = Validate(RequestWith( + new IpAllowlistConditionModel { Cidrs = ["10.0.0.0/8", null!] })); + + Assert.False(isValid); + Assert.Contains(results, r => r.MemberNames.Any(m => m.StartsWith("Conditions[0]"))); + } + + [Fact] + public void InvalidCidrs_ReportIndexedMembers_WithoutEchoingValues() + { + var (isValid, results) = Validate(RequestWith( + new IpAllowlistConditionModel { Cidrs = ["10.0.0.0/8", "bad-cidr/99", "10.0.0.1/8"] })); + + Assert.False(isValid); + // Each failing entry gets its own indexed member key so clients can tell them apart... + Assert.Contains(results, r => r.MemberNames.Contains("Conditions[0].Cidrs[1]")); + Assert.Contains(results, r => r.MemberNames.Contains("Conditions[0].Cidrs[2]")); + Assert.DoesNotContain(results, r => r.MemberNames.Contains("Conditions[0].Cidrs[0]")); + // ...and the raw user-supplied value never appears in the message. + Assert.DoesNotContain(results, r => r.ErrorMessage!.Contains("bad-cidr")); + } + + [Fact] + public void OverlongCidrEntry_FailsValidation() + { + // A degenerate zero-padded prefix parses as a valid CIDR but must be blocked by the + // per-entry length cap before it can be persisted. + var overlong = "10.0.0.0/" + new string('0', 300) + "8"; + var (isValid, results) = Validate(RequestWith( + new IpAllowlistConditionModel { Cidrs = [overlong] })); + + Assert.False(isValid); + Assert.Contains(results, r => r.MemberNames.Contains("Conditions[0].Cidrs[0]")); + } + + [Fact] + public void ExactlyHundredCidrs_PassesValidation() + { + var cidrs = Enumerable.Range(1, 100).Select(i => $"10.{i}.0.0/16").ToList(); + var (isValid, results) = Validate(RequestWith(new IpAllowlistConditionModel { Cidrs = cidrs })); + + Assert.True(isValid, string.Join(", ", results.Select(r => r.ErrorMessage))); + } + + [Fact] + public void MoreThanHundredCidrs_FailsValidation() + { + var cidrs = Enumerable.Range(1, 101).Select(i => $"10.{i}.0.0/16").ToList(); + var (isValid, results) = Validate(RequestWith(new IpAllowlistConditionModel { Cidrs = cidrs })); + + Assert.False(isValid); + Assert.Contains(results, r => r.MemberNames.Any(m => m.StartsWith("Conditions[0]"))); + } + + [Fact] + public void MoreThanTenConditions_FailsValidation() + { + var conditions = Enumerable.Range(0, 11) + .Select(_ => (AccessConditionModel)new HumanApprovalConditionModel()) + .ToArray(); + var (isValid, results) = Validate(RequestWith(conditions)); + + Assert.False(isValid); + Assert.Contains(results, r => r.MemberNames.Contains(nameof(AccessRuleRequestModel.Conditions))); + } + + [Fact] + public void ExactlyTenConditions_PassesValidation() + { + var conditions = Enumerable.Range(0, 10) + .Select(_ => (AccessConditionModel)new HumanApprovalConditionModel()) + .ToArray(); + var (isValid, results) = Validate(RequestWith(conditions)); + + Assert.True(isValid, string.Join(", ", results.Select(r => r.ErrorMessage))); + } + + [Fact] + public void EmptyCidrs_NestedInRequest_FailsValidation() + { + var (isValid, results) = Validate(RequestWith(new IpAllowlistConditionModel { Cidrs = [] })); + + Assert.False(isValid); + Assert.Contains(results, r => r.MemberNames.Any(m => m.StartsWith("Conditions[0]"))); + } + + [Fact] + public void InvalidCidr_NestedInRequest_FailsValidation() + { + var (isValid, results) = Validate(RequestWith( + new IpAllowlistConditionModel { Cidrs = ["10.0.0.1/8"] })); // host bits set + + Assert.False(isValid); + Assert.Contains(results, r => r.MemberNames.Any(m => m.StartsWith("Conditions[0]"))); + } + + [Fact] + public void ValidConditions_PassValidation() + { + var (isValid, results) = Validate(RequestWith( + new HumanApprovalConditionModel(), + new IpAllowlistConditionModel { Cidrs = ["10.0.0.0/8", "2001:db8::/32"] })); + + Assert.True(isValid, string.Join(", ", results.Select(r => r.ErrorMessage))); + } + + // ------------------------------------------------------------------------- + // Helpers + // ------------------------------------------------------------------------- + + private static (bool IsValid, List Results) Validate(AccessRuleRequestModel model) + { + var results = new List(); + var isValid = Validator.TryValidateObject( + model, new ValidationContext(model), results, validateAllProperties: true); + return (isValid, results); + } + + // Builds a request whose Conditions is the given typed conditions serialized to verbatim JSON, + // exercising the same serialize -> store -> decode path the wire contract uses. + private static AccessRuleRequestModel RequestWith(params AccessConditionModel[] conditions) => + RequestWithRawConditions(JsonSerializer.Serialize(conditions, JsonOptions)); + + // Builds a request from a raw conditions JSON string, for shapes that can't be produced by + // serializing typed models (missing kind, null element, unknown members, non-array, ...). + private static AccessRuleRequestModel RequestWithRawConditions(string conditionsJson) => + new() + { + Name = "Test rule", + Conditions = JsonSerializer.Deserialize(conditionsJson), + Collections = [Guid.NewGuid()], + }; + + private static AccessRuleRequestModel RequestWithoutConditions() => + new() + { + Name = "Test rule", + Conditions = null, + Collections = [Guid.NewGuid()], + }; +} diff --git a/bitwarden_license/test/Services/Pam.Test/Api/Models/CidrValidatorTests.cs b/bitwarden_license/test/Services/Pam.Test/Api/Models/CidrValidatorTests.cs new file mode 100644 index 000000000000..d4ee9fb55930 --- /dev/null +++ b/bitwarden_license/test/Services/Pam.Test/Api/Models/CidrValidatorTests.cs @@ -0,0 +1,91 @@ +using Bit.Services.Pam.Api.Models.Request; +using Xunit; + +namespace Bit.Services.Pam.Test.Api.Models; + +/// +/// Unit tests for . The test data mirrors the positive and +/// negative tables from the Rust SDK's is_valid_cidr tests in +/// bitwarden-pam/src/access_rules/validate.rs. +/// +public class CidrValidatorTests +{ + // ------------------------------------------------------------------------- + // Valid CIDRs + // ------------------------------------------------------------------------- + + [Theory] + [InlineData("10.0.0.0/8")] + [InlineData("192.168.0.0/16")] + [InlineData("0.0.0.0/0")] + [InlineData("255.255.255.255/32")] + [InlineData("10.0.0.1/32")] // /32 single host — no host bits + [InlineData("::/0")] + [InlineData("::1/128")] + [InlineData("2001:db8::/32")] + [InlineData("fe80::/10")] + [InlineData("10.0.0.0/08")] // leading zero on prefix — valid (SDK: prefix_with_leading_zero_is_valid) + [InlineData("2001:db8::1/128")] // IPv6 single-host /128 — valid (SDK: ipv6_full_prefix_is_valid) + // IPv6 non-canonical-but-value-equivalent forms — valid (Rust Ipv6Addr::from_str compares by value) + [InlineData("2001:0db8::/32")] // leading zero in hextet + [InlineData("2001:DB8::/32")] // uppercase hextets + [InlineData("2001:db8:0:0:0:0:0:0/32")] // fully uncompressed form + public void IsValid_ValidCidrs_ReturnsTrue(string cidr) + { + Assert.True(CidrValidator.IsValid(cidr)); + } + + // ------------------------------------------------------------------------- + // Invalid CIDRs + // ------------------------------------------------------------------------- + + [Theory] + // Host bits set (IPv4) + [InlineData("10.0.0.1/8", "host bits set")] + [InlineData("10.0.0.0/0", "IPv4 /0 with non-zero address has host bits set")] + // Prefix out of range + [InlineData("10.0.0.0/33", "IPv4 prefix > 32")] + [InlineData("2001:db8::/129", "IPv6 prefix > 128")] + [InlineData("2001:db8::/300", "IPv6 prefix > 255 — byte.TryParse fails")] + // Non-digit or signed prefix characters + [InlineData("10.0.0.0/-1", "negative prefix")] + [InlineData("10.0.0.0/+8", "signed positive prefix")] + [InlineData("10.0.0.0/ 8", "space in prefix")] + // Non-canonical address forms + [InlineData("010.0.0.0/8", "leading zero in octet")] + [InlineData("0x0A.0.0.0/8", "hex octet")] + [InlineData("10.0/8", "partial IPv4 address")] + [InlineData("1.2.3/24", "3-part IPv4 address")] + // Structural problems + [InlineData("not-an-ip/8", "garbage address")] + [InlineData("/8", "empty address")] + [InlineData("10.0.0.0/", "empty prefix")] + [InlineData("10.0.0.0", "no slash")] + [InlineData("10.0.0.0/8/8", "double slash — prefix part '8/8' contains non-digit")] + // Zone IDs + [InlineData("fe80::1%eth0/64", "IPv6 zone ID")] + [InlineData("fe80::1%1/64", "IPv6 zone ID (numeric)")] + // Bracketed IPv6 forms — Rust rejects these; server must match + [InlineData("[::1]/128", "bracketed IPv6 — Rust rejects bracketed form")] + [InlineData("[2001:db8::]/32", "bracketed IPv6 prefix")] + // Whitespace + [InlineData(" 10.0.0.0/8", "leading whitespace")] + // Host bits set (IPv6) + [InlineData("2001:db8::1/32", "IPv6 host bits set")] + // IPv4-mapped IPv6 — SDK rejects these (test ipv4_mapped_ipv6_is_invalid); the mapped form is + // ambiguous with the native IPv4 range + [InlineData("::ffff:10.0.0.0/104", "IPv4-mapped IPv6")] + [InlineData("::ffff:1.2.3.4/128", "IPv4-mapped IPv6 single host")] + public void IsValid_InvalidCidrs_ReturnsFalse(string cidr, string reason) + { + // reason is only for readability in the test output + Assert.False(CidrValidator.IsValid(cidr), $"Expected '{cidr}' to be invalid ({reason})"); + } + + [Fact] + public void IsValid_Null_ReturnsFalse() + { + // JSON binding can produce null list entries; the validator must not throw on them. + Assert.False(CidrValidator.IsValid(null)); + } +} diff --git a/bitwarden_license/test/Services/Pam.Test/packages.lock.json b/bitwarden_license/test/Services/Pam.Test/packages.lock.json index 603b17d76ff9..731184dc6469 100644 --- a/bitwarden_license/test/Services/Pam.Test/packages.lock.json +++ b/bitwarden_license/test/Services/Pam.Test/packages.lock.json @@ -1296,6 +1296,13 @@ }, "httpextensions": { "type": "Project" + }, + "pam": { + "type": "Project", + "dependencies": { + "Core": "[2026.6.2, )", + "HttpExtensions": "[2026.6.2, )" + } } } }