Safe Redirects

Open redirects (?next=https://evil.com) are a common vulnerability. Concept Core restricts “back” and fallback URLs to internal application addresses via StorePreviousUrlMiddleware and ResponseFactory::back().

See also: Responses → redirects, Middleware → StorePreviousUrlMiddleware, validation errors.

Core classes

ClassRole
StorePreviousUrlMiddleware URL chain in session + Concept\Core\Http\Requests\RequestAttribute::SAFE_BACK_URL
ResponseFactory::back() Referer → safe back → fallback, with isInternalUrl() check
ResponseFactory::redirectByName() Redirect by route name via UrlGenerator::uri()
HandleValidationExceptionMiddleware HTML: flash + back() after ValidationException

StorePreviousUrlMiddleware

The middleware tracks two URLs in the session:

  • SessionKey::CURRENT_URL (_url_current) — last GET page (non-AJAX)
  • SessionKey::PREVIOUS_URL (_url_previous) — previous GET page

On GET (non-AJAX) the middleware stores the full request URI ((string) $request->getUri(), including the query string). If the URI differs from the current session value, the previous CURRENT_URL becomes PREVIOUS_URL, and the new URI becomes CURRENT_URL.

On every request the PSR-7 attribute Concept\Core\Http\Requests\RequestAttribute::SAFE_BACK_URL (safe_back_url) is set:

  • GETPREVIOUS_URL (the page you came from)
  • POST, PUT, PATCH, DELETE, and other non-GET methods — CURRENT_URL (the form page)

Typical stack position — after CSRF, before ShareViewDataMiddleware:

$router->lazyMiddleware(HandleValidationExceptionMiddleware::class);
$router->lazyMiddleware(VerifyCsrfTokenMiddleware::class);
$router->lazyMiddleware(StorePreviousUrlMiddleware::class);
$router->lazyMiddleware(ShareViewDataMiddleware::class);

AJAX requests (HttpHeader::X_REQUESTED_WITH = XMLHttpRequest) do not update the URL chain, so background poll requests do not break “back” after a form submit. The SAFE_BACK_URL attribute is still set from the session on such requests.

ResponseFactory::back()

Controllers or middleware after validation often redirect back:

return $this->response->back();
return $this->response->back(
    status: HttpStatusCode::FOUND,
    fallback: '/cabinet'
);

Target selection algorithm:

  1. Referer — if the header is not empty (priority #1)
  2. SAFE_BACK_URL — from the request attribute, only when Referer is absent
  3. Check — the chosen URL passes isInternalUrl(); otherwise — fallback
  4. Fallback — second method argument (default /)

An external Referer (different host) is not used — fallback applies. Default status is HttpStatusCode::FOUND (302).

What counts as “internal”

Private method ResponseFactory::isInternalUrl():

  • Empty string — rejected
  • No host in parse_url() — allowed only if the string starts with /
  • With host — only if host strictly matches the current PSR-7 request host
  • External domains, //evil.com, javascript:, etc. — rejected → fallback

The check applies only in back(). Methods redirect($url) and redirectByName() do not filter the target — do not pass untrusted user input there.

Three redirect methods

MethodWhenURL check
back() Return to form, validation error Yes (isInternalUrl)
redirectByName() PRG after POST, fixed target No (path from route generator)
redirect($url) Known static path No (developer responsibility)

After validation errors

HandleValidationExceptionMiddleware for HTML requests flashes errors and calls back() — the user returns to the form with the same URL, without an arbitrary redirect($_GET['return']).

See Requests → validation errors and Middleware.

Named routes

For fixed targets, a named redirect is safer — the path is built by UrlGeneratorInterface::uri():

return $this->response->redirectByName('cabinet.home');
return $this->response->redirectByName('admin.user.edit', ['id' => $id]);

Use back() for “return to form” UX, and redirectByName() when the target is always known (login, dashboard).

Do not accept redirect URLs from untrusted user input without a whitelist. If you need a ?next= parameter, compare against an allowlist of paths or route names.