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();
|
$events = $meetup->meetupEvents()->where('start', '>=', now())->get();
|
||||||
$image = $meetup->getFirstMediaUrl('logo');
|
$image = $meetup->getFirstMediaUrl('logo');
|
||||||
} elseif ($request->has('my')) {
|
} 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()
|
$events = MeetupEvent::query()
|
||||||
->with([
|
->with([
|
||||||
'meetup',
|
'meetup',
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
import {npubEncode} from "nostr-tools/nip19";
|
|
||||||
|
|
||||||
export default () => ({
|
export default () => ({
|
||||||
pollingInterval: null,
|
pollingInterval: null,
|
||||||
errorCheckInterval: null,
|
errorCheckInterval: null,
|
||||||
@@ -13,11 +11,21 @@ export default () => ({
|
|||||||
},
|
},
|
||||||
|
|
||||||
async openNostrLogin() {
|
async openNostrLogin() {
|
||||||
const pubkey = await window.nostr.getPublicKey();
|
const livewireComponent = this.$el.closest('[wire\\:id]')?.__livewire;
|
||||||
const npub = npubEncode(pubkey);
|
const challenge = livewireComponent?.$wire?.nostrChallenge;
|
||||||
console.log(pubkey);
|
if (!challenge) {
|
||||||
console.log(npub);
|
this.showAuthError('Login challenge missing. Please reload and try again.');
|
||||||
this.$dispatch('nostrLoggedIn', {pubkey: npub});
|
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() {
|
initErrorPolling() {
|
||||||
|
|||||||
@@ -13,10 +13,13 @@ use Illuminate\Support\Facades\Session;
|
|||||||
use Illuminate\Support\Str;
|
use Illuminate\Support\Str;
|
||||||
use Illuminate\Validation\ValidationException;
|
use Illuminate\Validation\ValidationException;
|
||||||
use Livewire\Attributes\Layout;
|
use Livewire\Attributes\Layout;
|
||||||
|
use Livewire\Attributes\Locked;
|
||||||
use Livewire\Attributes\On;
|
use Livewire\Attributes\On;
|
||||||
use Livewire\Attributes\Validate;
|
use Livewire\Attributes\Validate;
|
||||||
use Livewire\Component;
|
use Livewire\Component;
|
||||||
use SimpleSoftwareIO\QrCode\Facades\QrCode;
|
use SimpleSoftwareIO\QrCode\Facades\QrCode;
|
||||||
|
use swentel\nostr\Event\Event as NostrEvent;
|
||||||
|
use swentel\nostr\Key\Key as NostrKey;
|
||||||
|
|
||||||
new #[Layout('components.layouts.auth')]
|
new #[Layout('components.layouts.auth')]
|
||||||
class extends Component {
|
class extends Component {
|
||||||
@@ -42,6 +45,11 @@ class extends Component {
|
|||||||
|
|
||||||
public ?string $authError = null;
|
public ?string $authError = null;
|
||||||
|
|
||||||
|
#[Locked]
|
||||||
|
public ?string $nostrChallenge = null;
|
||||||
|
|
||||||
|
private const NOSTR_CHALLENGE_TTL_SECONDS = 300;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handle authError property type conversion.
|
* Handle authError property type conversion.
|
||||||
* Ensure array values from frontend are converted to string or null.
|
* 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->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
|
// Nur beim ersten Mount initialisieren
|
||||||
if ($this->k1 === null) {
|
if ($this->k1 === null) {
|
||||||
$this->k1 = bin2hex(str()->random(32));
|
$this->k1 = bin2hex(str()->random(32));
|
||||||
@@ -80,18 +92,19 @@ class extends Component {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[On('nostrLoggedIn')]
|
#[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) {
|
if (!$user) {
|
||||||
$fakeName = str()->random(10);
|
$fakeName = str()->random(10);
|
||||||
// create User
|
|
||||||
$user = User::create([
|
$user = User::create([
|
||||||
'public_key' => null,
|
'public_key' => null,
|
||||||
'is_lecturer' => true,
|
'is_lecturer' => true,
|
||||||
'name' => $fakeName,
|
'name' => $fakeName,
|
||||||
'email' => str($pubkey)->substr(-12).'@portal.einundzwanzig.space',
|
'email' => str($npub)->substr(-12).'@portal.einundzwanzig.space',
|
||||||
'nostr' => $pubkey,
|
'nostr' => $npub,
|
||||||
'lnbits' => [
|
'lnbits' => [
|
||||||
'read_key' => null,
|
'read_key' => null,
|
||||||
'url' => 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.
|
* Ensure the authentication request is not rate limited.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -45,8 +45,17 @@ class extends Component {
|
|||||||
#[Locked]
|
#[Locked]
|
||||||
public ?string $updated_at = null;
|
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
|
public function mount(): void
|
||||||
{
|
{
|
||||||
|
$this->authorizeAccess();
|
||||||
|
|
||||||
$this->lecturer->load('media');
|
$this->lecturer->load('media');
|
||||||
|
|
||||||
$this->name = $this->lecturer->name ?? '';
|
$this->name = $this->lecturer->name ?? '';
|
||||||
@@ -70,6 +79,8 @@ class extends Component {
|
|||||||
|
|
||||||
public function updateLecturer(): void
|
public function updateLecturer(): void
|
||||||
{
|
{
|
||||||
|
$this->authorizeAccess();
|
||||||
|
|
||||||
$validated = $this->validate([
|
$validated = $this->validate([
|
||||||
'name' => ['required', 'string', 'max:255', Rule::unique('lecturers')->ignore($this->lecturer->id)],
|
'name' => ['required', 'string', 'max:255', Rule::unique('lecturers')->ignore($this->lecturer->id)],
|
||||||
'subtitle' => ['nullable', 'string'],
|
'subtitle' => ['nullable', 'string'],
|
||||||
|
|||||||
@@ -83,8 +83,17 @@ class extends Component {
|
|||||||
\Flux\Flux::modal('add-city')->close();
|
\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
|
public function mount(): void
|
||||||
{
|
{
|
||||||
|
$this->authorizeAccess();
|
||||||
|
|
||||||
$this->meetup->load('media');
|
$this->meetup->load('media');
|
||||||
|
|
||||||
// Basic Information
|
// Basic Information
|
||||||
@@ -117,6 +126,8 @@ class extends Component {
|
|||||||
|
|
||||||
public function updateMeetup(): void
|
public function updateMeetup(): void
|
||||||
{
|
{
|
||||||
|
$this->authorizeAccess();
|
||||||
|
|
||||||
$validated = $this->validate([
|
$validated = $this->validate([
|
||||||
'name' => ['required', 'string', 'max:255', Rule::unique('meetups')->ignore($this->meetup->id)],
|
'name' => ['required', 'string', 'max:255', Rule::unique('meetups')->ignore($this->meetup->id)],
|
||||||
'city_id' => ['nullable', 'exists:cities,id'],
|
'city_id' => ['nullable', 'exists:cities,id'],
|
||||||
|
|||||||
@@ -2,20 +2,13 @@
|
|||||||
|
|
||||||
use App\Http\Controllers\DownloadMeetupCalendar;
|
use App\Http\Controllers\DownloadMeetupCalendar;
|
||||||
use App\Http\Controllers\ImageController;
|
use App\Http\Controllers\ImageController;
|
||||||
use App\Jobs\FetchNostrProfileJob;
|
|
||||||
use App\Livewire\Helper\FollowTheRabbit;
|
use App\Livewire\Helper\FollowTheRabbit;
|
||||||
use App\Models\User;
|
|
||||||
use Illuminate\Support\Facades\Route;
|
use Illuminate\Support\Facades\Route;
|
||||||
use Laravel\Nightwatch\Http\Middleware\Sample;
|
use Laravel\Nightwatch\Http\Middleware\Sample;
|
||||||
|
|
||||||
// Redirect root URL to 'welcome' page
|
// Redirect root URL to 'welcome' page
|
||||||
Route::redirect('/', 'welcome');
|
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,
|
// Error page route that aborts with given HTTP status code (digits only,
|
||||||
// constrained to valid 4xx/5xx range to avoid TypeErrors from bot scans).
|
// constrained to valid 4xx/5xx range to avoid TypeErrors from bot scans).
|
||||||
Route::get('error/{code}', function (string $code) {
|
Route::get('error/{code}', function (string $code) {
|
||||||
|
|||||||
Reference in New Issue
Block a user