mirror of
https://github.com/makeplane/plane.git
synced 2026-05-18 05:05:30 +02:00
fix(api): require token to view workspace invitation (GHSA-gf48-p6jp-cwc4)
The unauthenticated GET /api/workspaces/<slug>/invitations/<pk>/join/ endpoint serialized the invitation with `fields = "__all__"`, exposing the secret invitation `token` (and an `invite_link` containing it) to any caller who could guess a workspace slug + invitation id. Reject GET requests that don't supply the matching `?token=` from the emailed invite link, comparing in constant time, and additionally strip `token` and `invite_link` from the response as defense in depth. Frontend callers (`/workspace-invitations` page, auth header) now thread the token from the URL through `getWorkspaceInvitation`. Auth pages visited without an invite token simply skip the invitation banner SWR call — they have no proof of access and shouldn't see invitation data.
This commit is contained in:
@@ -12,6 +12,7 @@ from django.conf import settings
|
||||
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 @@ class WorkspaceJoinEndpoint(BaseAPIView):
|
||||
)
|
||||
|
||||
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):
|
||||
|
||||
@@ -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));
|
||||
};
|
||||
|
||||
@@ -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: (
|
||||
<div className="relative inline-flex items-center gap-2">
|
||||
|
||||
@@ -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) {
|
||||
<AuthHeader
|
||||
workspaceSlug={workspaceSlug?.toString() || undefined}
|
||||
invitationId={invitation_id?.toString() || undefined}
|
||||
invitationToken={invitation_token?.toString() || undefined}
|
||||
invitationEmail={email || undefined}
|
||||
authMode={authMode}
|
||||
currentAuthStep={authStep}
|
||||
@@ -137,10 +139,10 @@ export const AuthRoot = observer(function AuthRoot(props: TAuthRoot) {
|
||||
authStep={authStep}
|
||||
authMode={authMode}
|
||||
email={email}
|
||||
setEmail={(email) => 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}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -169,8 +169,15 @@ export class WorkspaceService extends APIService {
|
||||
});
|
||||
}
|
||||
|
||||
async getWorkspaceInvitation(workspaceSlug: string, invitationId: string): Promise<IWorkspaceMemberInvitation> {
|
||||
return this.get(`/api/workspaces/${workspaceSlug}/invitations/${invitationId}/join/`, { headers: {} })
|
||||
async getWorkspaceInvitation(
|
||||
workspaceSlug: string,
|
||||
invitationId: string,
|
||||
token: string
|
||||
): Promise<IWorkspaceMemberInvitation> {
|
||||
return this.get(`/api/workspaces/${workspaceSlug}/invitations/${invitationId}/join/`, {
|
||||
headers: {},
|
||||
params: { token },
|
||||
})
|
||||
.then((response) => response?.data)
|
||||
.catch((error) => {
|
||||
throw error?.response?.data;
|
||||
|
||||
Reference in New Issue
Block a user