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:
parent
c694d966d7
commit
06e01797d1
27 changed files with 109 additions and 88 deletions
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
|
|
@ -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'),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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.');
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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, [
|
||||
|
|
|
|||
|
|
@ -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'));
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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'),
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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]),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)],
|
||||
];
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -10,6 +10,7 @@ import { type SharedData } from '@/types';
|
|||
|
||||
const props = defineProps<{
|
||||
invite: {
|
||||
id: string;
|
||||
id: string;
|
||||
email: string;
|
||||
role: {
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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 () {
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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 () {
|
||||
|
|
|
|||
Loading…
Reference in a new issue