manualWebhookFailureClientAddress($request).':'.hash('sha256', (string) $scope); } /** * The connection address of the client. X-Forwarded-For is ignored, since * any client can set it and rotate it to get a new bucket. On cloud, a * valid CF-Connecting-IP is used. */ protected function manualWebhookFailureClientAddress(Request $request): string { $cloudflareIp = $request->header('CF-Connecting-IP'); if (isCloud() && is_string($cloudflareIp) && filter_var($cloudflareIp, FILTER_VALIDATE_IP) !== false) { return $cloudflareIp; } return (string) $request->server('REMOTE_ADDR'); } /** * Attempt identity for a static token, such as the GitLab X-Gitlab-Token. */ protected function manualWebhookTokenAttempt(string $token): string { return 'token:'.$token; } /** * Attempt identity for an HMAC signature. The signature depends on the raw * payload, so the same signature for another payload is a new attempt. */ protected function manualWebhookSignedPayloadAttempt(Request $request, string $signature): string { return 'hmac:'.hash('sha256', $request->getContent()).':'.$signature; } protected function hasTooManyManualWebhookFailures(string $failureKey): bool { return RateLimiter::tooManyAttempts($failureKey, self::MANUAL_WEBHOOK_MAX_FAILURES); } protected function tooManyManualWebhookFailuresResponse(string $failureKey): Response { $retryAfter = RateLimiter::availableIn($failureKey); return response([ 'status' => 'failed', 'message' => 'Too many failed webhook authentication attempts. Try again later.', ], 429)->header('Retry-After', (string) max($retryAfter, 1)); } /** * Count a failed attempt, unless the same attempt was already counted in * the current failure window. * * The rate limiter counter stays the source of truth. The set of seen * attempts only suppresses repeats. It is reset when a new window starts * and holds at most MANUAL_WEBHOOK_MAX_FAILURES entries, because the scope * is locked when the counter reaches that value. A lost update of the set * (concurrent requests) can only count a repeat again, never skip a new * attempt. */ protected function recordManualWebhookFailure(string $failureKey, string $attempt): void { $store = $this->manualWebhookFailureStore(); $seenKey = $failureKey.':seen'; $marker = hash_hmac('sha256', 'manual-webhook-failure:'.$attempt, (string) config('app.key')); $seen = RateLimiter::attempts($failureKey) > 0 ? $store->get($seenKey) : null; $seen = is_array($seen) ? $seen : []; if (in_array($marker, $seen, true)) { return; } RateLimiter::hit($failureKey, self::MANUAL_WEBHOOK_FAILURE_DECAY_SECONDS); if (count($seen) < self::MANUAL_WEBHOOK_MAX_FAILURES) { $seen[] = $marker; $store->put($seenKey, $seen, self::MANUAL_WEBHOOK_FAILURE_DECAY_SECONDS); } } /** * The cache store of the rate limiter, so the seen attempts live next to * the counter. */ protected function manualWebhookFailureStore(): Repository { return Cache::store(config('cache.limiter')); } }