GAEのPHPで動いているLavavelでThrottleやTrustProxiesが正しく動作しない
PHP7.3のLaravel5.6という古い環境で発生していた。
みんなが引っかかりそうな問題だけど調べても出てこなかったのでメモとして残そうと思う。
ある日気づくと429(Too Many Requests)エラーがポツポツと出るようになった。そんな設定をした覚えはないので、GAEとかnginxとかのレイヤーでエラー出てるのかな、と思い様子見していた。
しかし日増しにエラーが増えていく。なんだか忙しい時間に発生しているが再現性がないので放置していた。
そんなある日middleの設定を眺めていると以下の記述を見つけた。
protected $middlewareGroups = [
***
'api' => [
'throttle:60,1',
***
],
];
どうやらLaravelにはデフォルトで、同一接続元と思われるクライアントからのアクセスを制限しているらしい。上記の記述だと1分間に60回までと制限されているようだった。
同一接続元から1分間に60回までなら問題ないように思える。上記スロットルの設定をすると残り回数がレスポンスヘッダに返されるらしいので、ブラウザでアクセスしてみた。
x-ratelimit-limit: 60
x-ratelimit-remaining: 30
1回しかアクセスしていないのに残り30であった。もしかして同一接続元判定が動いてない?そこでソースを読んでみた。
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なのでまさにその環境であった。なので全てのリクエストが同一接続元と判定されているみたい。
通常接続元IPはREMOTE_ADDR
を見るが、プロキシなど使う場合はヘッダのX-Forwarded-For
を見ないといけないらしい。$request->ip()
を追ってみると以下を発見。
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
に*
を設定すればよいらしい。
設定してみた。
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;
}
しかし依然として状況は変わらない。*
を設定すると何が行われるのか見てみる。
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);
}
}
private function setTrustedProxyIpAddressesToTheCallingIp(Request $request)
{
$request->setTrustedProxies([$request->server->get('REMOTE_ADDR')], $this->getTrustedHeaderNames());
}
「信頼できるプロキシに接続元IPをセットすることで、全部信頼する」という実装のようだった。ではなぜ上手くいかないのだろうか。REMOTE_ADDR
をサーバで出力してみると空文字が返ってきた。以下のサイトに仕様の記載があった。
$_SERVER['REMOTE_ADDR'] 変数は App Engine では使用できません。
どうもGAE環境ではREMOTE_ADDR
が空文字になるらしい。そこで$request->ip()
の実装を追ってみる。
public function isFromTrustedProxy()
{
return self::$trustedProxies && IpUtils::checkIp($this->server->get('REMOTE_ADDR', ''), self::$trustedProxies);
}
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);
}
空文字なので以下が通らない。*
を指定していても空文字なら通らないようだった。
filter_var($requestIp, \FILTER_VALIDATE_IP, \FILTER_FLAG_IPV4)
ここまでで、GAE環境ではREMOTE_ADDR
が使えないため、TrustProxies
に*
を設定しても接続元IPが正常に取得できないことがわかった。どうしようもないのでThrottleRequests
を拡張して独自に接続元IPを取得するようにして解決した。
<?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);
}
}