Validate Referer host before redirecting in Response::goBack by snoopysecurity · Pull Request #1980 · typecho/typecho · GitHub
Skip to content

Validate Referer host before redirecting in Response::goBack#1980

Open
snoopysecurity wants to merge 1 commit into
typecho:masterfrom
snoopysecurity:master
Open

Validate Referer host before redirecting in Response::goBack#1980
snoopysecurity wants to merge 1 commit into
typecho:masterfrom
snoopysecurity:master

Conversation

@snoopysecurity

Copy link
Copy Markdown

PR fixes a security issue Open Redirect via Referer Header in Response::goBack

Summary of security issue

Typecho\Widget\Response::goBack redirects users to the value of the Referer request header without verifying that the host matches Typecho's own host. Because Widget\Security::protect() calls goBack() whenever a CSRF token is missing or mismatched, any /action/<name> endpoint can be turned into an open redirect by a cross-origin page that simply links to it without providing the _=<token> parameter. No authentication is required.

Summary of fix

In Response::goBack, only redirect to the Referer if its host matches the host the request was served from. Otherwise fall back to $default (or /). This preserves the legitimate UX (stale-token bounce back) while removing the cross-origin redirect primitive.

More information regarding the security issue

1. Widget\Security::protect()var/Widget/Security.php:63

public function protect()
{
    if ($this->enabled && $this->request->get('_') != $this->getToken($this->request->getReferer())) {
        $this->response->goBack();   // <-- redirects on CSRF failure
    }
}

Every action handler in /action/<name> calls $this->security->protect()
near the top of action(). When the _=<token> query parameter is missing
or stale, protect() triggers goBack() instead of throwing an error.

2. Typecho\Widget\Response::goBack()var/Typecho/Widget/Response.php:187

public function goBack(?string $suffix = null, ?string $default = null)
{
    $referer = $this->request->getReferer();

    if (!empty($referer)) {
        // ... optional suffix-merge logic ...
        $this->redirect($referer);    // <-- redirects to raw Referer
    } else {
        $this->redirect($default ?: '/');
    }
}

The Referer value is passed straight to redirect().

3. Typecho\Widget\Response::redirect() — line 172

public function redirect(string $location, bool $isPermanently = false)
{
    $location = Common::safeUrl($location);   // strips control chars only
    $this->response->setStatus($isPermanently ? 301 : 302)
        ->setHeader('Location', $location)
        ->respond();
}

4. Common::safeUrl()var/Typecho/Common.php:537

public static function safeUrl($url)
{
    $params = parse_url(str_replace(["\r", "\n", "\t", ' '], '', $url));

    if (isset($params['scheme'])) {
        if (!in_array($params['scheme'], ['http', 'https'])) {
            return '/';
        }
    }
    // strips ", ', <, > and a few other XSS-ish bytes from each URL part
    // ...
    return self::buildUrl($params);
}

safeUrl only restricts the scheme to http/https and strips
characters that could break out of an HTML attribute (", ', <, >).
It does not validate the host. So https://evil.example/anything
survives safeUrl unchanged.

Proof of concept

Attacker page (https://evil.example/phish.html)

<!doctype html>
<a href="https://victim.example/index.php/action/login">
  Continue to victim.example login
</a>

Steps

  1. Victim browses to https://evil.example/phish.html and clicks the link.
  2. The browser navigates to
    https://victim.example/index.php/action/login with
    Referer: https://evil.example/phish.html.
  3. Widget\Action::execute dispatches to Widget\Login::action.
  4. $this->security->protect() runs. The request has no _ parameter, so
    the token comparison fails.
  5. protect() calls $this->response->goBack().
  6. goBack() reads Referer: https://evil.example/phish.html, passes it
    to redirect(), which passes it to safeUrl(). The scheme is https,
    so safeUrl returns it unchanged.
  7. The server responds with
    302 Location: https://evil.example/phish.html.

Repro with curl

$ curl -s -o /dev/null -D - \
       -H 'Referer: https://evil.example/anything' \
       'https://victim.example/index.php/action/login'

HTTP/2 302
location: https://evil.example/anything

The redirect fires unauthenticated.

@snoopysecurity snoopysecurity changed the title dont use referrer based based open redirect in Response::goBack Validate Referer host before redirecting in Response::goBack May 17, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant