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
129 changes: 129 additions & 0 deletions Core/Resgrid.Chatbot.NLU/LlmEndpointValidator.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
using System;
using System.Net;
using System.Net.Sockets;

namespace Resgrid.Chatbot.NLU
{
/// <summary>
/// SSRF guard for LLM endpoints (system-level ChatbotConfig.CloudNluApiEndpoint and per-department
/// overrides). Only absolute https URIs whose host is — and resolves to — public addresses are
/// accepted; loopback, private, link-local and reserved ranges are rejected so a configured
/// endpoint can never point the server at internal infrastructure.
/// </summary>
public static class LlmEndpointValidator
{
public static bool IsValid(string endpoint, out string error)
{
error = null;

if (string.IsNullOrWhiteSpace(endpoint))
{
error = "Endpoint is empty.";
return false;
}

if (!Uri.TryCreate(endpoint, UriKind.Absolute, out var uri))
{
error = "Endpoint must be an absolute URI.";
return false;
}

if (!string.Equals(uri.Scheme, Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase))
{
error = "Endpoint must use the https scheme.";
return false;
}

var host = uri.Host;
if (host.Length > 2 && host[0] == '[' && host[host.Length - 1] == ']')
host = host.Substring(1, host.Length - 2);

if (IPAddress.TryParse(host, out var literal))
return IsPublicAddress(literal, out error);

try
{
var addresses = Dns.GetHostAddresses(host);
if (addresses == null || addresses.Length == 0)
{
error = "Endpoint host did not resolve to any address.";
return false;
}

foreach (var address in addresses)
{
if (!IsPublicAddress(address, out error))
return false;
}

return true;
}
catch (Exception)
{
error = "Endpoint host could not be resolved.";
return false;
}
}

private static bool IsPublicAddress(IPAddress address, out string error)
{
if (IsBlockedAddress(address))
{
error = $"Endpoint host resolves to a loopback/private/link-local/reserved address ({address}).";
return false;
}

error = null;
return true;
}

private static bool IsBlockedAddress(IPAddress address)
{
if (address.AddressFamily == AddressFamily.InterNetwork)
{
var bytes = address.GetAddressBytes();

if (bytes[0] == 0)
return true; // 0.0.0.0/8 (incl. 0.0.0.0)

if (bytes[0] == 10)
return true; // 10.0.0.0/8

if (bytes[0] == 127)
return true; // 127.0.0.0/8 (loopback)

if (bytes[0] == 172 && bytes[1] >= 16 && bytes[1] <= 31)
return true; // 172.16.0.0/12

if (bytes[0] == 192 && bytes[1] == 168)
return true; // 192.168.0.0/16

if (bytes[0] == 169 && bytes[1] == 254)
return true; // 169.254.0.0/16 (link-local)

return false;
}

if (address.AddressFamily == AddressFamily.InterNetworkV6)
{
if (address.IsIPv4MappedToIPv6)
return IsBlockedAddress(address.MapToIPv4()); // ::ffff:a.b.c.d -> run IPv4 checks

if (IPAddress.IPv6Loopback.Equals(address))
return true; // ::1

var bytes = address.GetAddressBytes();

if ((bytes[0] & 0xFE) == 0xFC)
return true; // fc00::/7 (unique local)

if (bytes[0] == 0xFE && (bytes[1] & 0xC0) == 0x80)
return true; // fe80::/10 (link-local)

return false;
}
Comment on lines +107 to +124

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

kody code-review Security critical

SSRF vulnerability identified in IsBlockedAddress where IPv4-mapped IPv6 addresses (e.g., ::ffff:169.254.169.254) bypass private/loopback host checks. IPAddress.TryParse returns these as InterNetworkV6, causing the guard to treat them as public. Check address.IsIPv4MappedToIPv6 and map it to IPv4 via address.MapToIPv4() to run the IPv4 block checks.

if (address.IsIPv4MappedToIPv6)
    return IsBlockedAddress(address.MapToIPv4());

if (address.AddressFamily == AddressFamily.InterNetworkV6)
{
    if (IPAddress.IPv6Loopback.Equals(address))
        return true; // ::1

    var bytes = address.GetAddressBytes();

    if ((bytes[0] & 0xFE) == 0xFC)
        return true; // fc00::/7 (unique local)

    if (bytes[0] == 0xFE && (bytes[1] & 0xC0) == 0x80)
        return true; // fe80::/10 (link-local)

    return false;
}
Prompt for LLM

File Core/Resgrid.Chatbot.NLU/LlmEndpointValidator.cs:

Line 107 to 121:

SSRF vulnerability identified in `IsBlockedAddress` where IPv4-mapped IPv6 addresses (e.g., `::ffff:169.254.169.254`) bypass private/loopback host checks. `IPAddress.TryParse` returns these as `InterNetworkV6`, causing the guard to treat them as public. Check `address.IsIPv4MappedToIPv6` and map it to IPv4 via `address.MapToIPv4()` to run the IPv4 block checks.

Suggested Code:

if (address.IsIPv4MappedToIPv6)
    return IsBlockedAddress(address.MapToIPv4());

if (address.AddressFamily == AddressFamily.InterNetworkV6)
{
    if (IPAddress.IPv6Loopback.Equals(address))
        return true; // ::1

    var bytes = address.GetAddressBytes();

    if ((bytes[0] & 0xFE) == 0xFC)
        return true; // fc00::/7 (unique local)

    if (bytes[0] == 0xFE && (bytes[1] & 0xC0) == 0x80)
        return true; // fe80::/10 (link-local)

    return false;
}

Talk to Kody by mentioning @kody

Was this suggestion helpful? React with 👍 or 👎 to help Kody learn from this interaction.


return true;
}
}
}
5 changes: 5 additions & 0 deletions Core/Resgrid.Chatbot.NLU/NLUModule.cs
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,11 @@ protected override void Load(ContainerBuilder builder)
builder.RegisterType<EntityExtractor>()
.As<IEntityExtractor>()
.InstancePerLifetimeScope();

// Free-form chat completion (conversational fallback) sharing the cloud provider resolution
builder.RegisterType<OpenAiCompatibleChatCompletionClient>()
.As<IChatCompletionClient>()
.InstancePerLifetimeScope();
}
}
}
Loading
Loading