| | | 1 | | using System.Text.RegularExpressions; |
| | | 2 | | |
| | | 3 | | namespace Chronicis.Api.Services.ExternalLinks; |
| | | 4 | | |
| | | 5 | | /// <summary> |
| | | 6 | | /// Validates and parses blob-based external link IDs. |
| | | 7 | | /// Security: Prevents path traversal and injection attacks. |
| | | 8 | | /// Supports hierarchical IDs like "items/armor/breastplate" for subcategories. |
| | | 9 | | /// </summary> |
| | | 10 | | public static partial class BlobIdValidator |
| | | 11 | | { |
| | | 12 | | // ID must be: alphanumeric + hyphens (case-insensitive), one or more slashes |
| | | 13 | | // Format: "category/slug" OR "category/subcategory/slug" |
| | | 14 | | // Examples: "spells/fireball", "items/armor/breastplate", "bestiary/Beast/aboar" |
| | | 15 | | [GeneratedRegex(@"^[a-zA-Z0-9-]+(/[a-zA-Z0-9-]+)+$", RegexOptions.Compiled)] |
| | | 16 | | private static partial Regex ValidIdPattern(); |
| | | 17 | | |
| | | 18 | | // Characters that are prohibited in IDs (security) |
| | 0 | 19 | | private static readonly char[] ProhibitedChars = { '.', '\\', '[', ']', '|' }; |
| | | 20 | | |
| | | 21 | | /// <summary> |
| | | 22 | | /// Validates an external link ID against security rules. |
| | | 23 | | /// Supports hierarchical paths with multiple slashes (e.g., "items/armor/breastplate"). |
| | | 24 | | /// </summary> |
| | | 25 | | /// <param name="id">ID to validate.</param> |
| | | 26 | | /// <param name="error">Error message if validation fails.</param> |
| | | 27 | | /// <returns>True if valid, false otherwise.</returns> |
| | | 28 | | public static bool IsValid(string id, out string? error) |
| | | 29 | | { |
| | 0 | 30 | | error = null; |
| | | 31 | | |
| | 0 | 32 | | if (string.IsNullOrWhiteSpace(id)) |
| | | 33 | | { |
| | 0 | 34 | | error = "ID cannot be empty"; |
| | 0 | 35 | | return false; |
| | | 36 | | } |
| | | 37 | | |
| | | 38 | | // Check for prohibited characters (path traversal, injection attempts) |
| | 0 | 39 | | if (id.IndexOfAny(ProhibitedChars) >= 0) |
| | | 40 | | { |
| | 0 | 41 | | error = "ID contains prohibited characters (. \\ [ ] |)"; |
| | 0 | 42 | | return false; |
| | | 43 | | } |
| | | 44 | | |
| | | 45 | | // Check for path traversal patterns |
| | 0 | 46 | | if (id.Contains("..", StringComparison.Ordinal)) |
| | | 47 | | { |
| | 0 | 48 | | error = "ID contains path traversal pattern (..)"; |
| | 0 | 49 | | return false; |
| | | 50 | | } |
| | | 51 | | |
| | | 52 | | // Validate format: lowercase alphanumeric + hyphens with at least one slash |
| | 0 | 53 | | if (!ValidIdPattern().IsMatch(id)) |
| | | 54 | | { |
| | 0 | 55 | | error = "ID must have format 'category/slug' or 'category/subcategory/slug' with alphanumeric and hyphens on |
| | 0 | 56 | | return false; |
| | | 57 | | } |
| | | 58 | | |
| | 0 | 59 | | return true; |
| | | 60 | | } |
| | | 61 | | |
| | | 62 | | /// <summary> |
| | | 63 | | /// Parses a validated ID into category path and slug components. |
| | | 64 | | /// For hierarchical IDs, category includes all path segments except the last. |
| | | 65 | | /// </summary> |
| | | 66 | | /// <param name="id">ID to parse (e.g., "spells/fireball" or "items/armor/breastplate").</param> |
| | | 67 | | /// <returns> |
| | | 68 | | /// Tuple of (categoryPath, slug): |
| | | 69 | | /// - "spells/fireball" -> ("spells", "fireball") |
| | | 70 | | /// - "items/armor/breastplate" -> ("items/armor", "breastplate") |
| | | 71 | | /// Returns (null, null) if invalid format. |
| | | 72 | | /// </returns> |
| | | 73 | | public static (string? categoryPath, string? slug) ParseId(string id) |
| | | 74 | | { |
| | 0 | 75 | | if (string.IsNullOrWhiteSpace(id)) |
| | | 76 | | { |
| | 0 | 77 | | return (null, null); |
| | | 78 | | } |
| | | 79 | | |
| | 0 | 80 | | var lastSlashIndex = id.LastIndexOf('/'); |
| | 0 | 81 | | if (lastSlashIndex <= 0 || lastSlashIndex >= id.Length - 1) |
| | | 82 | | { |
| | | 83 | | // No slash, or slash at start/end |
| | 0 | 84 | | return (null, null); |
| | | 85 | | } |
| | | 86 | | |
| | 0 | 87 | | var categoryPath = id[..lastSlashIndex]; |
| | 0 | 88 | | var slug = id[(lastSlashIndex + 1)..]; |
| | | 89 | | |
| | 0 | 90 | | return (categoryPath, slug); |
| | | 91 | | } |
| | | 92 | | } |