Skip to content

Instantly share code, notes, and snippets.

@GrabYourPitchforks
Last active April 16, 2025 16:56
Show Gist options
  • Save GrabYourPitchforks/ee309573feccec8a88aee8ad08788295 to your computer and use it in GitHub Desktop.
Save GrabYourPitchforks/ee309573feccec8a88aee8ad08788295 to your computer and use it in GitHub Desktop.
Sample threat model for MCP weather API tutorial

Sample threat model for MCP weather API tutorial

Introduction

This is a sample showing how to threat model a library API. This model applies the "Introduction to Trust" principles to the C# helper functions located at https://modelcontextprotocol.io/quickstart/server as of Apr. 10, 2025 (Internet Archive link).

This sample discusses:

  • applying the "Introduction to Trust" guidance to take a structured approach to reasoning about assumptions, inputs, and data flows;
  • assessing these in the context of cross-party contract fulfillment;
  • applying reasonable beliefs to simplify our threat analysis;
  • changing the code to be compliant with the threat model; and
  • theorizing how extensions to the code might impact the threat analysis.

This sample does not discuss any MCP-specific or AI-specific concerns. It's just that the sample code contained within that tutorial is useful as a demonstration of how to approach threat modeling.

The sample code is provided below (with MCP/AI-specific concepts removed) for reference.

static HttpClient client = new HttpClient() { BaseAddress = new Uri("https://api.weather.gov") };

// Get weather alerts for a US state.
// area: The US state to get alerts for.
async Task<string> GetAlerts(string area)
{
    var jsonElement = await client.GetFromJsonAsync<JsonElement>($"/alerts/active/area/{area}");
    var alerts = jsonElement.GetProperty("features").EnumerateArray();

    if (!alerts.Any())
    {
        return "No active alerts for this state.";
    }

    return string.Join("\n--\n", alerts.Select(alert =>
    {
        JsonElement properties = alert.GetProperty("properties");
        return $"""
                Event: {properties.GetProperty("event").GetString()}
                Area: {properties.GetProperty("areaDesc").GetString()}
                Severity: {properties.GetProperty("severity").GetString()}
                Description: {properties.GetProperty("description").GetString()}
                Instruction: {properties.GetProperty("instruction").GetString()}
                """;
    }));
}

// Get weather forecast for a location.
// latitude: Latitude of the location.
// longitude: Longitude of the location.
async Task<string> GetForecast(double latitude, double longitude)
{
    var jsonElement = await client.GetFromJsonAsync<JsonElement>($"/points/{latitude},{longitude}");
    var periods = jsonElement.GetProperty("properties").GetProperty("periods").EnumerateArray();

    return string.Join("\n---\n", periods.Select(period => $"""
            {period.GetProperty("name").GetString()}
            Temperature: {period.GetProperty("temperature").GetInt32()}°F
            Wind: {period.GetProperty("windSpeed").GetString()} {period.GetProperty("windDirection").GetString()}
            Forecast: {period.GetProperty("detailedForecast").GetString()}
            """));
}

Step 1 - Scenario evaluation

We have a reasonable expectation that the inputs to these routines will be provided by a potentially hostile agent, even if the immediate caller is itself trusted. That is, we assume the trusted caller might forward adversarial arguments.

We're a wrapper around the NWS web APIs. Those APIs are described at https://www.weather.gov/documentation/services-web-api, with the exact API spec at https://api.weather.gov/openapi.json.

Because we're a wrapper around another web API, we're not concerned about the quality of the information it returns, but we do want to ensure that we're faithfully returning that information back to our immediate caller. The return format is intended to be human-readable, so there's no expectation of a clear structure or envelope.

Step 2 - List assumptions

This list is derived from the Step (1) scenario evaluation above, and it also describes some characteristics of the implementation which allow us to make security assumptions. We'll use these in Step (3) and later sections below.

  • The inputs to our methods are potentially hostile.
  • The output of our method is intended to be unstructured human-readable text.
  • The NWS web API is an externally-controlled public service which requires no authentication and returns curated data.
  • We're using System.Text.Json with default options to deserialize response payloads.

Step 3 - Data flows

The GetAlerts method

Outbound data flow to NWS 'alerts' web API

Potentially hostile user input contributes to the construction of a URL. According to the NWS API spec, the {area} portion of the URL is a "state or marine area," which is further defined as a two-character string whose value is chosen from this list:

AL, AK, AS, AR, AZ, CA, CO, CT, DE, DC, FL, GA, GU, HI, ID, IL, IN, IA, KS, KY,
LA, ME, MD, MA, MI, MN, MS, MO, MT, NE, NV, NH, NJ, NM, NY, NC, ND, OH, OK, OR,
PA, PR, RI, SC, SD, TN, TX, UT, VT, VI, VA, WA, WV, WI, WY, MP, PW, FM, MH, AM,
AN, GM, LC, LE, LH, LM, LO, LS, PH, PK, PM, PS, PZ, SL

In other words, if any other value is provided for {area}, we have violated the contract for the web API we're calling.

Warning

The current implementation of the client in the sample code does not enforce the contract on {area} in the face of hostile input. We'll show how to fix this below.

Note

Remember that both parties to the communication - both the web client and the web server - need to uphold the contract. The contract specifies what action the client expects the server to perform, and it provides a mechanism for the client and the server to agree on the shape of the transmitted data.

We do not control the server (the NWS web API), so it's out of scope for us. But in general, servers are expected to validate that their input conforms to the contract, failing the request if the contract is violated or if there's an unresolvable ambiguity.

We do control the client (the GetAlerts method). If the client can be coerced by a bad actor into breaking the contract, this could result in a confused deputy situation where the client ends up triggering an action it did not intend. This isn't a huge concern when contacting the publicly available NWS web API, but it is a concern when we're contacting an authenticated service like Blob Storage or some backend API. We must get into the habit of having the client enforce the contract, even for sample / tutorial code, because it builds muscle memory for ensuring potential contract breakages don't slip into impactful production code.

Inbound data flow from NWS 'alerts' web API

We expect the response to contain an array, and we build a string return value by enumerating the elements in the array. It is acceptable for our method to throw an exception if the response payload does not have the expected shape, so we do not need extra error handling. Because we're using System.Text.Json, whose default behavior forbids backreferences like $id, there is no opportunity for a malicious NWS web API service to pull off an algorithmic complexity attack against our own code. The size of the string we return will be proportional to the size of the response the NWS web API returns to us. If we ever use non-default System.Text.Json behavior, or if we switch to an alternative serializer like Newtonsoft.Json, we will need to reevaluate our exposure to this attack.

Finally, we consider that because our return value is not strongly structured, there is no reliable way to envelope the individual records or their properties. This is acceptable because: (a) our scenario prioritizes human-readability over machine-readability; and (b) we have a reasonable belief that the NWS web API will not return data that would cause a human to misinterpret the data.

Note

The "reasonable belief" above stems from that we believe the NWS web APIs return curated data produced by authoritative entities. That is, since the NWS expects human readers to act on this data, they have no reason to include in their response data that might break our weak structuring.

The GetForecast method

Outbound data flow to NWS 'points' web API

Potentially hostile user input contributes to the construction of a URL. According to the NWS API spec, each of the {latitude} and {longitude} portions of the URL is expected to match the regex ^(-?[0-9]+(?:\.[0-9]+)?)$.

Warning

The current implementation of the client in the sample code does not enforce the contract on {latitude} and {longitude} in the face of hostile input. We'll show how to fix this below.

We could try to restrict things like NaN and ±Infinity and other extreme values, but we can use what we know about the scenario to simplify. Since these represent latitude and longitude in degrees, we can restrict values to be within ±90.0° and ±180.0°, respectively. The endpoint's FAQ states that granularity is good to within a 2.5-km grid. One minute of latitude is one natical mile (~1.85 km); and according to USGS, one minute of longitude across CONUS is ~4800 ft (~1.46 km). Given that the representation of latitude and longitude are in degrees, this implies that two decimal places of precision (which is slightly more granular than one minute of precision, which is already more granular than a 2.5-km grid) would be appropriate.

The conclusion is that we should perform a bounds check on the inputs for ±90.0 and ±180.0, and we should always format to two decimal places before insertion into the URL. This eliminates the need for NaN and other extreme value checks. It does allow -0.0 through, but that's technically allowed per the NWS web API contract, so we're ok.

Outbound data flow to NWS 'gridpoints/forecast' web API

The code in the GetForecast sample method is incorrect and will always throw an exception. It assumes the response from the /points/{point} API contains the forecast data. In reality, the response from the /points/{point} API contains metadata that tells the client about another endpoint -- /gridpoints/{wfo}/{x},{y}/forecast -- which provides the forecast data. The metadata contains the values to use for {wfo}, {x}, and {y}.

Ideally, we should be independently validating the values of {wfo} and {x} and {y} before forwarding them along. This would require us to maintain a list of legal {wfo} values, and we don't know how often those might update. This could represent an unacceptable burden to our service.

Instead, we'll take advantage of the fact that the /points/{point} API also returns the full URI with all of these values already filled in. We never want to blindly trust any URI we're asked to contact, but we can take advantage of a few things working in our favor.

  1. We know that the /gridpoints/... endpoint should be on the same target server as the original /points/... endpoint.
  2. We don't have any special permissions on the target server. We don't authenticate, we don't have write access, we don't read privileged data, etc.

This means that if we get a redirect URI from the NWS web API itself, as long as we validate that the URI continues to point back to the same origin, there's no opportunity for a confused deputy attack. In effect, we're using the response from the /points/... API as a sort of stand-in contract to tell us what values we should forward along, with the understanding that this contract is only valid within the same target server.

Caution

This shortcut is predicated on the NWS web API being a public API where we have no specific privilege and where the entirety of the public API operates under a single authority. This would not be an appropriate shortcut to take if we authenticated to https://example.com/foo/... and blindly followed a redirect to https://example.com/bar/..., as those web APIs could be operating under different authorities and exist within different privilege spheres, even though they're part of the same host. In that case, our client would want to minimize the risk of confused deputy attacks by validating the well-formedness of the components used to generate the target URI.

Inbound data flow from 'gridpoints/forecast' web API

See the GetAlerts section for discussion on how the client builds the string return value by iterating over the response. The discussion there about complexity, error handling, and structuring the return value all apply equally here.

Step 4 - Fixing the code

The sample below represents a fixed version of these methods where the web client performs any appropriate validation and adheres to the NWS web API contract before calling into the web service.

Note

The samples below also demonstrate using Uri.EscapeDataString as a defense-in-depth measure. Since the path portion of a URI is only weakly structured, it is not safe to rely solely on Uri.EscapeDataString without also validating the shape of the input data. And in the case of these samples, our validation ensures that no reserved characters will ever be emitted into the URI. Still, it's good to get into the habit of calling this API as part of a defense-in-depth strategy, so we'll do it here as well.

Tip

The NWS web APIs require a User-Agent request header. If you run this sample and observe an HTTP 403 Forbidden error, try setting a User-Agent header as below and see if it resolves the issue.

client.DefaultRequestHeaders.UserAgent.Add(new("DemoApp", "1.0"));
static HttpClient client = new HttpClient() { BaseAddress = new Uri("https://api.weather.gov") };

static FrozenSet<string> allowedAlertAreas =
[
    "AL", "AK", "AS", "AR", "AZ", "CA", "CO", "CT", "DE", "DC", "FL", "GA", "GU", "HI", "ID", "IL", "IN", "IA", "KS", "KY",
    "LA", "ME", "MD", "MA", "MI", "MN", "MS", "MO", "MT", "NE", "NV", "NH", "NJ", "NM", "NY", "NC", "ND", "OH", "OK", "OR",
    "PA", "PR", "RI", "SC", "SD", "TN", "TX", "UT", "VT", "VI", "VA", "WA", "WV", "WI", "WY", "MP", "PW", "FM", "MH", "AM",
    "AN", "GM", "LC", "LE", "LH", "LM", "LO", "LS", "PH", "PK", "PM", "PS", "PZ", "SL"
];

// Get weather alerts for a US state.
// area: The US state to get alerts for.
async Task<string> GetAlerts(string area)
{
    // Since 'area' may contain hostile input, it's on us to validate
    // the input value against the list of allowed strings per the
    // NWS web API specification.
    if (area is null || !allowedAlertAreas.Contains(area))
    {
        return "Invalid area provided.";
    }

    var jsonElement = await client.GetFromJsonAsync<JsonElement>($"/alerts/active/area/{Uri.EscapeDataString(area)}");
    var alerts = jsonElement.GetProperty("features").EnumerateArray();

    if (!alerts.Any())
    {
        return "No active alerts for this state.";
    }

    return string.Join("\n--\n", alerts.Select(alert =>
    {
        JsonElement properties = alert.GetProperty("properties");
        return $"""
                Event: {properties.GetProperty("event").GetString()}
                Area: {properties.GetProperty("areaDesc").GetString()}
                Severity: {properties.GetProperty("severity").GetString()}
                Description: {properties.GetProperty("description").GetString()}
                Instruction: {properties.GetProperty("instruction").GetString()}
                """;
    }));
}

// Get weather forecast for a location.
// latitude: Latitude of the location.
// longitude: Longitude of the location.
async Task<string> GetForecast(double latitude, double longitude)
{
    /*
     * First, we need to look up the forecast office and zone grid coordinates
     * for the area of interest.
     */

    // Limit lat to [-90.0, +90.0] and lon to [-180.0, +180.0].
    // The syntax below ensures NaN values are rejected.
    if (latitude is not (>= -90.0 and <= +90.0) ||
        longitude is not (>= -180.0 and <= +180.0))
    {
        return "Bad latitude or longitude.";
    }

    // 'F2' ensures that we never use scientific notation and always emit two
    // decimal places of precision. InvariantCulture ensures that we always
    // use a '.' as the decimal separator, as mandated by the NWS web API contract,
    // regardless of the current machine's culture.
    string latString = latitude.ToString("F2", CultureInfo.InvariantCulture);
    string lonString = longitude.ToString("F2", CultureInfo.InvariantCulture);

    var jsonElement = await client.GetFromJsonAsync<JsonElement>($"/points/{Uri.EscapeDataString(latString)},{Uri.EscapeDataString(lonString)}");
    var forecastUri = new Uri(jsonElement.GetProperty("properties").GetProperty("forecast").GetString()!);

    /*
     * Then, we need to make a request to the local forecast office endpoint
     * to get the hourly forecast.
     */
    
    // The 'forecastUri' value comes from the NWS web API and is expected to
    // be of the format "/gridpoints/{wfo}/{x},{y}/forecast". We could double-
    // check that all of its components are per spec, including that the WFO
    // and grid identifiers are well-formed, but this isn't necessary. All
    // we need to check is that we're not being asked to redirect to a different
    // target server. See threat model discussion for more info.
    if (!forecastUri.IsAbsoluteUri || GetStrongSchemeAndAuthority(forecastUri) != GetStrongSchemeAndAuthority(client.BaseAddress))
    {
        return "Unknown redirect returned by the service endpoint.";
    }

    jsonElement = await client.GetFromJsonAsync<JsonElement>(forecastUri);
    var periods = jsonElement.GetProperty("properties").GetProperty("periods").EnumerateArray();

    return string.Join("\n---\n", periods.Select(period => $"""
            {period.GetProperty("name").GetString()}
            Temperature: {period.GetProperty("temperature").GetInt32()}°F
            Wind: {period.GetProperty("windSpeed").GetString()} {period.GetProperty("windDirection").GetString()}
            Forecast: {period.GetProperty("detailedForecast").GetString()}
            """));

    static string GetStrongSchemeAndAuthority(Uri uri) => uri.GetComponents(UriComponents.SchemeAndServer | UriComponents.StrongAuthority, UriFormat.Unescaped);
}

Step 5 - Out-of-scope elements and ancillary discussion

Cancellation and logging

In a production application which performs i/o, a CancellationToken would be explicitly provided as a parameter or would be fetched from the ambient environment. The web client may also choose to implement its own timeout policy and eagerly terminate the web request, even before the incoming CancellationToken has tripped. We do not show this here, and we assume the developer will configure the web client appropriately.

Additionally, in an enterprise environment, communications which cross a machine boundary should be durably logged. The specific logging mechanism is usually going to be enterprise-defined, and discussion is out of scope here. We do not show this here, and we assume the developer will configure the web client appropriately.

Rate limiting and caching

The application may be subject to many requests for the same data. While the NWS web API is a public service, it does impose rate limits to facilitate equitable access. The web client itself does not impose any type of rate limit. If necessary, the client's immediate caller (that is, the component that invokes GetAlerts or GetForecast) should introduce a limiting mechanism.

Similarly, the web client itself does not implement a cache of data. The caller may implement their own cache if desired, but they should be aware of certain caveats.

  • Weather information may be time-sensitive, so any cache should be very short-lived, possibly no more than one minute.
  • The caller is responsible for generating the cache key and for limiting the size of the cache. Secure cache key generation from non-string data types like double is not intuitive.

Server-side request forgery (SSRF)

Any time an HttpClient makes an outbound request, the potential for SSRF exists. Your enterprise may have specific requirements for mitigating this risk, such as through the use of a custom HttpMessageHandler or a custom DNS resolver. Compliance can usually be determined through static analysis. We do not show this here, and we assume the developer will configure the web client appropriately.

@RikkiGibson
Copy link

Thanks for providing this sample. I had a question:

We never want to blindly trust any URI we're asked to contact

Does that mean the static HttpClient client needs to also be configured to not automatically follow redirects to unknown origins, for example, by passing in an HttpClientHandler which sets AllowAutoRedirect = false?

@GrabYourPitchforks
Copy link
Author

Does that mean the static HttpClient client needs to also be configured to not automatically follow redirects to unknown origins ...

It's a good question!

In a real-world application, that would typically be part of a holistic SSRF defense. I didn't want to show it here since the exact steps are very enterprise-specific. Microsoft has its own internal package it expects first parties to use. Amazon has its own recommendations. And so on. Among other things, those packages would typically set redirect or DNS resolution policy on your behalf, eliminating the need for the app developer to set them explicitly. (See the last paragraph in the doc above.)

I showed the explicit host check here because we're manually triggering a new request, so responsibility for applying any necessary policy falls to us. We wouldn't get the benefit of any auto-applied policy.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment