Open8

GAEのPHPで動いているLavavelでThrottleやTrustProxiesが正しく動作しない

tsururintsururin

PHP7.3のLaravel5.6という古い環境で発生していた。
みんなが引っかかりそうな問題だけど調べても出てこなかったのでメモとして残そうと思う。

tsururintsururin

ある日気づくと429(Too Many Requests)エラーがポツポツと出るようになった。そんな設定をした覚えはないので、GAEとかnginxとかのレイヤーでエラー出てるのかな、と思い様子見していた。
しかし日増しにエラーが増えていく。なんだか忙しい時間に発生しているが再現性がないので放置していた。

tsururintsururin

そんなある日middleの設定を眺めていると以下の記述を見つけた。

app.php
protected $middlewareGroups = [
    ***
    'api' => [
        'throttle:60,1',
        ***
    ],
];

どうやらLaravelにはデフォルトで、同一接続元と思われるクライアントからのアクセスを制限しているらしい。上記の記述だと1分間に60回までと制限されているようだった。

tsururintsururin

同一接続元から1分間に60回までなら問題ないように思える。上記スロットルの設定をすると残り回数がレスポンスヘッダに返されるらしいので、ブラウザでアクセスしてみた。

x-ratelimit-limit: 60
x-ratelimit-remaining: 30

1回しかアクセスしていないのに残り30であった。もしかして同一接続元判定が動いてない?そこでソースを読んでみた。

ThrottleRequests.php
protected function resolveRequestSignature($request)
{
    if ($user = $request->user()) {
        return sha1($user->getAuthIdentifier());
    }

    if ($route = $request->route()) {
        return sha1($route->getDomain().'|'.$request->ip());
    }

    throw new RuntimeException('Unable to generate the request signature. Route unavailable.');
}

$request->ip()がとてもあやしい。調べてみるとプロキシやロードバランサを通した時には、そのIPが返って来てしまうらしい。GAEなのでまさにその環境であった。なので全てのリクエストが同一接続元と判定されているみたい。

tsururintsururin

通常接続元IPはREMOTE_ADDRを見るが、プロキシなど使う場合はヘッダのX-Forwarded-Forを見ないといけないらしい。$request->ip()を追ってみると以下を発見。

Request.php
public function getClientIps()
{
    $ip = $this->server->get('REMOTE_ADDR');

    if (!$this->isFromTrustedProxy()) {
        return [$ip];
    }

    return $this->getTrustedValues(self::HEADER_X_FORWARDED_FOR, $ip) ?: [$ip];
}

isFromTrustedProxyを通ればX-Forwarded-Forを使ってくれそうである。以下のページをみるとTrustProxies$proxies*を設定すればよいらしい。
https://readouble.com/laravel/5.6/ja/requests.html

tsururintsururin

設定してみた。

TrustProxies.php
class TrustProxies extends Middleware
{
    /**
     * The trusted proxies for this application.
     *
     * @var array
     */
    protected $proxies = '*';

    /**
     * The headers that should be used to detect proxies.
     *
     * @var int
     */
    protected $headers = Request::HEADER_X_FORWARDED_ALL;
}

しかし依然として状況は変わらない。*を設定すると何が行われるのか見てみる。

TrustProxies.php
protected function setTrustedProxyIpAddresses(Request $request)
{
    $trustedIps = $this->proxies ?: $this->config->get('trustedproxy.proxies');

    // Trust any IP address that calls us
    // `**` for backwards compatibility, but is deprecated
    if ($trustedIps === '*' || $trustedIps === '**') {
        return $this->setTrustedProxyIpAddressesToTheCallingIp($request);
    }

    // Support IPs addresses separated by comma
    $trustedIps = is_string($trustedIps) ? array_map('trim', explode(',', $trustedIps)) : $trustedIps;

    // Only trust specific IP addresses
    if (is_array($trustedIps)) {
        return $this->setTrustedProxyIpAddressesToSpecificIps($request, $trustedIps);
    }
}
TrustProxies.php
private function setTrustedProxyIpAddressesToTheCallingIp(Request $request)
{
    $request->setTrustedProxies([$request->server->get('REMOTE_ADDR')], $this->getTrustedHeaderNames());
}

「信頼できるプロキシに接続元IPをセットすることで、全部信頼する」という実装のようだった。ではなぜ上手くいかないのだろうか。REMOTE_ADDRをサーバで出力してみると空文字が返ってきた。以下のサイトに仕様の記載があった。

$_SERVER['REMOTE_ADDR'] 変数は App Engine では使用できません。

https://cloud.google.com/appengine/docs/standard/php-gen2/runtime?hl=ja

tsururintsururin

どうもGAE環境ではREMOTE_ADDRが空文字になるらしい。そこで$request->ip()の実装を追ってみる。

Request.php
public function isFromTrustedProxy()
{
    return self::$trustedProxies && IpUtils::checkIp($this->server->get('REMOTE_ADDR', ''), self::$trustedProxies);
}
IpUtils.php
public static function checkIp4($requestIp, $ip)
{
    $cacheKey = $requestIp.'-'.$ip;
    if (isset(self::$checkedIps[$cacheKey])) {
        return self::$checkedIps[$cacheKey];
    }

    if (!filter_var($requestIp, \FILTER_VALIDATE_IP, \FILTER_FLAG_IPV4)) {
        return self::$checkedIps[$cacheKey] = false;
    }

    if (str_contains($ip, '/')) {
        [$address, $netmask] = explode('/', $ip, 2);

        if ('0' === $netmask) {
            return self::$checkedIps[$cacheKey] = filter_var($address, \FILTER_VALIDATE_IP, \FILTER_FLAG_IPV4);
        }

        if ($netmask < 0 || $netmask > 32) {
            return self::$checkedIps[$cacheKey] = false;
        }
    } else {
        $address = $ip;
        $netmask = 32;
    }

    if (false === ip2long($address)) {
        return self::$checkedIps[$cacheKey] = false;
    }

    return self::$checkedIps[$cacheKey] = 0 === substr_compare(sprintf('%032b', ip2long($requestIp)), sprintf('%032b', ip2long($address)), 0, $netmask);
}

空文字なので以下が通らない。*を指定していても空文字なら通らないようだった。

IpUtils.php
filter_var($requestIp, \FILTER_VALIDATE_IP, \FILTER_FLAG_IPV4)
tsururintsururin

ここまでで、GAE環境ではREMOTE_ADDRが使えないため、TrustProxies*を設定しても接続元IPが正常に取得できないことがわかった。どうしようもないのでThrottleRequests を拡張して独自に接続元IPを取得するようにして解決した。

ThrottleRequestsMod.php
<?php

namespace App\Http\Middleware;

use Exception;
use Illuminate\Routing\Middleware\ThrottleRequests;

class ThrottleRequestsMod extends ThrottleRequests
{
    protected function resolveRequestSignature($request)
    {
        $tmp = $request->header('X-Forwarded-For');
        $ips = explode(',', $tmp);
        $ip = trim($ips[0]);
        if (!empty($ip)) {
            return $ip;
        }
        return parent::resolveRequestSignature($request);     
    }
}