DEV Community

Ivan Rossouw
Ivan Rossouw

Posted on

A URL Is Only Safe for the Sink That Consumes It

Safety is not a permanent property of a string. A value can be acceptable in one browser context and dangerous in another.

That sounds obvious when we compare HTML text with executable script. It is easier to miss when both values look like URLs.

A recent committed correction I reviewed had exactly that shape. A shared resolver had been designed for asset sources. In that context, the application intentionally supported a wider set of values, including inline media. The same resolver was then reused for clickable anchors. Its name still sounded reassuring, but the destination had gained more power: the browser could now navigate when a person clicked the value.

The lesson is simple: validate for the sink that consumes the value.

One resolver was doing two different jobs

Consider these two destinations:

<img src="...">
<a href="...">Open document</a>
Enter fullscreen mode Exit fullscreen mode

Both attributes receive a URL-shaped string, but they are not the same contract. An application may deliberately permit a constrained inline image source while refusing that same scheme as a navigation target. An anchor can also hand control to an external origin or a custom application handler.

A helper called something broad such as NormaliseUrl hides this difference. Callers see a normalised string and may infer that it is safe everywhere. The helper has accidentally become a security promise it cannot keep.

Prefer contracts that name the destination: ResolveAssetSource, ResolveAnchorHref, or another equally explicit boundary.

Resolve first, then apply policy

Validation order matters. Relative values may be combined with a trusted base URI, while absolute values can ignore that base entirely. A check performed only on the raw input can therefore approve one shape and produce another after resolution.

The safer sequence is:

  1. Trim and normalise the input.
  2. Resolve it against the expected base when appropriate.
  3. Parse the final result.
  4. Apply the allowlist for the exact destination.
  5. Return a deliberate, non-executable fallback when the policy rejects the scheme.

Here is a simplified, newly written example:

static string SafeAnchorHref(string? raw, Uri origin)
{
    if (string.IsNullOrWhiteSpace(raw))
        return "#";

    var normalised = raw.Trim().Replace('\\', '/');
    if (normalised.StartsWith("//", StringComparison.Ordinal))
        return "#";

    var resolved = ResolveAgainstOrigin(normalised, origin);

    return IsAllowedForAnchor(resolved, origin)
        ? resolved
        : "#";
}
Enter fullscreen mode Exit fullscreen mode

A # fallback prevents external or script-scheme navigation, but it can still affect scroll position or history. Where the interface must truly do nothing, render plain text or omit the link instead.

The exact policy will differ by product. Some systems may need mail links or a carefully controlled app scheme. The important part is that each additional capability is explicit, reviewed, and tested. “Anything URI-shaped” is not a useful allowlist.

A scheme allowlist only controls what the browser may execute or delegate. It does not prove that an HTTP or HTTPS destination is trustworthy; origin policy, redirect handling, download policy, and content controls are separate decisions.

Make rejection boring

Security controls work better when rejected data degrades predictably.

For a document list, that might mean keeping a safely encoded display label but replacing the navigation target with a same-document fragment. Another interface might render plain text instead of an anchor. Either choice is preferable to throwing during rendering or letting the browser interpret an unexpected scheme.

Display text and navigation data should also be separate. Decoding an encoded filename for readability does not mean the decoded string should become the href. The label goes through HTML text encoding; the resolved navigation value goes through the anchor policy.

This separation makes both behaviours easier to reason about.

A new tab is another boundary

Opening external links in a new tab or window adds a second small contract. Set the component’s real target parameter rather than assuming an arbitrary attribute will survive rendering. Pair a blank target with noopener and noreferrer when that matches the product’s privacy policy.

These details are easy to dismiss as markup polish. They control whether the opened page can reach its opener and whether it receives a referrer.

Test boundary shapes, not only happy URLs

The focused tests in the reviewed change were valuable because they described the boundary, not the implementation. A useful anchor-policy suite should include:

  • mixed-case script-capable schemes;
  • inline-data, blob, file, intent, and unknown custom schemes;
  • protocol-relative values;
  • malformed and blank input;
  • valid HTTP and HTTPS URLs;
  • root-relative paths and fragments;
  • percent-encoded filenames;
  • the expected target and relationship attributes.

Also test the asset-source policy separately. A security fix for anchors should not silently break a legitimate image path elsewhere.

The trade-off: compatibility for explicit safety

A narrow allowlist can replace old dirty links with a same-document fallback. It can also block a legitimate integration until its scheme is deliberately supported. That is real compatibility work, not a reason to keep the boundary broad.

The alternative is worse: a generic helper quietly expands its promise as it moves into more powerful contexts. Rejection is deterministic, fails closed, and leaves the underlying data repairable. An unsafe navigation path may remain unnoticed until someone supplies the wrong stored value.

My practical rule is now: when data crosses into a browser attribute, name the sink, validate the final resolved value, and test every capability you intend to allow.

Where in your application does a “safe” URL move between contexts with different powers?

Top comments (0)