fix: security audit - IDOR, open redirect, authorization, session fixes

Critical:
- Fix EnsureUserSetupIsComplete middleware route name prefixes and
  redirect Subscription step to subscribe page (not onboarding)
- Fix MCP session pollution: Auth::setUser() instead of Auth::login()
- Remove dead BillingController::addWorkspace/removeWorkspace methods
- Remove broken Workspace::pendingInvites() method

Security (IDOR):
- MediaController: add workspace ownership verification on all endpoints
- UpdatePostRequest: scope label_ids validation to current workspace
- UpdatePostRequest: scope platform IDs validation to current post

Security (other):
- Fix open redirect in login and registration (validate internal URLs)
- Add validation to API PostController store/update (was $request->all())
- Prevent Owner role assignment via updateRole endpoint
- Fix API post author attribution to use workspace owner

Authorization:
- PostController: use createPost policy instead of view for store/update/destroy

Logic:
- Post Status enum labels now use translation system instead of hardcoded Portuguese
- Workspace deletion cleans up current_workspace_id for all affected members
- StoreWorkspaceInviteRequest: replace Portuguese validation messages with __()

Rename onboarding:
- Step1.vue -> Role.vue, Step2.vue -> Connect.vue
- Controller methods: step1->role, storeStep1->storeRole, step2->connect, storeStep2->storeConnect

All 728 tests passing.
This commit is contained in:
Paulo Castellano 2026-03-30 14:58:25 -03:00
parent c694d966d7
commit 06e01797d1
27 changed files with 109 additions and 88 deletions

View file

@ -11,9 +11,7 @@ class DeleteWorkspace
{
public static function execute(User $user, Workspace $workspace): void
{
if ($user->current_workspace_id === $workspace->id) {
$user->update(['current_workspace_id' => null]);
}
User::where('current_workspace_id', $workspace->id)->update(['current_workspace_id' => null]);
$workspace->delete();

View file

@ -16,12 +16,12 @@ enum Status: string
public function label(): string
{
return match ($this) {
self::Draft => 'Rascunho',
self::Scheduled => 'Agendado',
self::Publishing => 'Publicando',
self::Published => 'Publicado',
self::PartiallyPublished => 'Parcialmente Publicado',
self::Failed => 'Falhou',
self::Draft => __('posts.status.draft'),
self::Scheduled => __('posts.status.scheduled'),
self::Publishing => __('posts.status.publishing'),
self::Published => __('posts.status.published'),
self::PartiallyPublished => __('posts.status.partially_published'),
self::Failed => __('posts.status.failed'),
};
}

View file

@ -39,10 +39,19 @@ public function show(Request $request, Post $post): PostResource
public function store(Request $request): JsonResponse
{
$validated = $request->validate([
'platforms' => ['required', 'array', 'min:1'],
'platforms.*.social_account_id' => ['required', 'uuid'],
'platforms.*.content_type' => ['required', 'string'],
'platforms.*.content' => ['nullable', 'string'],
'scheduled_at' => ['nullable', 'date', 'after:now'],
'status' => ['nullable', 'string', 'in:draft,scheduled,publishing'],
]);
$post = CreatePost::execute(
$request->workspace,
$request->workspace->members()->first(),
$request->all()
$request->workspace->owner,
$validated
);
$post->load(['postPlatforms.socialAccount']);
@ -58,7 +67,17 @@ public function update(Request $request, Post $post): PostResource|JsonResponse
abort(Response::HTTP_NOT_FOUND);
}
$result = UpdatePost::execute($request->workspace, $post, $request->all());
$validated = $request->validate([
'platforms' => ['sometimes', 'array'],
'platforms.*.id' => ['required', 'uuid'],
'platforms.*.content' => ['nullable', 'string'],
'scheduled_at' => ['nullable', 'date'],
'status' => ['nullable', 'string', 'in:draft,scheduled,publishing'],
'label_ids' => ['sometimes', 'array'],
'label_ids.*' => ['uuid'],
]);
$result = UpdatePost::execute($request->workspace, $post, $validated);
if (data_get($result, 'action') === 'already_published') {
return response()->json(

View file

@ -98,31 +98,4 @@ public function portal(Request $request): RedirectResponse
route('app.billing.index')
);
}
public function addWorkspace(Request $request): RedirectResponse
{
$user = $request->user();
if (! $user->subscribed('default')) {
return redirect()->route('app.billing.index')
->withErrors(['subscription' => 'You need an active subscription.']);
}
$user->incrementWorkspaceQuantity();
return back()->with('success', 'Workspace added to subscription.');
}
public function removeWorkspace(Request $request): RedirectResponse
{
$user = $request->user();
if (! $user->subscribed('default')) {
return back();
}
$user->decrementWorkspaceQuantity();
return back()->with('success', 'Workspace removed from subscription.');
}
}

View file

@ -7,6 +7,8 @@
use App\Http\Requests\App\Media\StoreChunkedMediaRequest;
use App\Http\Requests\App\Media\StoreMediaRequest;
use App\Models\Media;
use App\Models\Post;
use App\Models\PostPlatform;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\Relation;
use Illuminate\Http\JsonResponse;
@ -17,6 +19,7 @@ class MediaController extends Controller
public function store(StoreMediaRequest $request): JsonResponse
{
$model = $this->resolveModel($request->input('model'), $request->input('model_id'));
$this->authorizeModelOwnership($model, $request);
$collection = $request->input('collection', 'default');
$media = $model->addMedia(
@ -47,6 +50,7 @@ public function storeChunked(StoreChunkedMediaRequest $request): JsonResponse
}
$model = $this->resolveModel($request->input('model'), $request->input('model_id'));
$this->authorizeModelOwnership($model, $request);
$media = $model->addMediaFromPath(
$tempFile,
@ -66,12 +70,15 @@ public function storeChunked(StoreChunkedMediaRequest $request): JsonResponse
]);
}
public function destroy(string $modelId, Media $media): JsonResponse
public function destroy(string $modelId, Media $media, Request $request): JsonResponse
{
if ($media->mediable_id !== $modelId) {
abort(403);
}
$model = $media->mediable;
$this->authorizeModelOwnership($model, $request);
$media->delete();
return response()->json(['success' => true]);
@ -94,11 +101,15 @@ public function reorder(Request $request): JsonResponse
public function duplicate(Media $media, Request $request): JsonResponse
{
$sourceModel = $media->mediable;
$this->authorizeModelOwnership($sourceModel, $request);
$targets = $request->input('targets', []);
$duplicates = [];
foreach ($targets as $target) {
$model = $this->resolveModel($target['model'], $target['model_id']);
$this->authorizeModelOwnership($model, $request);
$collection = $target['collection'] ?? $media->collection;
$duplicate = $model->media()->create([
@ -127,6 +138,21 @@ public function duplicate(Media $media, Request $request): JsonResponse
return response()->json($duplicates);
}
private function authorizeModelOwnership(Model $model, Request $request): void
{
$workspace = $request->user()->currentWorkspace;
if ($model instanceof PostPlatform) {
if ($model->post->workspace_id !== $workspace->id) {
abort(403);
}
} elseif ($model instanceof Post) {
if ($model->workspace_id !== $workspace->id) {
abort(403);
}
}
}
private function resolveModel(string $alias, string $id): Model
{
$modelClass = Relation::getMorphedModel($alias) ?? $alias;

View file

@ -17,19 +17,19 @@
class OnboardingController extends Controller
{
public function step1(Request $request): Response|RedirectResponse
public function role(Request $request): Response|RedirectResponse
{
$redirect = $this->enforceStep($request->user(), Setup::Role);
if ($redirect) {
return $redirect;
}
return Inertia::render('onboarding/Step1', [
return Inertia::render('onboarding/Role', [
'personas' => Persona::toSelectArray(),
]);
}
public function storeStep1(Request $request): RedirectResponse
public function storeRole(Request $request): RedirectResponse
{
$validated = $request->validate([
'persona' => ['required', Rule::enum(Persona::class)],
@ -43,7 +43,7 @@ public function storeStep1(Request $request): RedirectResponse
return redirect()->route('app.onboarding.connect');
}
public function step2(Request $request): Response|RedirectResponse
public function connect(Request $request): Response|RedirectResponse
{
$redirect = $this->enforceStep($request->user(), Setup::Connections);
if ($redirect) {
@ -67,13 +67,13 @@ public function step2(Request $request): Response|RedirectResponse
])->values();
}
return Inertia::render('onboarding/Step2', [
return Inertia::render('onboarding/Connect', [
'platforms' => $platforms,
'hasWorkspace' => $workspace !== null,
]);
}
public function storeStep2(Request $request): SymfonyResponse|RedirectResponse
public function storeConnect(Request $request): SymfonyResponse|RedirectResponse
{
$user = $request->user();

View file

@ -112,7 +112,7 @@ public function store(Request $request): RedirectResponse|\Symfony\Component\Htt
return redirect()->route('app.workspaces.create');
}
$this->authorize('view', $workspace);
$this->authorize('createPost', $workspace);
$socialAccounts = $workspace->socialAccounts;
@ -187,7 +187,7 @@ public function update(UpdatePostRequest $request, Post $post): RedirectResponse
return redirect()->route('app.workspaces.create');
}
$this->authorize('view', $workspace);
$this->authorize('createPost', $workspace);
if ($post->workspace_id !== $workspace->id) {
abort(404);
@ -229,7 +229,7 @@ public function destroy(Request $request, Post $post): RedirectResponse
return redirect()->route('app.workspaces.create');
}
$this->authorize('view', $workspace);
$this->authorize('createPost', $workspace);
if ($post->workspace_id !== $workspace->id) {
abort(404);

View file

@ -150,7 +150,7 @@ public function updateRole(Request $request, string $userId): RedirectResponse
}
$validated = $request->validate([
'role' => ['required', Rule::enum(WorkspaceRole::class)],
'role' => ['required', Rule::in([WorkspaceRole::Admin->value, WorkspaceRole::Member->value])],
]);
$workspace->members()->updateExistingPivot($userId, [

View file

@ -37,7 +37,10 @@ public function store(LoginRequest $request): RedirectResponse
// Check for redirect param
if ($redirect = $request->input('redirect')) {
return redirect($redirect);
// Only allow internal redirects (paths starting with /)
if (str_starts_with($redirect, '/') && ! str_starts_with($redirect, '//')) {
return redirect($redirect);
}
}
return redirect()->intended(route('app.calendar'));

View file

@ -77,7 +77,10 @@ public function store(Request $request): RedirectResponse
// Check for redirect param
if ($redirect = $request->input('redirect')) {
return redirect($redirect);
// Only allow internal redirects (paths starting with /)
if (str_starts_with($redirect, '/') && ! str_starts_with($redirect, '//')) {
return redirect($redirect);
}
}
return redirect()->route('app.onboarding.role');

View file

@ -31,10 +31,10 @@ public function handle(Request $request, Closure $next): Response
// Map setup status to allowed routes
$allowedRoutes = match ($user->setup) {
Setup::Role => ['onboarding.role', 'onboarding.role.store'],
Setup::Connections => ['onboarding.connect', 'onboarding.connect.store', 'social.*'],
Setup::Subscription => ['onboarding.complete', 'onboarding.connect'],
default => ['onboarding.role', 'onboarding.role.store'],
Setup::Role => ['app.onboarding.role', 'app.onboarding.role.store'],
Setup::Connections => ['app.onboarding.connect', 'app.onboarding.connect.store', 'app.social.*'],
Setup::Subscription => ['app.subscribe', 'app.billing.*', 'app.onboarding.complete'],
default => ['app.onboarding.role', 'app.onboarding.role.store'],
};
$currentRoute = $request->route()?->getName();
@ -50,7 +50,7 @@ public function handle(Request $request, Closure $next): Response
return match ($user->setup) {
Setup::Role => redirect()->route('app.onboarding.role'),
Setup::Connections => redirect()->route('app.onboarding.connect'),
Setup::Subscription => redirect()->route('app.onboarding.connect'),
Setup::Subscription => redirect()->route('app.subscribe'),
default => redirect()->route('app.onboarding.role'),
};
}

View file

@ -51,7 +51,7 @@ public function handle(Request $request, Closure $next): Response
}
$user->current_workspace_id = $workspace->id;
Auth::login($user);
Auth::setUser($user);
return $next($request);
}

View file

@ -28,7 +28,7 @@ public function rules(): array
{
return [
'email' => ['required', 'email', 'max:255'],
'role' => ['nullable', Rule::enum(WorkspaceRole::class)],
'role' => ['nullable', Rule::in([WorkspaceRole::Admin->value, WorkspaceRole::Member->value])],
];
}
@ -40,9 +40,9 @@ public function rules(): array
public function messages(): array
{
return [
'email.required' => 'O email é obrigatório.',
'email.email' => 'Digite um email válido.',
'email.max' => 'O email deve ter no máximo 255 caracteres.',
'email.required' => __('validation.required', ['attribute' => 'email']),
'email.email' => __('validation.email', ['attribute' => 'email']),
'email.max' => __('validation.max.string', ['attribute' => 'email', 'max' => 255]),
];
}
}

View file

@ -5,6 +5,7 @@
namespace App\Http\Requests\App\Post;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class UpdatePostRequest extends FormRequest
{
@ -19,10 +20,10 @@ public function rules(): array
'status' => ['sometimes', 'string'],
'scheduled_at' => ['sometimes', 'nullable', 'string'],
'platforms' => ['sometimes', 'array'],
'platforms.*.id' => ['required', 'uuid', 'exists:post_platforms,id'],
'platforms.*.id' => ['required', 'uuid', Rule::exists('post_platforms', 'id')->where('post_id', $this->route('post')->id ?? $this->route('post'))],
'platforms.*.content' => ['nullable', 'string', 'max:5000'],
'label_ids' => ['sometimes', 'array'],
'label_ids.*' => ['uuid', 'exists:workspace_labels,id'],
'label_ids.*' => ['uuid', Rule::exists('workspace_labels', 'id')->where('workspace_id', $this->user()->currentWorkspace->id)],
];
}

View file

@ -35,7 +35,7 @@ public function content(): Content
'title' => "You've been invited to join {$this->invite->workspace->name}",
'previewText' => "You've been invited to join {$this->invite->workspace->name}",
'invite' => $this->invite,
'url' => route('app.invites.show', $this->invite->id),
'url' => route('app.invites.show', $this->invite),
],
);
}

View file

@ -63,11 +63,6 @@ public function invites(): HasMany
return $this->hasMany(WorkspaceInvite::class);
}
public function pendingInvites(): HasMany
{
return $this->invites()->pending();
}
public function hashtags(): HasMany
{
return $this->hasMany(WorkspaceHashtag::class);

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -10,6 +10,7 @@ import { type SharedData } from '@/types';
const props = defineProps<{
invite: {
id: string;
id: string;
email: string;
role: {

View file

@ -48,10 +48,10 @@ function () {
// Onboarding routes
Route::middleware(['auth', 'verified'])->prefix('onboarding')->group(function () {
Route::get('/', fn () => redirect()->route('app.onboarding.role'));
Route::get('role', [OnboardingController::class, 'step1'])->name('app.onboarding.role');
Route::post('role', [OnboardingController::class, 'storeStep1'])->name('app.onboarding.role.store');
Route::get('connect', [OnboardingController::class, 'step2'])->name('app.onboarding.connect');
Route::post('connect', [OnboardingController::class, 'storeStep2'])->name('app.onboarding.connect.store');
Route::get('role', [OnboardingController::class, 'role'])->name('app.onboarding.role');
Route::post('role', [OnboardingController::class, 'storeRole'])->name('app.onboarding.role.store');
Route::get('connect', [OnboardingController::class, 'connect'])->name('app.onboarding.connect');
Route::post('connect', [OnboardingController::class, 'storeConnect'])->name('app.onboarding.connect.store');
Route::get('complete', [OnboardingController::class, 'complete'])->name('app.onboarding.complete');
});

View file

@ -70,7 +70,12 @@
it('creates a post', function () {
$this->withHeaders(['Authorization' => 'Bearer '.$this->plainToken])
->postJson(route('api.posts.store'), [
'date' => now()->addDay()->format('Y-m-d'),
'platforms' => [
[
'social_account_id' => $this->socialAccount->id,
'content_type' => 'linkedin_post',
],
],
])
->assertCreated()
->assertJsonPath('status', PostStatus::Draft->value);

View file

@ -45,7 +45,7 @@
->assertRedirect(route('app.onboarding.connect'));
});
test('user on subscription step is redirected to onboarding step 2', function () {
test('user on subscription step is redirected to subscribe', function () {
config(['trypost.self_hosted' => true]);
$user = User::factory()->create(['setup' => Setup::Subscription]);
@ -55,7 +55,7 @@
$this->actingAs($user)
->get(route('app.calendar'))
->assertRedirect(route('app.onboarding.connect'));
->assertRedirect(route('app.subscribe'));
});
test('user on role step can access onboarding step 1', function () {

View file

@ -25,7 +25,7 @@
$response->assertOk();
$response->assertInertia(fn ($page) => $page
->component('onboarding/Step1', false)
->component('onboarding/Role', false)
->has('personas')
);
});
@ -81,7 +81,7 @@
$response->assertOk();
$response->assertInertia(fn ($page) => $page
->component('onboarding/Step2', false)
->component('onboarding/Connect', false)
->has('platforms')
->has('hasWorkspace')
);

View file

@ -14,12 +14,12 @@
});
test('post status has labels', function () {
expect(Status::Draft->label())->toBe('Rascunho');
expect(Status::Scheduled->label())->toBe('Agendado');
expect(Status::Publishing->label())->toBe('Publicando');
expect(Status::Published->label())->toBe('Publicado');
expect(Status::PartiallyPublished->label())->toBe('Parcialmente Publicado');
expect(Status::Failed->label())->toBe('Falhou');
expect(Status::Draft->label())->toBe('Draft');
expect(Status::Scheduled->label())->toBe('Scheduled');
expect(Status::Publishing->label())->toBe('Publishing');
expect(Status::Published->label())->toBe('Published');
expect(Status::PartiallyPublished->label())->toBe('Partially Published');
expect(Status::Failed->label())->toBe('Failed');
});
test('post status has colors', function () {