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:
sriram veeraghanta
2026-05-05 01:00:09 +05:30
parent 9491bdbe46
commit fbb96fc66a
5 changed files with 59 additions and 17 deletions

View File

@@ -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):

View File

@@ -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));
};

View File

@@ -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">

View File

@@ -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}
/>
)}

View File

@@ -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;