Skip to content

@absoluteui/helpers/url

Query strings, URL joining, scheme allow-lists, same-origin checks, and open-redirect protection.

import { ... } from '@absoluteui/helpers/url';

Safe building and reading of URLs and query strings, plus the checks that stop open redirects and javascript: links. Reach for it whenever a URL contains user input: a return address after sign-in, an ID in an API path, filters kept in the address bar, or a link someone typed into a profile. The HTTP guide shows how the services HTTP client builds request URLs on top of these helpers.

How do I build a safe redirect target after sign-in?

Section titled “How do I build a safe redirect target after sign-in?”

Never redirect to a returnTo parameter as is. safeRedirectTarget keeps same-site paths and absolute URLs on the origins you allow; anything else (//evil.test, /\evil.test, other schemes) becomes the fallback.

import { getQueryParam, safeRedirectTarget } from '@absoluteui/helpers/url';
const options = { allowedOrigins: ['https://payroll.example.com'], fallback: '/home' };
const back = (url: string) => safeRedirectTarget(getQueryParam(url, 'returnTo'), options);
back('https://hr.example.com/login?returnTo=%2Fleave%2Frequests%3Fstatus%3Dopen'); // => '/leave/requests?status=open'
back('https://hr.example.com/login?returnTo=https%3A%2F%2Fpayroll.example.com%2Fpayslips'); // => 'https://payroll.example.com/payslips'
back('https://hr.example.com/login?returnTo=%2F%2Fevil.test'); // => '/home'
back('https://hr.example.com/login'); // => '/home' // parameter missing

How do I put an untrusted ID into an API path?

Section titled “How do I put an untrusted ID into an API path?”

joinUrl handles the slashes but does not encode, so encode each untrusted segment with encodePathSegment. Then add the query with withQuery, which encodes values and skips undefined.

import { encodePathSegment, joinUrl, withQuery } from '@absoluteui/helpers/url';
const employeeId = '../admin';
const path = joinUrl('https://api.example.com/v1/', 'employees', encodePathSegment(employeeId), 'payslips');
path; // => 'https://api.example.com/v1/employees/..%2Fadmin/payslips' // the slash is encoded, so no traversal
withQuery(path, { year: 2026, month: undefined, status: ['issued', 'paid'] }); // => 'https://api.example.com/v1/employees/..%2Fadmin/payslips?year=2026&status=issued&status=paid'

How do I keep list filters in the address bar?

Section titled “How do I keep list filters in the address bar?”

Read the current filters with parseQuery (null-prototype, repeated keys become arrays), change one with withQuery, and clear them with removeQuery. Other parameters and the fragment are kept.

import { parseQuery, removeQuery, withQuery } from '@absoluteui/helpers/url';
const current = '/employees?dept=finance&dept=hr&page=3#results';
parseQuery(current.split(/[?#]/)[1] ?? '').dept; // => ['finance', 'hr']
withQuery(current, { page: 1, q: 'rao' }); // => '/employees?dept=finance&dept=hr&page=1&q=rao#results'
removeQuery(current, ['dept']); // => '/employees?page=3#results'

Check it with isSafeUrl before it reaches href. The default allow-list is http, https, mailto, and tel; pass allowedSchemes to narrow it and allowRelative: false to require absolute URLs.

import { isSafeUrl } from '@absoluteui/helpers/url';
const profileLink = (url: string) => (isSafeUrl(url, { allowedSchemes: ['https'], allowRelative: false }) ? url : undefined);
profileLink('https://www.linkedin.com/in/asha-rao'); // => 'https://www.linkedin.com/in/asha-rao'
profileLink('javascript:alert(document.cookie)'); // => undefined
profileLink('http://example.com'); // => undefined // not on the narrowed list
profileLink('/relative/path'); // => undefined

Unsafe input fails loudly instead of producing a surprising URL. parseQuery caps length (64 KiB), parameter count (1000), and nesting depth (5) by default; lower them for untrusted input.

import { buildQuery, joinUrl, parseQuery } from '@absoluteui/helpers/url';
joinUrl('/files', '../etc/passwd', { confine: true }); // => throws HLP_URL_PATH_TRAVERSAL
buildQuery(JSON.parse('{"__proto__":{"admin":true}}')); // => throws HLP_UNSAFE_KEY
parseQuery('a=1&b=2&c=3', { maxParams: 2 }); // => throws HLP_URL_TOO_MANY_PARAMS
parseQuery('a[b][c]=1', { maxDepth: 1 }); // => throws HLP_URL_TOO_DEEP

buildQuery

Builds a query string (without ? unless prefix). Spaces encode as %20. Nested objects use bracket notation. Unsafe keys (__proto__, …) throw HLP_UNSAFE_KEY.

function buildQuery(params: QueryInput, options?: BuildQueryOptions): string;

Example:

buildQuery({ q: 'a b', tag: ['x', 'y'], page: 2 }); // => 'q=a%20b&tag=x&tag=y&page=2'
buildQuery({ tag: ['x', 'y'] }, { arrayFormat: 'comma' }); // => 'tag=x,y'

encodePathSegment

Percent-encodes one path segment, including /, ?, #; . and .. are encoded so they cannot traverse.

function encodePathSegment(segment: string | number): string;

Example:

encodePathSegment('a/b c'); // => 'a%2Fb%20c'
encodePathSegment('..'); // => '%2E%2E'

getDomain

Hostname of an absolute URL (lowercase, no port); undefined when unparseable.

function getDomain(url: string): string | undefined;

Example:

getDomain('https://Sub.Example.com:8080/x'); // => 'sub.example.com'

getExtension

Lowercase file extension without the dot, from a URL or path; undefined when none (dotfiles have none).

function getExtension(urlOrPath: string): string | undefined;

Example:

getExtension('/a/photo.JPG?x=1'); // => 'jpg'
getExtension('.env'); // => undefined

getFileNameFromUrl

Last path segment, percent-decoded; undefined when the path ends with /. Works for relative URLs.

function getFileNameFromUrl(url: string): string | undefined;

Example:

getFileNameFromUrl('https://x.test/files/report%202026.pdf?v=1'); // => 'report 2026.pdf'

getQueryParam

First value of a query parameter, decoded; undefined when absent.

function getQueryParam(url: string, key: string): string | undefined;

Example:

getQueryParam('https://x.test/?a=1&a=2', 'a'); // => '1'

isAbsoluteUrl

True for URLs with a scheme (https:, mailto:); protocol-relative //host is not absolute.

function isAbsoluteUrl(url: string): boolean;

Example:

isAbsoluteUrl('https://a.test'); // => true
isAbsoluteUrl('/a'); // => false

isSafeUrl

True when url is safe to put in href/src: scheme on the allow-list (rejects javascript:, data:, vbscript:), no control characters (blocks java\tscript:), and parseable.

function isSafeUrl(url: string, options?: IsSafeUrlOptions): boolean;

Example:

isSafeUrl('https://example.com'); // => true
isSafeUrl(' JaVa\tScRiPt:alert(1)'); // => false

isSameOrigin

True when both absolute URLs have the same origin (scheme, host, port). Opaque origins never match.

function isSameOrigin(a: string | URL, b: string | URL): boolean;

Example:

isSameOrigin('https://a.test/x', 'https://A.test:443/y'); // => true

joinUrl

Joins a base URL and path segments with exactly one / between parts. Keeps the base’s protocol, authority, query and fragment. Pass { confine: true } last to block .. escapes. Segments are not encoded: use encodePathSegment for untrusted values.

function joinUrl(base: string, ...parts: readonly (string | number | JoinUrlOptions)[]): string;

Example:

joinUrl('https://api.example.com/v1/', '/users/', 42); // => 'https://api.example.com/v1/users/42'
joinUrl('/files', '../etc', { confine: true }); // => throws HLP_URL_PATH_TRAVERSAL

normalizeUrl

Normalizes an absolute URL: lowercase scheme and host, default port removed, dot segments resolved, plus optional query sort and fragment removal. Returns undefined when unparseable.

function normalizeUrl(url: string, options?: NormalizeUrlOptions): string | undefined;

Example:

normalizeUrl('HTTPS://Example.COM:443/a/../b?z=1&a=2#x', { sortQuery: true, removeFragment: true });
// 'https://example.com/b?a=2&z=1'

parseQuery

Parses a query string (leading ? optional; + decodes as a space) into null-prototype objects. Supports repeated keys, a[]= arrays and a[b][c]= nesting. Parameters with unsafe keys (__proto__, constructor, prototype) are dropped; conflicting shapes keep the first value.

function parseQuery(qs: string, options?: ParseQueryOptions): QueryObject;

Example:

parseQuery('?q=a+b&tag=x&tag=y&user[name]=Ann'); // => { q: 'a b', tag: ['x', 'y'], user: { name: 'Ann' } }

parseUrl

Parses a URL, returning undefined instead of throwing.

function parseUrl(url: string | URL, base?: string | URL): URL | undefined;

Example:

parseUrl('not a url'); // => undefined
parseUrl('/a', 'https://x.test')?.href; // => 'https://x.test/a'

removeQuery

Removes the given query keys (or the whole query when keys is omitted). Keeps the fragment.

function removeQuery(url: string, keys?: readonly string[]): string;

Example:

removeQuery('/a?x=1&y=2', ['x']); // => '/a?y=2'
removeQuery('/a?x=1#f'); // => '/a#f'

safeRedirectTarget

Open-redirect guard. Accepts same-site paths starting with a single / and absolute http(s) URLs whose origin is allowed; rejects //evil.com, /\evil.com, schemes, and control characters.

function safeRedirectTarget(target: string | null | undefined, options?: SafeRedirectOptions): string;

Example:

safeRedirectTarget('/dashboard?tab=1'); // => '/dashboard?tab=1'
safeRedirectTarget('//evil.com'); // => '/'

withQuery

Returns url with params set: existing keys named in params are replaced, others kept; an undefined value removes the key. Works with relative URLs and keeps the fragment.

function withQuery(url: string, params: QueryInput, options?: BuildQueryOptions): string;

Example:

withQuery('/list?page=1&q=x#top', { page: 2 }); // => '/list?q=x&page=2#top'

BuildQueryOptions

interface BuildQueryOptions {
readonly arrayFormat?: ArrayFormat;
readonly skipNull?: boolean;
readonly sort?: boolean;
readonly prefix?: boolean;
}

IsSafeUrlOptions

interface IsSafeUrlOptions {
readonly allowedSchemes?: readonly string[];
readonly allowRelative?: boolean;
readonly maxLength?: number;
}

JoinUrlOptions

interface JoinUrlOptions {
readonly confine?: boolean;
}

NormalizeUrlOptions

interface NormalizeUrlOptions {
readonly sortQuery?: boolean;
readonly removeFragment?: boolean;
readonly removeTrailingSlash?: boolean;
}

ParseQueryOptions

interface ParseQueryOptions {
readonly arrayFormat?: ArrayFormat;
readonly maxParams?: number;
readonly maxDepth?: number;
readonly maxLength?: number;
}

QueryInput

interface QueryInput {
readonly [key: string]: QueryInputValue;
}

QueryObject

Null-prototype object returned by parseQuery.

interface QueryObject {
[key: string]: QueryValue;
}

SafeRedirectOptions

interface SafeRedirectOptions {
readonly allowedOrigins?: readonly string[];
readonly fallback?: string;
}

ArrayFormat

type ArrayFormat = 'repeat' | 'brackets' | 'comma';

QueryInputValue

Values accepted by buildQuery: primitives, arrays of primitives, and nested objects (bracket notation).

type QueryInputValue = QueryPrimitive | readonly QueryPrimitive[] | QueryInput;

QueryPrimitive

A single query value. undefined is always skipped; null becomes an empty value unless skipNull.

type QueryPrimitive = string | number | boolean | bigint | Date | null | undefined;

QueryValue

Parsed query value.

type QueryValue = string | string[] | QueryObject;