mirror of
https://github.com/HolgerHatGarKeineNode/einundzwanzig-app.git
synced 2026-05-05 04:54:53 +00:00
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:
@@ -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',
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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'],
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user