Compare commits

..

No commits in common. "main" and "security/improve-teapot-sanitization" have entirely different histories.

9 changed files with 291 additions and 1108 deletions

View file

@ -91,7 +91,7 @@ class TeapotController
}
/**
* Remove sensitive headers and enforce size limits before storing.
* Whitelist headers useful for bot detection before storing.
*/
protected function sanitizeHeaders(array $headers): array
{
@ -109,14 +109,7 @@ class TeapotController
'x-client-ip',
];
$headers = array_intersect_key($headers, array_flip($allowed));
// Enforce header count limit before passing to the model
if (count($headers) > HoneypotHit::HEADERS_MAX_COUNT) {
$headers = array_slice($headers, 0, HoneypotHit::HEADERS_MAX_COUNT, true);
}
return $headers;
return array_intersect_key($headers, array_flip($allowed));
}
/**

View file

@ -27,61 +27,6 @@ class HoneypotHit extends Model
'is_bot' => 'boolean',
];
/**
* Maximum number of headers to store per hit.
*/
public const HEADERS_MAX_COUNT = 50;
/**
* Maximum size in bytes for the serialised headers JSON (16 KB).
*/
public const HEADERS_MAX_SIZE = 16_384;
/**
* Validate and set the headers attribute, enforcing count and size limits.
*/
public function setHeadersAttribute(mixed $value): void
{
if (is_null($value)) {
$this->attributes['headers'] = null;
return;
}
if (is_string($value)) {
$decoded = json_decode($value, true);
if (json_last_error() !== JSON_ERROR_NONE) {
$this->attributes['headers'] = null;
return;
}
$value = $decoded;
}
if (! is_array($value)) {
$this->attributes['headers'] = null;
return;
}
// Limit header count
if (count($value) > self::HEADERS_MAX_COUNT) {
$value = array_slice($value, 0, self::HEADERS_MAX_COUNT, true);
}
// Check total size and truncate further if needed
$json = json_encode($value);
if (strlen($json) > self::HEADERS_MAX_SIZE) {
// Progressively reduce until under limit
while (strlen($json) > self::HEADERS_MAX_SIZE && count($value) > 0) {
array_pop($value);
$json = json_encode($value);
}
}
$this->attributes['headers'] = $json;
}
/**
* Severity levels for honeypot hits.
*

View file

@ -6,7 +6,6 @@ namespace Core\Mod\Hub\Models;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use InvalidArgumentException;
class Service extends Model
{
@ -122,55 +121,6 @@ class Service extends Model
return null;
}
/**
* Maximum size in bytes for the serialised metadata JSON (64 KB).
*/
public const METADATA_MAX_SIZE = 65_535;
/**
* Maximum number of top-level keys allowed in metadata.
*/
public const METADATA_MAX_KEYS = 100;
/**
* Validate and set the metadata attribute.
*/
public function setMetadataAttribute(mixed $value): void
{
if (is_null($value)) {
$this->attributes['metadata'] = null;
return;
}
if (is_string($value)) {
$decoded = json_decode($value, true);
if (json_last_error() !== JSON_ERROR_NONE) {
throw new InvalidArgumentException('Metadata must be valid JSON');
}
$value = $decoded;
}
if (! is_array($value)) {
throw new InvalidArgumentException('Metadata must be an array or null');
}
if (count($value) > self::METADATA_MAX_KEYS) {
throw new InvalidArgumentException(
'Metadata exceeds maximum of ' . self::METADATA_MAX_KEYS . ' keys'
);
}
$json = json_encode($value);
if (strlen($json) > self::METADATA_MAX_SIZE) {
throw new InvalidArgumentException(
'Metadata exceeds maximum size of ' . self::METADATA_MAX_SIZE . ' bytes'
);
}
$this->attributes['metadata'] = $json;
}
/**
* Check if a specific metadata key exists.
*/
@ -189,17 +139,9 @@ class Service extends Model
/**
* Set a metadata value.
*
* Keys must be non-empty and contain only alphanumeric characters, underscores, and hyphens.
*/
public function setMeta(string $key, mixed $value): void
{
if (empty($key) || ! preg_match('/^[a-zA-Z0-9_-]+$/', $key)) {
throw new InvalidArgumentException(
'Metadata key must be non-empty and contain only alphanumeric characters, underscores, and hyphens'
);
}
$metadata = $this->metadata ?? [];
$metadata[$key] = $value;
$this->metadata = $metadata;

View file

@ -1,72 +0,0 @@
<?php
declare(strict_types=1);
namespace Website\Hub\Concerns;
use Illuminate\Support\Facades\RateLimiter;
/**
* Provides rate limiting helpers for Livewire admin components.
*
* Usage:
* $this->rateLimit('tier-change', 10, function () { ... });
* $this->rateLimit('waitlist-export', 5, function () { ... });
*/
trait HasRateLimiting
{
/**
* Execute a callback with rate limiting for mutation actions.
*
* @param string $action Short action identifier (e.g. 'tier-change')
* @param int $maxAttempts Maximum attempts per minute
* @param callable $callback The action to execute if within limits
* @param int $decaySeconds Rate limit window in seconds
*/
protected function rateLimit(string $action, int $maxAttempts, callable $callback, int $decaySeconds = 60): mixed
{
$key = $this->rateLimitKey($action);
$executed = false;
$result = null;
RateLimiter::attempt(
$key,
$maxAttempts,
function () use ($callback, &$executed, &$result) {
$executed = true;
$result = $callback();
},
$decaySeconds,
);
if (! $executed) {
$this->onRateLimited($action, $key);
}
return $result;
}
/**
* Build a rate limit key scoped to the authenticated user.
*/
protected function rateLimitKey(string $action): string
{
return $action.':'.auth()->id();
}
/**
* Handle rate limit exceeded - sets action message on the component.
*/
protected function onRateLimited(string $action, string $key): void
{
$seconds = RateLimiter::availableIn($key);
if (property_exists($this, 'actionMessage') && property_exists($this, 'actionType')) {
$this->actionMessage = "Too many requests. Please wait {$seconds} seconds before trying again.";
$this->actionType = 'error';
} else {
session()->flash('error', "Too many requests. Please wait {$seconds} seconds before trying again.");
}
}
}

View file

@ -14,12 +14,9 @@ use Core\Tenant\Models\Package;
use Core\Tenant\Models\User;
use Core\Tenant\Models\Workspace;
use Core\Tenant\Services\EntitlementService;
use Website\Hub\Concerns\HasRateLimiting;
class PlatformUser extends Component
{
use HasRateLimiting;
public User $user;
// Editable fields
@ -83,31 +80,27 @@ class PlatformUser extends Component
public function saveTier(): void
{
$this->rateLimit('admin-tier-change', 10, function () {
$this->user->tier = UserTier::from($this->editingTier);
$this->user->save();
$this->user->tier = UserTier::from($this->editingTier);
$this->user->save();
$this->actionMessage = "Tier updated to {$this->editingTier}.";
$this->actionType = 'success';
});
$this->actionMessage = "Tier updated to {$this->editingTier}.";
$this->actionType = 'success';
}
public function saveVerification(): void
{
$this->rateLimit('admin-verification', 10, function () {
if ($this->editingVerified && ! $this->user->email_verified_at) {
$this->user->email_verified_at = now();
} elseif (! $this->editingVerified) {
$this->user->email_verified_at = null;
}
if ($this->editingVerified && ! $this->user->email_verified_at) {
$this->user->email_verified_at = now();
} elseif (! $this->editingVerified) {
$this->user->email_verified_at = null;
}
$this->user->save();
$this->user->save();
$this->actionMessage = $this->editingVerified
? 'Email marked as verified.'
: 'Email verification removed.';
$this->actionType = 'success';
});
$this->actionMessage = $this->editingVerified
? 'Email marked as verified.'
: 'Email verification removed.';
$this->actionType = 'success';
}
public function resendVerification(): void
@ -130,23 +123,21 @@ class PlatformUser extends Component
*/
public function exportUserData()
{
return $this->rateLimit('admin-export', 5, function () {
$data = $this->collectUserData();
$data = $this->collectUserData();
$filename = "user-data-{$this->user->id}-".now()->format('Y-m-d-His').'.json';
$filename = "user-data-{$this->user->id}-".now()->format('Y-m-d-His').'.json';
Log::info('GDPR data export performed by admin', [
'admin_id' => auth()->id(),
'target_user_id' => $this->user->id,
'target_email' => $this->user->email,
]);
Log::info('GDPR data export performed by admin', [
'admin_id' => auth()->id(),
'target_user_id' => $this->user->id,
'target_email' => $this->user->email,
]);
return response()->streamDownload(function () use ($data) {
echo json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE);
}, $filename, [
'Content-Type' => 'application/json',
]);
});
return response()->streamDownload(function () use ($data) {
echo json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE);
}, $filename, [
'Content-Type' => 'application/json',
]);
}
/**
@ -249,34 +240,32 @@ class PlatformUser extends Component
*/
public function scheduleDelete(): void
{
$this->rateLimit('admin-deletion', 3, function () {
if ($this->user->isHades() && $this->user->id === auth()->id()) {
$this->actionMessage = 'You cannot delete your own Hades account from here.';
$this->actionType = 'error';
$this->showDeleteConfirm = false;
return;
}
$request = AccountDeletionRequest::createForUser($this->user, $this->deleteReason ?: 'Admin initiated - GDPR request');
Log::warning('GDPR deletion scheduled by admin', [
'admin_id' => auth()->id(),
'target_user_id' => $this->user->id,
'target_email' => $this->user->email,
'immediate' => $this->immediateDelete,
'reason' => $this->deleteReason,
]);
if ($this->immediateDelete) {
$this->executeImmediateDelete($request);
} else {
$this->actionMessage = 'Account deletion scheduled. Will be deleted in 7 days unless cancelled.';
$this->actionType = 'warning';
}
if ($this->user->isHades() && $this->user->id === auth()->id()) {
$this->actionMessage = 'You cannot delete your own Hades account from here.';
$this->actionType = 'error';
$this->showDeleteConfirm = false;
});
return;
}
$request = AccountDeletionRequest::createForUser($this->user, $this->deleteReason ?: 'Admin initiated - GDPR request');
Log::warning('GDPR deletion scheduled by admin', [
'admin_id' => auth()->id(),
'target_user_id' => $this->user->id,
'target_email' => $this->user->email,
'immediate' => $this->immediateDelete,
'reason' => $this->deleteReason,
]);
if ($this->immediateDelete) {
$this->executeImmediateDelete($request);
} else {
$this->actionMessage = 'Account deletion scheduled. Will be deleted in 7 days unless cancelled.';
$this->actionType = 'warning';
}
$this->showDeleteConfirm = false;
}
/**
@ -351,52 +340,50 @@ class PlatformUser extends Component
*/
public function anonymizeUser(): void
{
$this->rateLimit('admin-deletion', 3, function () {
if ($this->user->isHades() && $this->user->id === auth()->id()) {
$this->actionMessage = 'You cannot anonymize your own account.';
$this->actionType = 'error';
if ($this->user->isHades() && $this->user->id === auth()->id()) {
$this->actionMessage = 'You cannot anonymize your own account.';
$this->actionType = 'error';
return;
}
return;
}
$originalEmail = $this->user->email;
$anonymizedId = 'anon_'.$this->user->id.'_'.now()->timestamp;
$originalEmail = $this->user->email;
$anonymizedId = 'anon_'.$this->user->id.'_'.now()->timestamp;
DB::transaction(function () use ($anonymizedId) {
$this->user->update([
'name' => 'Anonymized User',
'email' => $anonymizedId.'@anonymized.local',
'password' => bcrypt(str()->random(64)),
'tier' => UserTier::FREE,
'email_verified_at' => null,
'cached_stats' => null,
]);
// Remove from all workspaces
if (method_exists($this->user, 'hostWorkspaces')) {
$this->user->hostWorkspaces()->detach();
}
// Cancel any pending deletions
AccountDeletionRequest::where('user_id', $this->user->id)
->whereNull('completed_at')
->whereNull('cancelled_at')
->update(['cancelled_at' => now()]);
});
Log::warning('User anonymized by admin (GDPR)', [
'admin_id' => auth()->id(),
'target_user_id' => $this->user->id,
'original_email' => $originalEmail,
DB::transaction(function () use ($anonymizedId) {
$this->user->update([
'name' => 'Anonymized User',
'email' => $anonymizedId.'@anonymized.local',
'password' => bcrypt(str()->random(64)),
'tier' => UserTier::FREE,
'email_verified_at' => null,
'cached_stats' => null,
]);
$this->user->refresh();
$this->editingTier = $this->user->tier?->value ?? 'free';
$this->editingVerified = false;
// Remove from all workspaces
if (method_exists($this->user, 'hostWorkspaces')) {
$this->user->hostWorkspaces()->detach();
}
$this->actionMessage = 'User data has been anonymized.';
$this->actionType = 'success';
// Cancel any pending deletions
AccountDeletionRequest::where('user_id', $this->user->id)
->whereNull('completed_at')
->whereNull('cancelled_at')
->update(['cancelled_at' => now()]);
});
Log::warning('User anonymized by admin (GDPR)', [
'admin_id' => auth()->id(),
'target_user_id' => $this->user->id,
'original_email' => $originalEmail,
]);
$this->user->refresh();
$this->editingTier = $this->user->tier?->value ?? 'free';
$this->editingVerified = false;
$this->actionMessage = 'User data has been anonymized.';
$this->actionType = 'success';
}
/**
@ -461,35 +448,33 @@ class PlatformUser extends Component
*/
public function provisionPackage(): void
{
$this->rateLimit('admin-entitlement', 10, function () {
if (! $this->selectedWorkspaceId || ! $this->selectedPackageCode) {
$this->actionMessage = 'Please select a workspace and package.';
$this->actionType = 'warning';
if (! $this->selectedWorkspaceId || ! $this->selectedPackageCode) {
$this->actionMessage = 'Please select a workspace and package.';
$this->actionType = 'warning';
return;
}
return;
}
$workspace = Workspace::findOrFail($this->selectedWorkspaceId);
$package = Package::where('code', $this->selectedPackageCode)->firstOrFail();
$workspace = Workspace::findOrFail($this->selectedWorkspaceId);
$package = Package::where('code', $this->selectedPackageCode)->firstOrFail();
$entitlements = app(EntitlementService::class);
$entitlements->provisionPackage($workspace, $this->selectedPackageCode, [
'source' => 'admin',
]);
$entitlements = app(EntitlementService::class);
$entitlements->provisionPackage($workspace, $this->selectedPackageCode, [
'source' => 'admin',
]);
Log::info('Package provisioned by admin', [
'admin_id' => auth()->id(),
'user_id' => $this->user->id,
'workspace_id' => $workspace->id,
'package_code' => $this->selectedPackageCode,
]);
Log::info('Package provisioned by admin', [
'admin_id' => auth()->id(),
'user_id' => $this->user->id,
'workspace_id' => $workspace->id,
'package_code' => $this->selectedPackageCode,
]);
$this->actionMessage = "Package '{$package->name}' provisioned to workspace '{$workspace->name}'.";
$this->actionType = 'success';
$this->actionMessage = "Package '{$package->name}' provisioned to workspace '{$workspace->name}'.";
$this->actionType = 'success';
$this->closePackageModal();
unset($this->workspaces); // Clear computed cache
});
$this->closePackageModal();
unset($this->workspaces); // Clear computed cache
}
/**
@ -497,36 +482,34 @@ class PlatformUser extends Component
*/
public function revokePackage(int $workspaceId, string $packageCode): void
{
$this->rateLimit('admin-entitlement', 10, function () use ($workspaceId, $packageCode) {
$workspace = Workspace::findOrFail($workspaceId);
$workspace = Workspace::findOrFail($workspaceId);
// Verify this belongs to one of the user's workspaces
if (! $this->user->hostWorkspaces->contains($workspace)) {
$this->actionMessage = 'This workspace does not belong to this user.';
$this->actionType = 'error';
// Verify this belongs to one of the user's workspaces
if (! $this->user->hostWorkspaces->contains($workspace)) {
$this->actionMessage = 'This workspace does not belong to this user.';
$this->actionType = 'error';
return;
}
return;
}
$package = Package::where('code', $packageCode)->first();
$packageName = $package?->name ?? $packageCode;
$workspaceName = $workspace->name;
$package = Package::where('code', $packageCode)->first();
$packageName = $package?->name ?? $packageCode;
$workspaceName = $workspace->name;
$entitlements = app(EntitlementService::class);
$entitlements->revokePackage($workspace, $packageCode, 'admin');
$entitlements = app(EntitlementService::class);
$entitlements->revokePackage($workspace, $packageCode, 'admin');
Log::info('Package revoked by admin', [
'admin_id' => auth()->id(),
'user_id' => $this->user->id,
'workspace_id' => $workspace->id,
'package_code' => $packageCode,
]);
Log::info('Package revoked by admin', [
'admin_id' => auth()->id(),
'user_id' => $this->user->id,
'workspace_id' => $workspace->id,
'package_code' => $packageCode,
]);
$this->actionMessage = "Package '{$packageName}' revoked from workspace '{$workspaceName}'.";
$this->actionType = 'success';
$this->actionMessage = "Package '{$packageName}' revoked from workspace '{$workspaceName}'.";
$this->actionType = 'success';
unset($this->workspaces); // Clear computed cache
});
unset($this->workspaces); // Clear computed cache
}
// ─────────────────────────────────────────────────────────────
@ -603,70 +586,68 @@ class PlatformUser extends Component
*/
public function provisionEntitlement(): void
{
$this->rateLimit('admin-entitlement', 10, function () {
if (! $this->entitlementWorkspaceId || ! $this->entitlementFeatureCode) {
$this->actionMessage = 'Please select a workspace and feature.';
$this->actionType = 'warning';
if (! $this->entitlementWorkspaceId || ! $this->entitlementFeatureCode) {
$this->actionMessage = 'Please select a workspace and feature.';
$this->actionType = 'warning';
return;
}
return;
}
$workspace = Workspace::findOrFail($this->entitlementWorkspaceId);
$feature = Feature::where('code', $this->entitlementFeatureCode)->first();
$workspace = Workspace::findOrFail($this->entitlementWorkspaceId);
$feature = Feature::where('code', $this->entitlementFeatureCode)->first();
if (! $feature) {
$this->actionMessage = 'Feature not found.';
$this->actionType = 'error';
if (! $feature) {
$this->actionMessage = 'Feature not found.';
$this->actionType = 'error';
return;
}
return;
}
// Verify this belongs to one of the user's workspaces
if (! $this->user->hostWorkspaces->contains($workspace)) {
$this->actionMessage = 'This workspace does not belong to this user.';
$this->actionType = 'error';
// Verify this belongs to one of the user's workspaces
if (! $this->user->hostWorkspaces->contains($workspace)) {
$this->actionMessage = 'This workspace does not belong to this user.';
$this->actionType = 'error';
return;
}
return;
}
$options = [
'source' => 'admin',
'boost_type' => match ($this->entitlementType) {
'enable' => Boost::BOOST_TYPE_ENABLE,
'add_limit' => Boost::BOOST_TYPE_ADD_LIMIT,
'unlimited' => Boost::BOOST_TYPE_UNLIMITED,
default => Boost::BOOST_TYPE_ENABLE,
},
'duration_type' => $this->entitlementDuration === 'permanent'
? Boost::DURATION_PERMANENT
: Boost::DURATION_DURATION,
];
$options = [
'source' => 'admin',
'boost_type' => match ($this->entitlementType) {
'enable' => Boost::BOOST_TYPE_ENABLE,
'add_limit' => Boost::BOOST_TYPE_ADD_LIMIT,
'unlimited' => Boost::BOOST_TYPE_UNLIMITED,
default => Boost::BOOST_TYPE_ENABLE,
},
'duration_type' => $this->entitlementDuration === 'permanent'
? Boost::DURATION_PERMANENT
: Boost::DURATION_DURATION,
];
if ($this->entitlementType === 'add_limit' && $this->entitlementLimit) {
$options['limit_value'] = $this->entitlementLimit;
}
if ($this->entitlementType === 'add_limit' && $this->entitlementLimit) {
$options['limit_value'] = $this->entitlementLimit;
}
if ($this->entitlementDuration === 'duration' && $this->entitlementExpiresAt) {
$options['expires_at'] = $this->entitlementExpiresAt;
}
if ($this->entitlementDuration === 'duration' && $this->entitlementExpiresAt) {
$options['expires_at'] = $this->entitlementExpiresAt;
}
$entitlements = app(EntitlementService::class);
$entitlements->provisionBoost($workspace, $this->entitlementFeatureCode, $options);
$entitlements = app(EntitlementService::class);
$entitlements->provisionBoost($workspace, $this->entitlementFeatureCode, $options);
Log::info('Entitlement provisioned by admin', [
'admin_id' => auth()->id(),
'user_id' => $this->user->id,
'workspace_id' => $workspace->id,
'feature_code' => $this->entitlementFeatureCode,
'type' => $this->entitlementType,
]);
Log::info('Entitlement provisioned by admin', [
'admin_id' => auth()->id(),
'user_id' => $this->user->id,
'workspace_id' => $workspace->id,
'feature_code' => $this->entitlementFeatureCode,
'type' => $this->entitlementType,
]);
$this->actionMessage = "Entitlement '{$feature->name}' added to workspace '{$workspace->name}'.";
$this->actionType = 'success';
$this->actionMessage = "Entitlement '{$feature->name}' added to workspace '{$workspace->name}'.";
$this->actionType = 'success';
$this->closeEntitlementModal();
unset($this->workspaceEntitlements);
});
$this->closeEntitlementModal();
unset($this->workspaceEntitlements);
}
/**
@ -674,36 +655,34 @@ class PlatformUser extends Component
*/
public function removeBoost(int $boostId): void
{
$this->rateLimit('admin-entitlement', 10, function () use ($boostId) {
$boost = Boost::findOrFail($boostId);
$boost = Boost::findOrFail($boostId);
// Verify this belongs to one of the user's workspaces
$workspace = $boost->workspace;
if (! $this->user->hostWorkspaces->contains($workspace)) {
$this->actionMessage = 'This boost does not belong to this user.';
$this->actionType = 'error';
// Verify this belongs to one of the user's workspaces
$workspace = $boost->workspace;
if (! $this->user->hostWorkspaces->contains($workspace)) {
$this->actionMessage = 'This boost does not belong to this user.';
$this->actionType = 'error';
return;
}
return;
}
$featureCode = $boost->feature_code;
$workspaceName = $workspace->name;
$featureCode = $boost->feature_code;
$workspaceName = $workspace->name;
$boost->update(['status' => Boost::STATUS_CANCELLED]);
$boost->update(['status' => Boost::STATUS_CANCELLED]);
Log::info('Boost removed by admin', [
'admin_id' => auth()->id(),
'user_id' => $this->user->id,
'workspace_id' => $workspace->id,
'boost_id' => $boostId,
'feature_code' => $featureCode,
]);
Log::info('Boost removed by admin', [
'admin_id' => auth()->id(),
'user_id' => $this->user->id,
'workspace_id' => $workspace->id,
'boost_id' => $boostId,
'feature_code' => $featureCode,
]);
$this->actionMessage = "Boost for '{$featureCode}' removed from workspace '{$workspaceName}'.";
$this->actionType = 'success';
$this->actionMessage = "Boost for '{$featureCode}' removed from workspace '{$workspaceName}'.";
$this->actionType = 'success';
unset($this->workspaceEntitlements);
});
unset($this->workspaceEntitlements);
}
public function render()

View file

@ -15,12 +15,9 @@ use Core\Tenant\Mail\AccountDeletionRequested;
use Core\Tenant\Models\AccountDeletionRequest;
use Core\Tenant\Models\User;
use Core\Tenant\Services\UserStatsService;
use Website\Hub\Concerns\HasRateLimiting;
class Settings extends Component
{
use HasRateLimiting;
// Active section for sidebar navigation
#[Url(as: 'tab')]
public string $activeSection = 'profile';
@ -111,12 +108,6 @@ class Settings extends Component
$this->timezones = UserStatsService::getTimezoneList();
}
protected function onRateLimited(string $action, string $key): void
{
$seconds = \Illuminate\Support\Facades\RateLimiter::availableIn($key);
Flux::toast(text: "Too many requests. Please wait {$seconds} seconds before trying again.", variant: 'danger');
}
protected function getUserSetting(string $name, mixed $default = null): mixed
{
$setting = Setting::where('user_id', Auth::id())
@ -128,21 +119,19 @@ class Settings extends Component
public function updateProfile(): void
{
$this->rateLimit('profile-update', 20, function () {
$this->validate([
'name' => ['required', 'string', 'max:255'],
'email' => ['required', 'email', 'max:255', 'unique:'.(new User)->getTable().',email,'.Auth::id()],
]);
$this->validate([
'name' => ['required', 'string', 'max:255'],
'email' => ['required', 'email', 'max:255', 'unique:'.(new User)->getTable().',email,'.Auth::id()],
]);
$user = User::findOrFail(Auth::id());
$user->update([
'name' => $this->name,
'email' => $this->email,
]);
$user = User::findOrFail(Auth::id());
$user->update([
'name' => $this->name,
'email' => $this->email,
]);
$this->dispatch('profile-updated');
Flux::toast(text: __('hub::hub.settings.messages.profile_updated'), variant: 'success');
});
$this->dispatch('profile-updated');
Flux::toast(text: __('hub::hub.settings.messages.profile_updated'), variant: 'success');
}
public function updatePreferences(): void
@ -174,24 +163,22 @@ class Settings extends Component
public function updatePassword(): void
{
$this->rateLimit('password-change', 5, function () {
$this->validate([
'current_password' => ['required', 'current_password'],
'new_password' => ['required', 'confirmed', Password::defaults()],
]);
$this->validate([
'current_password' => ['required', 'current_password'],
'new_password' => ['required', 'confirmed', Password::defaults()],
]);
$user = User::findOrFail(Auth::id());
$user->update([
'password' => Hash::make($this->new_password),
]);
$user = User::findOrFail(Auth::id());
$user->update([
'password' => Hash::make($this->new_password),
]);
$this->current_password = '';
$this->new_password = '';
$this->new_password_confirmation = '';
$this->current_password = '';
$this->new_password = '';
$this->new_password_confirmation = '';
$this->dispatch('password-updated');
Flux::toast(text: __('hub::hub.settings.messages.password_updated'), variant: 'success');
});
$this->dispatch('password-updated');
Flux::toast(text: __('hub::hub.settings.messages.password_updated'), variant: 'success');
}
public function enableTwoFactor(): void
@ -226,22 +213,20 @@ class Settings extends Component
public function requestAccountDeletion(): void
{
$this->rateLimit('account-deletion', 3, function () {
// Get the base user model for the app
$user = \Core\Tenant\Models\User::findOrFail(Auth::id());
// Get the base user model for the app
$user = \Core\Tenant\Models\User::findOrFail(Auth::id());
// Create the deletion request
$deletionRequest = AccountDeletionRequest::createForUser($user, $this->deleteReason ?: null);
// Create the deletion request
$deletionRequest = AccountDeletionRequest::createForUser($user, $this->deleteReason ?: null);
// Send confirmation email
Mail::to($user->email)->send(new AccountDeletionRequested($deletionRequest));
// Send confirmation email
Mail::to($user->email)->send(new AccountDeletionRequested($deletionRequest));
$this->pendingDeletion = $deletionRequest;
$this->showDeleteConfirmation = false;
$this->deleteReason = '';
$this->pendingDeletion = $deletionRequest;
$this->showDeleteConfirmation = false;
$this->deleteReason = '';
Flux::toast(text: __('hub::hub.settings.messages.deletion_scheduled'), variant: 'warning');
});
Flux::toast(text: __('hub::hub.settings.messages.deletion_scheduled'), variant: 'warning');
}
public function cancelAccountDeletion(): void

View file

@ -10,12 +10,10 @@ use Livewire\Attributes\Computed;
use Livewire\Attributes\Title;
use Livewire\Component;
use Livewire\WithPagination;
use Website\Hub\Concerns\HasRateLimiting;
#[Title('Waitlist')]
class WaitlistManager extends Component
{
use HasRateLimiting;
use WithPagination;
// Filters
@ -70,21 +68,19 @@ class WaitlistManager extends Component
*/
public function sendInvite(int $id): void
{
$this->rateLimit('admin-mutation', 10, function () use ($id) {
$entry = WaitlistEntry::findOrFail($id);
$entry = WaitlistEntry::findOrFail($id);
if ($entry->isInvited()) {
session()->flash('error', 'This person has already been invited.');
if ($entry->isInvited()) {
session()->flash('error', 'This person has already been invited.');
return;
}
return;
}
$entry->generateInviteCode();
$entry->notify(new WaitlistInviteNotification($entry));
$entry->generateInviteCode();
$entry->notify(new WaitlistInviteNotification($entry));
session()->flash('message', "Invite sent to {$entry->email}");
$this->refreshStats();
});
session()->flash('message', "Invite sent to {$entry->email}");
$this->refreshStats();
}
/**
@ -92,30 +88,28 @@ class WaitlistManager extends Component
*/
public function sendBulkInvites(): void
{
$this->rateLimit('admin-mutation', 10, function () {
$entries = WaitlistEntry::whereIn('id', $this->selected)
->whereNull('invited_at')
->get();
$entries = WaitlistEntry::whereIn('id', $this->selected)
->whereNull('invited_at')
->get();
if ($entries->isEmpty()) {
session()->flash('error', 'No pending entries selected.');
if ($entries->isEmpty()) {
session()->flash('error', 'No pending entries selected.');
return;
}
return;
}
$count = 0;
foreach ($entries as $entry) {
$entry->generateInviteCode();
$entry->notify(new WaitlistInviteNotification($entry));
$count++;
}
$count = 0;
foreach ($entries as $entry) {
$entry->generateInviteCode();
$entry->notify(new WaitlistInviteNotification($entry));
$count++;
}
$this->selected = [];
$this->selectAll = false;
$this->selected = [];
$this->selectAll = false;
session()->flash('message', "Sent {$count} invite(s) successfully.");
$this->refreshStats();
});
session()->flash('message', "Sent {$count} invite(s) successfully.");
$this->refreshStats();
}
/**
@ -147,20 +141,18 @@ class WaitlistManager extends Component
*/
public function delete(int $id): void
{
$this->rateLimit('admin-mutation', 10, function () use ($id) {
$entry = WaitlistEntry::findOrFail($id);
$entry = WaitlistEntry::findOrFail($id);
if ($entry->hasConverted()) {
session()->flash('error', 'Cannot delete entries that have converted to users.');
if ($entry->hasConverted()) {
session()->flash('error', 'Cannot delete entries that have converted to users.');
return;
}
return;
}
$entry->delete();
$entry->delete();
session()->flash('message', 'Entry deleted.');
$this->refreshStats();
});
session()->flash('message', 'Entry deleted.');
$this->refreshStats();
}
/**
@ -179,32 +171,30 @@ class WaitlistManager extends Component
*/
public function export()
{
return $this->rateLimit('admin-export', 5, function () {
$entries = $this->getFilteredQuery()->get();
$entries = $this->getFilteredQuery()->get();
$csv = "Email,Name,Interest,Source,Status,Signed Up,Invited,Registered\n";
$csv = "Email,Name,Interest,Source,Status,Signed Up,Invited,Registered\n";
foreach ($entries as $entry) {
$status = $entry->hasConverted() ? 'Converted' : ($entry->isInvited() ? 'Invited' : 'Pending');
$csv .= sprintf(
"%s,%s,%s,%s,%s,%s,%s,%s\n",
$entry->email,
$entry->name ?? '',
$entry->interest ?? '',
$entry->source ?? '',
$status,
$entry->created_at->format('Y-m-d'),
$entry->invited_at?->format('Y-m-d') ?? '',
$entry->registered_at?->format('Y-m-d') ?? ''
);
}
foreach ($entries as $entry) {
$status = $entry->hasConverted() ? 'Converted' : ($entry->isInvited() ? 'Invited' : 'Pending');
$csv .= sprintf(
"%s,%s,%s,%s,%s,%s,%s,%s\n",
$entry->email,
$entry->name ?? '',
$entry->interest ?? '',
$entry->source ?? '',
$status,
$entry->created_at->format('Y-m-d'),
$entry->invited_at?->format('Y-m-d') ?? '',
$entry->registered_at?->format('Y-m-d') ?? ''
);
}
return response()->streamDownload(function () use ($csv) {
echo $csv;
}, 'waitlist-export-'.now()->format('Y-m-d').'.csv', [
'Content-Type' => 'text/csv',
]);
});
return response()->streamDownload(function () use ($csv) {
echo $csv;
}, 'waitlist-export-'.now()->format('Y-m-d').'.csv', [
'Content-Type' => 'text/csv',
]);
}
protected function refreshStats(): void

View file

@ -1,299 +0,0 @@
<?php
/*
* Core PHP Framework
*
* Licensed under the European Union Public Licence (EUPL) v1.2.
* See LICENSE file for details.
*/
declare(strict_types=1);
use Core\Mod\Hub\Models\HoneypotHit;
use Core\Mod\Hub\Models\Service;
/**
* Tests for JSON metadata field validation on Service and HoneypotHit models.
*
* Ensures size limits, key count limits, and key format validation
* are enforced to prevent mass assignment of arbitrary data.
*/
beforeEach(function () {
if (! \Illuminate\Support\Facades\Schema::hasTable('platform_services')) {
\Illuminate\Support\Facades\Schema::create('platform_services', function ($table) {
$table->id();
$table->string('code')->unique();
$table->string('module')->nullable();
$table->string('name');
$table->string('tagline')->nullable();
$table->text('description')->nullable();
$table->string('icon')->nullable();
$table->string('color')->nullable();
$table->string('marketing_domain')->nullable();
$table->string('website_class')->nullable();
$table->string('marketing_url')->nullable();
$table->string('docs_url')->nullable();
$table->boolean('is_enabled')->default(true);
$table->boolean('is_public')->default(true);
$table->boolean('is_featured')->default(false);
$table->string('entitlement_code')->nullable();
$table->integer('sort_order')->default(50);
$table->json('metadata')->nullable();
$table->timestamps();
});
}
if (! \Illuminate\Support\Facades\Schema::hasTable('honeypot_hits')) {
\Illuminate\Support\Facades\Schema::create('honeypot_hits', function ($table) {
$table->id();
$table->string('ip_address', 45);
$table->string('user_agent', 1000)->nullable();
$table->string('referer', 2000)->nullable();
$table->string('path', 255);
$table->string('method', 10);
$table->json('headers')->nullable();
$table->string('country', 2)->nullable();
$table->string('city', 100)->nullable();
$table->boolean('is_bot')->default(false);
$table->string('bot_name', 100)->nullable();
$table->string('severity', 20)->default('warning');
$table->timestamps();
$table->index('ip_address');
$table->index('created_at');
$table->index('is_bot');
});
}
});
afterEach(function () {
Service::query()->delete();
HoneypotHit::query()->delete();
});
// =============================================================================
// Service Metadata Validation
// =============================================================================
describe('Service metadata validation', function () {
describe('setMetadataAttribute mutator', function () {
it('accepts valid metadata arrays', function () {
$service = new Service();
$service->metadata = ['key' => 'value', 'count' => 42];
expect($service->getAttributes()['metadata'])->toBe('{"key":"value","count":42}');
});
it('accepts null metadata', function () {
$service = new Service();
$service->metadata = null;
expect($service->getAttributes()['metadata'])->toBeNull();
});
it('accepts valid JSON strings', function () {
$service = new Service();
$service->metadata = '{"key":"value"}';
expect($service->getAttributes()['metadata'])->toBe('{"key":"value"}');
});
it('rejects invalid JSON strings', function () {
$service = new Service();
expect(fn () => $service->metadata = '{invalid json}')
->toThrow(InvalidArgumentException::class, 'Metadata must be valid JSON');
});
it('rejects non-array non-string values', function () {
$service = new Service();
expect(fn () => $service->metadata = 12345)
->toThrow(InvalidArgumentException::class, 'Metadata must be an array or null');
});
it('rejects metadata exceeding maximum key count', function () {
$service = new Service();
$data = [];
for ($i = 0; $i <= Service::METADATA_MAX_KEYS; $i++) {
$data["key_{$i}"] = 'value';
}
expect(fn () => $service->metadata = $data)
->toThrow(InvalidArgumentException::class, 'Metadata exceeds maximum of');
});
it('accepts metadata at the maximum key count', function () {
$service = new Service();
$data = [];
for ($i = 0; $i < Service::METADATA_MAX_KEYS; $i++) {
$data["key_{$i}"] = 'v';
}
$service->metadata = $data;
expect(json_decode($service->getAttributes()['metadata'], true))
->toHaveCount(Service::METADATA_MAX_KEYS);
});
it('rejects metadata exceeding maximum size', function () {
$service = new Service();
// Create a payload that exceeds 64KB
$data = ['large' => str_repeat('x', Service::METADATA_MAX_SIZE)];
expect(fn () => $service->metadata = $data)
->toThrow(InvalidArgumentException::class, 'Metadata exceeds maximum size');
});
it('persists valid metadata to database', function () {
$service = Service::create([
'code' => 'test-service',
'name' => 'Test Service',
'metadata' => ['version' => '1.0', 'features' => ['a', 'b']],
]);
$fresh = Service::find($service->id);
expect($fresh->metadata)->toBe(['version' => '1.0', 'features' => ['a', 'b']]);
});
});
describe('setMeta key validation', function () {
it('accepts valid alphanumeric keys', function () {
$service = new Service();
$service->metadata = [];
$service->setMeta('valid_key', 'value');
expect($service->metadata['valid_key'])->toBe('value');
});
it('accepts keys with hyphens', function () {
$service = new Service();
$service->metadata = [];
$service->setMeta('my-key', 'value');
expect($service->metadata['my-key'])->toBe('value');
});
it('rejects empty keys', function () {
$service = new Service();
$service->metadata = [];
expect(fn () => $service->setMeta('', 'value'))
->toThrow(InvalidArgumentException::class);
});
it('rejects keys with special characters', function () {
$service = new Service();
$service->metadata = [];
expect(fn () => $service->setMeta('key.with.dots', 'value'))
->toThrow(InvalidArgumentException::class);
expect(fn () => $service->setMeta('key with spaces', 'value'))
->toThrow(InvalidArgumentException::class);
expect(fn () => $service->setMeta('key/path', 'value'))
->toThrow(InvalidArgumentException::class);
});
});
});
// =============================================================================
// HoneypotHit Headers Validation
// =============================================================================
describe('HoneypotHit headers validation', function () {
describe('setHeadersAttribute mutator', function () {
it('accepts valid header arrays', function () {
$hit = new HoneypotHit();
$hit->headers = ['host' => ['example.com'], 'accept' => ['text/html']];
$decoded = json_decode($hit->getAttributes()['headers'], true);
expect($decoded)->toHaveKey('host');
expect($decoded)->toHaveKey('accept');
});
it('accepts null headers', function () {
$hit = new HoneypotHit();
$hit->headers = null;
expect($hit->getAttributes()['headers'])->toBeNull();
});
it('truncates headers exceeding count limit', function () {
$hit = new HoneypotHit();
$headers = [];
for ($i = 0; $i < HoneypotHit::HEADERS_MAX_COUNT + 20; $i++) {
$headers["x-header-{$i}"] = ["value-{$i}"];
}
$hit->headers = $headers;
$decoded = json_decode($hit->getAttributes()['headers'], true);
expect(count($decoded))->toBeLessThanOrEqual(HoneypotHit::HEADERS_MAX_COUNT);
});
it('keeps headers at the exact limit', function () {
$hit = new HoneypotHit();
$headers = [];
for ($i = 0; $i < HoneypotHit::HEADERS_MAX_COUNT; $i++) {
$headers["h{$i}"] = ['v'];
}
$hit->headers = $headers;
$decoded = json_decode($hit->getAttributes()['headers'], true);
expect(count($decoded))->toBe(HoneypotHit::HEADERS_MAX_COUNT);
});
it('truncates headers exceeding size limit', function () {
$hit = new HoneypotHit();
// Create headers with large values that exceed 16KB
$headers = [];
for ($i = 0; $i < 10; $i++) {
$headers["x-large-{$i}"] = [str_repeat('x', 2000)];
}
$hit->headers = $headers;
$json = $hit->getAttributes()['headers'];
expect(strlen($json))->toBeLessThanOrEqual(HoneypotHit::HEADERS_MAX_SIZE);
});
it('handles invalid JSON string gracefully', function () {
$hit = new HoneypotHit();
$hit->headers = '{not valid json}';
expect($hit->getAttributes()['headers'])->toBeNull();
});
it('handles non-array non-string values gracefully', function () {
$hit = new HoneypotHit();
$hit->headers = 12345;
expect($hit->getAttributes()['headers'])->toBeNull();
});
it('accepts valid JSON strings', function () {
$hit = new HoneypotHit();
$hit->headers = '{"host":["example.com"]}';
$decoded = json_decode($hit->getAttributes()['headers'], true);
expect($decoded)->toHaveKey('host');
});
it('persists valid headers to database', function () {
$hit = HoneypotHit::create([
'ip_address' => '192.168.1.1',
'path' => '/teapot',
'method' => 'GET',
'headers' => ['host' => ['example.com'], 'accept' => ['*/*']],
'severity' => 'warning',
]);
$fresh = HoneypotHit::find($hit->id);
expect($fresh->headers)->toBe(['host' => ['example.com'], 'accept' => ['*/*']]);
});
});
});

View file

@ -1,280 +0,0 @@
<?php
/*
* Core PHP Framework
*
* Licensed under the European Union Public Licence (EUPL) v1.2.
* See LICENSE file for details.
*/
declare(strict_types=1);
use Illuminate\Support\Facades\RateLimiter;
use Livewire\Component;
use Livewire\Livewire;
use Website\Hub\Concerns\HasRateLimiting;
// =============================================================================
// Test Double Components
// =============================================================================
/**
* Component with actionMessage/actionType properties (PlatformUser pattern).
*/
class RateLimitedActionComponent extends Component
{
use HasRateLimiting;
public string $actionMessage = '';
public string $actionType = '';
public int $executionCount = 0;
public function mutate(): void
{
$this->rateLimit('test-mutation', 3, function () {
$this->executionCount++;
$this->actionMessage = 'Action executed.';
$this->actionType = 'success';
});
}
public function export()
{
return $this->rateLimit('test-export', 2, function () {
$this->executionCount++;
return 'export-data';
});
}
public function destroy(): void
{
$this->rateLimit('test-deletion', 1, function () {
$this->executionCount++;
$this->actionMessage = 'Deleted.';
$this->actionType = 'success';
});
}
public function render(): string
{
return <<<'HTML'
<div>
<span>Executions: {{ $executionCount }}</span>
<span>Message: {{ $actionMessage }}</span>
<span>Type: {{ $actionType }}</span>
</div>
HTML;
}
}
/**
* Component without actionMessage/actionType (session flash fallback).
*/
class RateLimitedSessionComponent extends Component
{
use HasRateLimiting;
public int $executionCount = 0;
public function mutate(): void
{
$this->rateLimit('test-session-mutation', 2, function () {
$this->executionCount++;
});
}
public function render(): string
{
return <<<'HTML'
<div>
<span>Executions: {{ $executionCount }}</span>
</div>
HTML;
}
}
// =============================================================================
// Rate Limiting Enforcement Tests
// =============================================================================
beforeEach(function () {
RateLimiter::clear('test-mutation:1');
RateLimiter::clear('test-export:1');
RateLimiter::clear('test-deletion:1');
RateLimiter::clear('test-session-mutation:1');
});
describe('Rate limiting enforcement', function () {
it('allows actions within the rate limit', function () {
$this->actingAs(new \Illuminate\Foundation\Auth\User(['id' => 1]));
Livewire::test(RateLimitedActionComponent::class)
->call('mutate')
->assertSet('executionCount', 1)
->assertSet('actionMessage', 'Action executed.')
->assertSet('actionType', 'success')
->call('mutate')
->assertSet('executionCount', 2)
->call('mutate')
->assertSet('executionCount', 3);
});
it('blocks actions exceeding the rate limit', function () {
$this->actingAs(new \Illuminate\Foundation\Auth\User(['id' => 1]));
$component = Livewire::test(RateLimitedActionComponent::class);
// Execute up to the limit
$component->call('mutate')
->call('mutate')
->call('mutate')
->assertSet('executionCount', 3);
// Fourth call should be blocked
$component->call('mutate')
->assertSet('executionCount', 3) // Not incremented
->assertSet('actionType', 'error')
->assertSet('actionMessage', fn (string $msg) => str_contains($msg, 'Too many requests'));
});
it('blocks export actions exceeding the rate limit', function () {
$this->actingAs(new \Illuminate\Foundation\Auth\User(['id' => 1]));
$component = Livewire::test(RateLimitedActionComponent::class);
// Execute up to the limit (2 for exports)
$component->call('export')
->assertSet('executionCount', 1)
->call('export')
->assertSet('executionCount', 2);
// Third call should be blocked
$component->call('export')
->assertSet('executionCount', 2) // Not incremented
->assertSet('actionType', 'error');
});
it('enforces strict limits on destructive actions', function () {
$this->actingAs(new \Illuminate\Foundation\Auth\User(['id' => 1]));
$component = Livewire::test(RateLimitedActionComponent::class);
// Execute up to the limit (1 for deletions)
$component->call('destroy')
->assertSet('executionCount', 1)
->assertSet('actionMessage', 'Deleted.');
// Second call should be blocked
$component->call('destroy')
->assertSet('executionCount', 1) // Not incremented
->assertSet('actionType', 'error')
->assertSet('actionMessage', fn (string $msg) => str_contains($msg, 'Too many requests'));
});
});
// =============================================================================
// Rate Limit Key Scoping Tests
// =============================================================================
describe('Rate limit key scoping', function () {
it('scopes rate limits per user', function () {
// User 1 exhausts their limit
$this->actingAs(new \Illuminate\Foundation\Auth\User(['id' => 1]));
RateLimiter::clear('test-deletion:1');
RateLimiter::clear('test-deletion:2');
$component1 = Livewire::test(RateLimitedActionComponent::class);
$component1->call('destroy')
->assertSet('executionCount', 1);
$component1->call('destroy')
->assertSet('executionCount', 1); // Blocked
// User 2 should not be affected
$this->actingAs(new \Illuminate\Foundation\Auth\User(['id' => 2]));
Livewire::test(RateLimitedActionComponent::class)
->call('destroy')
->assertSet('executionCount', 1) // User 2's own count
->assertSet('actionMessage', 'Deleted.');
});
it('uses separate limits for different action types', function () {
$this->actingAs(new \Illuminate\Foundation\Auth\User(['id' => 1]));
$component = Livewire::test(RateLimitedActionComponent::class);
// Exhaust deletion limit (1)
$component->call('destroy')
->assertSet('executionCount', 1);
// Mutation limit (3) should still be available
$component->call('mutate')
->assertSet('executionCount', 2)
->assertSet('actionMessage', 'Action executed.')
->assertSet('actionType', 'success');
});
});
// =============================================================================
// User Feedback Tests
// =============================================================================
describe('User feedback when rate limited', function () {
it('shows error message with retry time via actionMessage', function () {
$this->actingAs(new \Illuminate\Foundation\Auth\User(['id' => 1]));
$component = Livewire::test(RateLimitedActionComponent::class);
// Exhaust limit
$component->call('destroy');
// Next call should show error with seconds
$component->call('destroy')
->assertSet('actionType', 'error')
->assertSet('actionMessage', fn (string $msg) => str_contains($msg, 'Too many requests')
&& str_contains($msg, 'seconds'));
});
it('flashes error to session when component lacks actionMessage property', function () {
$this->actingAs(new \Illuminate\Foundation\Auth\User(['id' => 1]));
$component = Livewire::test(RateLimitedSessionComponent::class);
// Exhaust limit (2)
$component->call('mutate')->call('mutate');
// Third call should be blocked and flash to session
$component->call('mutate')
->assertSet('executionCount', 2); // Not incremented
});
});
// =============================================================================
// Rate Limit Reset Tests
// =============================================================================
describe('Rate limit reset', function () {
it('allows actions after rate limit window resets', function () {
$this->actingAs(new \Illuminate\Foundation\Auth\User(['id' => 1]));
$component = Livewire::test(RateLimitedActionComponent::class);
// Exhaust limit
$component->call('destroy')
->assertSet('executionCount', 1);
$component->call('destroy')
->assertSet('executionCount', 1); // Blocked
// Clear the rate limiter (simulates window expiry)
RateLimiter::clear('test-deletion:1');
// Should work again
$component->call('destroy')
->assertSet('executionCount', 2)
->assertSet('actionMessage', 'Deleted.')
->assertSet('actionType', 'success');
});
});