security: critical fixes (test route, edit authz, nostr signature, calendar IDOR)

- Remove unauthenticated /test route that dispatched FetchNostrProfileJob
  for a hardcoded user (routes/web.php).
- Enforce created_by ownership check in meetup and lecturer Livewire edit
  components; mirror the existing services/edit pattern.
- Replace blind-trust nostrLoggedIn handler with NIP-42-style signed event
  verification: server-issued challenge stored in session, client signs a
  kind:22242 event, server verifies signature via swentel/nostr-php and
  derives npub. Challenge is single-use with 5-minute TTL.
- Validate the ?my[] parameter on the calendar download endpoint as an
  array of integers and intersect with the authenticated user's meetups.
This commit is contained in:
Claude
2026-05-03 12:51:10 +00:00
parent 1f9e5309d2
commit 90835f8b1f
6 changed files with 139 additions and 20 deletions
@@ -29,7 +29,17 @@ class DownloadMeetupCalendar extends Controller
$events = $meetup->meetupEvents()->where('start', '>=', now())->get();
$image = $meetup->getFirstMediaUrl('logo');
} elseif ($request->has('my')) {
$ids = $request->input('my');
$validated = $request->validate([
'my' => ['required', 'array'],
'my.*' => ['integer'],
]);
$ids = $validated['my'];
if (auth()->check()) {
$ownedIds = auth()->user()->meetups->pluck('id')->all();
$ids = array_values(array_intersect($ids, $ownedIds));
}
$events = MeetupEvent::query()
->with([
'meetup',
+15 -7
View File
@@ -1,5 +1,3 @@
import {npubEncode} from "nostr-tools/nip19";
export default () => ({
pollingInterval: null,
errorCheckInterval: null,
@@ -13,11 +11,21 @@ export default () => ({
},
async openNostrLogin() {
const pubkey = await window.nostr.getPublicKey();
const npub = npubEncode(pubkey);
console.log(pubkey);
console.log(npub);
this.$dispatch('nostrLoggedIn', {pubkey: npub});
const livewireComponent = this.$el.closest('[wire\\:id]')?.__livewire;
const challenge = livewireComponent?.$wire?.nostrChallenge;
if (!challenge) {
this.showAuthError('Login challenge missing. Please reload and try again.');
return;
}
const signedEvent = await window.nostr.signEvent({
kind: 22242,
created_at: Math.floor(Date.now() / 1000),
tags: [['challenge', challenge]],
content: '',
});
this.$dispatch('nostrLoggedIn', {signedEvent});
},
initErrorPolling() {
+91 -5
View File
@@ -13,10 +13,13 @@ use Illuminate\Support\Facades\Session;
use Illuminate\Support\Str;
use Illuminate\Validation\ValidationException;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Locked;
use Livewire\Attributes\On;
use Livewire\Attributes\Validate;
use Livewire\Component;
use SimpleSoftwareIO\QrCode\Facades\QrCode;
use swentel\nostr\Event\Event as NostrEvent;
use swentel\nostr\Key\Key as NostrKey;
new #[Layout('components.layouts.auth')]
class extends Component {
@@ -42,6 +45,11 @@ class extends Component {
public ?string $authError = null;
#[Locked]
public ?string $nostrChallenge = null;
private const NOSTR_CHALLENGE_TTL_SECONDS = 300;
/**
* Handle authError property type conversion.
* Ensure array values from frontend are converted to string or null.
@@ -57,6 +65,10 @@ class extends Component {
{
$this->currentLangCountry = session('lang_country') ?? 'de-DE';
$this->nostrChallenge = bin2hex(random_bytes(32));
Session::put('nostr_login_challenge', $this->nostrChallenge);
Session::put('nostr_login_challenge_expires_at', now()->addSeconds(self::NOSTR_CHALLENGE_TTL_SECONDS)->timestamp);
// Nur beim ersten Mount initialisieren
if ($this->k1 === null) {
$this->k1 = bin2hex(str()->random(32));
@@ -80,18 +92,19 @@ class extends Component {
}
#[On('nostrLoggedIn')]
public function loginListener($pubkey): void
public function loginListener($signedEvent = null): void
{
$user = \App\Models\User::query()->where('nostr', $pubkey)->first();
$npub = $this->verifyNostrLoginEvent($signedEvent);
$user = User::query()->where('nostr', $npub)->first();
if (!$user) {
$fakeName = str()->random(10);
// create User
$user = User::create([
'public_key' => null,
'is_lecturer' => true,
'name' => $fakeName,
'email' => str($pubkey)->substr(-12).'@portal.einundzwanzig.space',
'nostr' => $pubkey,
'email' => str($npub)->substr(-12).'@portal.einundzwanzig.space',
'nostr' => $npub,
'lnbits' => [
'read_key' => null,
'url' => null,
@@ -137,6 +150,79 @@ class extends Component {
);
}
/**
* Verify a NIP-42-style signed login event and return the user's npub.
*
* Throws ValidationException on any invalid input never trust client data.
*/
protected function verifyNostrLoginEvent(mixed $signedEvent): string
{
if (!is_array($signedEvent)) {
throw ValidationException::withMessages(['email' => __('auth.failed')]);
}
$required = ['id', 'pubkey', 'created_at', 'kind', 'tags', 'content', 'sig'];
foreach ($required as $key) {
if (!array_key_exists($key, $signedEvent)) {
throw ValidationException::withMessages(['email' => __('auth.failed')]);
}
}
if ((int) $signedEvent['kind'] !== 22242) {
throw ValidationException::withMessages(['email' => __('auth.failed')]);
}
$expectedChallenge = Session::get('nostr_login_challenge');
$expiresAt = (int) Session::get('nostr_login_challenge_expires_at', 0);
if (!is_string($expectedChallenge) || $expectedChallenge === '' || $expiresAt < now()->timestamp) {
Session::forget(['nostr_login_challenge', 'nostr_login_challenge_expires_at']);
throw ValidationException::withMessages(['email' => __('auth.failed')]);
}
$challengeFromEvent = null;
foreach ($signedEvent['tags'] as $tag) {
if (is_array($tag) && ($tag[0] ?? null) === 'challenge') {
$challengeFromEvent = (string) ($tag[1] ?? '');
break;
}
}
if ($challengeFromEvent === null || !hash_equals($expectedChallenge, $challengeFromEvent)) {
throw ValidationException::withMessages(['email' => __('auth.failed')]);
}
$createdAt = (int) $signedEvent['created_at'];
if (abs(now()->timestamp - $createdAt) > self::NOSTR_CHALLENGE_TTL_SECONDS) {
throw ValidationException::withMessages(['email' => __('auth.failed')]);
}
$eventJson = json_encode([
'id' => (string) $signedEvent['id'],
'pubkey' => (string) $signedEvent['pubkey'],
'created_at' => $createdAt,
'kind' => 22242,
'tags' => $signedEvent['tags'],
'content' => (string) $signedEvent['content'],
'sig' => (string) $signedEvent['sig'],
], JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
$isValid = false;
try {
$isValid = (new NostrEvent())->verify($eventJson);
} catch (\Throwable $e) {
$isValid = false;
}
if (!$isValid) {
throw ValidationException::withMessages(['email' => __('auth.failed')]);
}
Session::forget(['nostr_login_challenge', 'nostr_login_challenge_expires_at']);
return (new NostrKey())->convertPublicKeyToBech32((string) $signedEvent['pubkey']);
}
/**
* Ensure the authentication request is not rate limited.
*/
@@ -45,8 +45,17 @@ class extends Component {
#[Locked]
public ?string $updated_at = null;
protected function authorizeAccess(): void
{
if (!is_null($this->lecturer->created_by) && auth()->id() !== $this->lecturer->created_by) {
abort(403);
}
}
public function mount(): void
{
$this->authorizeAccess();
$this->lecturer->load('media');
$this->name = $this->lecturer->name ?? '';
@@ -70,6 +79,8 @@ class extends Component {
public function updateLecturer(): void
{
$this->authorizeAccess();
$validated = $this->validate([
'name' => ['required', 'string', 'max:255', Rule::unique('lecturers')->ignore($this->lecturer->id)],
'subtitle' => ['nullable', 'string'],
@@ -83,8 +83,17 @@ class extends Component {
\Flux\Flux::modal('add-city')->close();
}
protected function authorizeAccess(): void
{
if (!is_null($this->meetup->created_by) && auth()->id() !== $this->meetup->created_by) {
abort(403);
}
}
public function mount(): void
{
$this->authorizeAccess();
$this->meetup->load('media');
// Basic Information
@@ -117,6 +126,8 @@ class extends Component {
public function updateMeetup(): void
{
$this->authorizeAccess();
$validated = $this->validate([
'name' => ['required', 'string', 'max:255', Rule::unique('meetups')->ignore($this->meetup->id)],
'city_id' => ['nullable', 'exists:cities,id'],
-7
View File
@@ -2,20 +2,13 @@
use App\Http\Controllers\DownloadMeetupCalendar;
use App\Http\Controllers\ImageController;
use App\Jobs\FetchNostrProfileJob;
use App\Livewire\Helper\FollowTheRabbit;
use App\Models\User;
use Illuminate\Support\Facades\Route;
use Laravel\Nightwatch\Http\Middleware\Sample;
// Redirect root URL to 'welcome' page
Route::redirect('/', 'welcome');
// Test route that dispatches a job to fetch Nostr profile for user with ID 1426
Route::get('test', function () {
FetchNostrProfileJob::dispatchSync(User::find(1426));
});
// Error page route that aborts with given HTTP status code (digits only,
// constrained to valid 4xx/5xx range to avoid TypeErrors from bot scans).
Route::get('error/{code}', function (string $code) {