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: (
diff --git a/apps/web/core/components/account/auth-forms/auth-root.tsx b/apps/web/core/components/account/auth-forms/auth-root.tsx index 5cba8a178e9..665ba4bf7a1 100644 --- a/apps/web/core/components/account/auth-forms/auth-root.tsx +++ b/apps/web/core/components/account/auth-forms/auth-root.tsx @@ -37,6 +37,7 @@ export const AuthRoot = observer(function AuthRoot(props: TAuthRoot) { // query params const emailParam = searchParams.get("email"); const invitation_id = searchParams.get("invitation_id"); + const invitation_token = searchParams.get("token"); const workspaceSlug = searchParams.get("slug"); const error_code = searchParams.get("error_code"); // props @@ -121,6 +122,7 @@ export const AuthRoot = observer(function AuthRoot(props: TAuthRoot) { setEmail(email)} - setAuthMode={(authMode) => setAuthMode(authMode)} - setAuthStep={(authStep) => setAuthStep(authStep)} - setErrorInfo={(errorInfo) => setErrorInfo(errorInfo)} + setEmail={(value) => setEmail(value)} + setAuthMode={(value) => setAuthMode(value)} + setAuthStep={(value) => setAuthStep(value)} + setErrorInfo={(value) => setErrorInfo(value)} currentAuthMode={currentAuthMode} /> )} diff --git a/apps/web/core/services/workspace.service.ts b/apps/web/core/services/workspace.service.ts index 3c2565322a0..5db3b1e72c0 100644 --- a/apps/web/core/services/workspace.service.ts +++ b/apps/web/core/services/workspace.service.ts @@ -169,8 +169,15 @@ export class WorkspaceService extends APIService { }); } - async getWorkspaceInvitation(workspaceSlug: string, invitationId: string): Promise { - return this.get(`/api/workspaces/${workspaceSlug}/invitations/${invitationId}/join/`, { headers: {} }) + async getWorkspaceInvitation( + workspaceSlug: string, + invitationId: string, + token: string + ): Promise { + return this.get(`/api/workspaces/${workspaceSlug}/invitations/${invitationId}/join/`, { + headers: {}, + params: { token }, + }) .then((response) => response?.data) .catch((error) => { throw error?.response?.data;