diff --git a/apps/api/plane/app/views/workspace/invite.py b/apps/api/plane/app/views/workspace/invite.py index cf2ab795a73..622b82adc14 100644 --- a/apps/api/plane/app/views/workspace/invite.py +++ b/apps/api/plane/app/views/workspace/invite.py @@ -12,6 +12,7 @@ from django.core.exceptions import ValidationError from django.core.validators import validate_email from django.utils import timezone +from django.utils.crypto import constant_time_compare # Third party modules from rest_framework import status @@ -236,9 +237,34 @@ def post(self, request, slug, pk): ) def get(self, request, slug, pk): - workspace_invitation = WorkspaceMemberInvite.objects.get(workspace__slug=slug, pk=pk) + # Require the invitation token from the email link; without it, + # the endpoint would leak invitation details (including the token + # itself via the serializer) to any unauthenticated caller. + token = request.GET.get("token", "") + forbidden_response = Response( + {"error": "You do not have permission to access this invitation"}, + status=status.HTTP_403_FORBIDDEN, + ) + if not token: + return forbidden_response + + try: + workspace_invitation = WorkspaceMemberInvite.objects.get( + workspace__slug=slug, pk=pk + ) + except WorkspaceMemberInvite.DoesNotExist: + return forbidden_response + + if not constant_time_compare(workspace_invitation.token, token): + return forbidden_response + serializer = WorkSpaceMemberInviteSerializer(workspace_invitation) - return Response(serializer.data, status=status.HTTP_200_OK) + data = { + key: value + for key, value in serializer.data.items() + if key not in ("token", "invite_link") + } + return Response(data, status=status.HTTP_200_OK) class UserWorkspaceInvitationsViewSet(BaseViewSet): diff --git a/apps/web/app/(all)/workspace-invitations/page.tsx b/apps/web/app/(all)/workspace-invitations/page.tsx index 4ab9b02eb8e..364f0a80669 100644 --- a/apps/web/app/(all)/workspace-invitations/page.tsx +++ b/apps/web/app/(all)/workspace-invitations/page.tsx @@ -39,9 +39,9 @@ function WorkspaceInvitationPage() { const { data: currentUser } = useUser(); const { data: invitationDetail, error } = useSWR( - invitation_id && slug && WORKSPACE_INVITATION(invitation_id.toString()), - invitation_id && slug - ? () => workspaceService.getWorkspaceInvitation(slug.toString(), invitation_id.toString()) + invitation_id && slug && token && WORKSPACE_INVITATION(invitation_id.toString()), + invitation_id && slug && token + ? () => workspaceService.getWorkspaceInvitation(slug.toString(), invitation_id.toString(), token.toString()) : null ); @@ -58,6 +58,7 @@ function WorkspaceInvitationPage() { } else { router.push("/"); } + return; }) .catch((err: unknown) => console.error(err)); }; @@ -71,6 +72,7 @@ function WorkspaceInvitationPage() { }) .then(() => { router.push("/"); + return; }) .catch((err: unknown) => console.error(err)); }; diff --git a/apps/web/core/components/account/auth-forms/auth-header.tsx b/apps/web/core/components/account/auth-forms/auth-header.tsx index 239d6582918..b08593983ed 100644 --- a/apps/web/core/components/account/auth-forms/auth-header.tsx +++ b/apps/web/core/components/account/auth-forms/auth-header.tsx @@ -19,6 +19,7 @@ import { WorkspaceService } from "@/services/workspace.service"; type TAuthHeader = { workspaceSlug: string | undefined; invitationId: string | undefined; + invitationToken: string | undefined; invitationEmail: string | undefined; authMode: EAuthModes; currentAuthStep: EAuthSteps; @@ -58,13 +59,17 @@ const Titles = { const workSpaceService = new WorkspaceService(); export const AuthHeader = observer(function AuthHeader(props: TAuthHeader) { - const { workspaceSlug, invitationId, invitationEmail, authMode, currentAuthStep } = props; + const { workspaceSlug, invitationId, invitationToken, invitationEmail, authMode, currentAuthStep } = props; // plane imports const { t } = useTranslation(); const { data: invitation, isLoading } = useSWR( - workspaceSlug && invitationId ? `WORKSPACE_INVITATION_${workspaceSlug}_${invitationId}` : null, - async () => workspaceSlug && invitationId && workSpaceService.getWorkspaceInvitation(workspaceSlug, invitationId), + workspaceSlug && invitationId && invitationToken ? `WORKSPACE_INVITATION_${workspaceSlug}_${invitationId}` : null, + async () => + workspaceSlug && + invitationId && + invitationToken && + workSpaceService.getWorkspaceInvitation(workspaceSlug, invitationId, invitationToken), { revalidateOnFocus: false, shouldRetryOnError: false, @@ -74,11 +79,11 @@ export const AuthHeader = observer(function AuthHeader(props: TAuthHeader) { const getHeaderSubHeader = ( step: EAuthSteps, mode: EAuthModes, - invitation: IWorkspaceMemberInvitation | undefined, + inviteData: IWorkspaceMemberInvitation | undefined, email: string | undefined ) => { - if (invitation && email && invitation.email === email && invitation.workspace) { - const workspace = invitation.workspace; + if (inviteData && email && inviteData.email === email && inviteData.workspace) { + const workspace = inviteData.workspace; return { header: (