urlguard
v1.0.0
Published
A tiny TypeScript URL builder that keeps path params, query params, and policy-checked URLs separate.
Downloads
49
Maintainers
Readme
A tiny TypeScript URL builder for keeping path params, query params, and policy-checked URLs separate.
urlguard helps build URLs without string concatenation. This prevents common path traversal vulnerabilities: user-input can't escape the URL part they were meant for, and URLs that come from user input can be checked against an allowlist before you redirect or fetch.
Install
pnpm add urlguardUsage
import { safeExternalUrl, safeRedirectUrl, url } from "urlguard";
const apiUrl = url("https://api.example.com/v1")
.path("/users/:userId/repos", {
userId: 1337,
})
.query({
q: "hello&admin=true",
page: 1,
includeArchived: false,
})
.toString();
// "https://api.example.com/v1/users/1337/repos?q=hello%26admin%3Dtrue&page=1&includeArchived=false"Relative URLs work too:
url().path("/users/:id", { id: "alice" }).query({ tab: "settings" }).toString();
// "/users/alice?tab=settings"Why not just URL encode it?
encodeURIComponent encodes characters, but it doesn't validate anything:
const href = `/files/${encodeURIComponent(userInput)}`;
// userInput = ".." -> "/files/.." -> "/"
// userInput = "" -> "/files/"And encoding only helps if the value is decoded exactly once. Many stacks decode more than that, a reverse proxy might normalize %2e%2e or %252e%252e back into ...
Policy-checked URLs
Use safeRedirectUrl for user-controlled redirect targets and safeExternalUrl for user-controlled absolute URLs.
const next = new URL(request.url).searchParams.get("next") ?? "/";
const redirectUrl = safeRedirectUrl(next, {
baseUrl: "https://app.example.com",
});
// next = "/dashboard?tab=billing" -> "https://app.example.com/dashboard?tab=billing"
// next = "https://evil.example.com" -> throws UrlGuardError
const docsUrl = safeExternalUrl(userProvidedUrl, {
allowedHosts: ["docs.example.com"],
});
// userProvidedUrl = "https://docs.example.com/start" -> "https://docs.example.com/start"
// userProvidedUrl = "https://evil.example.com/start" -> throws UrlGuardError
// userProvidedUrl = "https://[email protected]" -> throws UrlGuardErrorBy default, external URLs allow HTTPS only and reject embedded credentials. Redirect URLs resolve relative inputs against baseUrl and are restricted to that origin unless allowedOrigins or allowedHosts is set; a host allowlist replaces the base-origin default, so include the base hostname there when relative redirects should stay allowed.
API
Builder
function url(base?: string | URL, options?: UrlBuilderOptions): UrlBuilder;
class UrlBuilder {
// Append a path template with whole-segment :params.
path(template: string, params?: PathParams): UrlBuilder;
// Append one dynamic path segment.
segment(value: string | number | boolean): UrlBuilder;
// Append query params. Arrays repeat the key, null and undefined are skipped.
query(params: QueryParams): UrlBuilder;
// Return the final branded URL string.
toString(): SafeUrlString;
// Return a URL object. Requires an absolute base.
toURL(): URL;
}Every method returns a new builder, so chains can be forked and reused. Path params are inferred from the template, so missing or extra params are compile errors:
url().path("/users/:id", { id: "alice" }); // ok
url().path("/users/:id", {}); // type error: missing "id"Standalone path helpers
function path(template: string, params?: PathParams): SafePathString;
function encodePathParam(value: string | number | boolean): SafePathString;Policy checks
function safeExternalUrl(input: string | URL, policy?: ExternalUrlPolicy): SafeExternalUrlString;
function safeRedirectUrl(input: string | URL, policy: RedirectUrlPolicy): SafeRedirectUrlString;
interface ExternalUrlPolicy {
allowedProtocols?: readonly string[]; // default: ["https"]
allowedOrigins?: readonly string[]; // exact scheme + host + port matches
allowedHosts?: readonly string[]; // hostnames without ports
allowedPorts?: readonly (number | string)[]; // effective ports, defaults like 443 count
allowCredentials?: boolean; // default: false
}
interface RedirectUrlPolicy extends ExternalUrlPolicy {
baseUrl: string | URL; // trusted base for relative redirects
allowRelative?: boolean; // default: true
// allowedProtocols default: ["http", "https"]
// allowedOrigins default: the baseUrl origin, unless allowedHosts is set
}Errors
Everything above throws UrlGuardError on bad input. Each function has a try* twin (tryUrl, tryPath, tryEncodePathParam, trySafeExternalUrl, trySafeRedirectUrl) that returns a result instead:
class UrlGuardError extends TypeError {
code:
| "BASE_URL_INVALID"
| "EXTERNAL_URL_REJECTED"
| "PATH_SEGMENT_REJECTED"
| "PATH_TEMPLATE_INVALID"
| "QUERY_PARAM_INVALID"
| "REDIRECT_URL_REJECTED";
}
const result = trySafeRedirectUrl(next, { baseUrl: "https://app.example.com" });
if (result.kind === "Ok") {
result.value; // SafeRedirectUrlString
} else {
result.error; // UrlGuardError
}License
MIT
