Skip to content
← Back to Functions
Code

Safe Redirect Builder

Builds an internal redirect URL only if the target matches approved local paths.

Function signature

ogSafeRedirectUrl(candidate_url, allowed_prefixes = array(), fallback_url = '/')

Categories

  • Security

Parameters

candidate_urlCandidate URL from a form or return parameter.allowed_prefixesOptional approved local path prefixes.fallback_urlLocal fallback path.

Return value

Short public-safe status message.

  • url

Compatibility

Existing function name and call order preserved; metadata signature corrected to source.

Minimum PHP version: 7.4

Security notes

Validate request method, identity, permissions, and caller-owned allowlists before use; keep secrets out of public output.

Code

<?php

/*
 * Copyright (c) 2026 Jeffery L. Paris <jparis@phpog.com>.
 * Free for personal and internal use. Paid project use requires visible credit
 * to Jeffery L. Paris. Corporate use requires a paid license fee unless a
 * separate written license states otherwise.
 */

/**
 * Builds a safe internal redirect URL from a candidate path.
 *
 * External URLs, protocol-relative URLs, CR/LF header injection attempts, and
 * traversal-shaped paths are rejected. Only local site paths are returned.
 *
 * @param string $candidate_url Candidate URL from a form or return parameter.
 * @param array $allowed_prefixes Optional approved local path prefixes.
 * @param string $fallback_url Local fallback path.
 * @return array Safe redirect result with selected URL.
 */
function ogSafeRedirectUrl($candidate_url, $allowed_prefixes = array(), $fallback_url = '/') {
	$result = array(
		'success' => false,
		'message' => '',
		'data' => array('url' => '/')
	);

	$candidate_url = trim((string)$candidate_url);
	$fallback_url = trim((string)$fallback_url);

	if (empty($fallback_url) || substr($fallback_url, 0, 1) != '/') {
		$fallback_url = '/';
	}

	$result['data']['url'] = $fallback_url;

	if (empty($candidate_url)) {
		$result['success'] = true;
		$result['message'] = 'Fallback redirect selected.';
		return $result;
	}

	if (preg_match('/[\r\n]/', $candidate_url)) {
		$result['message'] = 'Redirect contains invalid control characters.';
		return $result;
	}

	if (strpos($candidate_url, '//') === 0) {
		$result['message'] = 'Protocol-relative redirect rejected.';
		return $result;
	}

	if (preg_match('/^[a-z][a-z0-9+.-]*:/i', $candidate_url)) {
		$result['message'] = 'External redirect rejected.';
		return $result;
	}

	if (substr($candidate_url, 0, 1) != '/') {
		$result['message'] = 'Redirect must be a local path.';
		return $result;
	}

	if (strpos($candidate_url, '/..') !== false || strpos($candidate_url, '..') === 0) {
		$result['message'] = 'Redirect path traversal rejected.';
		return $result;
	}

	$allowed = false;
	if (empty($allowed_prefixes)) {
		$allowed = true;
	} else {
		foreach ($allowed_prefixes as $prefix) {
			$prefix = trim((string)$prefix);
			if (!empty($prefix) && substr($prefix, 0, 1) == '/') {
				if (strpos($candidate_url, $prefix) === 0) {
					$allowed = true;
				}
			}
		}
	}

	if (!$allowed) {
		$result['message'] = 'Redirect path is not approved.';
		return $result;
	}

	$result['success'] = true;
	$result['message'] = 'Safe redirect accepted.';
	$result['data']['url'] = $candidate_url;

	return $result;
}