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
| Class | Role |
|---|---|
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:
- GET —
PREVIOUS_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:
- Referer — if the header is not empty (priority #1)
- SAFE_BACK_URL — from the request attribute, only when Referer is absent
- Check — the chosen URL passes
isInternalUrl(); otherwise — fallback - 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
hoststrictly 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
| Method | When | URL 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.