diff --git a/authentik/stages/invitation/api.py b/authentik/stages/invitation/api.py index 56b60c02b..0c77cec4a 100644 --- a/authentik/stages/invitation/api.py +++ b/authentik/stages/invitation/api.py @@ -8,6 +8,7 @@ from rest_framework.viewsets import ModelViewSet from authentik.core.api.groups import GroupMemberSerializer from authentik.core.api.used_by import UsedByMixin from authentik.core.api.utils import is_dict +from authentik.flows.api.flows import FlowSerializer from authentik.flows.api.stages import StageSerializer from authentik.stages.invitation.models import Invitation, InvitationStage @@ -49,6 +50,7 @@ class InvitationSerializer(ModelSerializer): created_by = GroupMemberSerializer(read_only=True) fixed_data = JSONField(validators=[is_dict], required=False) + flow_obj = FlowSerializer(read_only=True, required=False, source="flow") class Meta: @@ -60,6 +62,8 @@ class InvitationSerializer(ModelSerializer): "fixed_data", "created_by", "single_use", + "flow", + "flow_obj", ] @@ -69,8 +73,8 @@ class InvitationViewSet(UsedByMixin, ModelViewSet): queryset = Invitation.objects.all() serializer_class = InvitationSerializer ordering = ["-expires"] - search_fields = ["name", "created_by__username", "expires"] - filterset_fields = ["name", "created_by__username", "expires"] + search_fields = ["name", "created_by__username", "expires", "flow__slug"] + filterset_fields = ["name", "created_by__username", "expires", "flow__slug"] def perform_create(self, serializer: InvitationSerializer): serializer.save(created_by=self.request.user) diff --git a/authentik/stages/invitation/migrations/0007_invitation_flow.py b/authentik/stages/invitation/migrations/0007_invitation_flow.py new file mode 100644 index 000000000..2842a3039 --- /dev/null +++ b/authentik/stages/invitation/migrations/0007_invitation_flow.py @@ -0,0 +1,26 @@ +# Generated by Django 4.1.4 on 2022-12-20 13:43 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("authentik_flows", "0024_flow_authentication"), + ("authentik_stages_invitation", "0001_squashed_0006_invitation_name"), + ] + + operations = [ + migrations.AddField( + model_name="invitation", + name="flow", + field=models.ForeignKey( + default=None, + help_text="When set, only the configured flow can use this invitation.", + null=True, + on_delete=django.db.models.deletion.SET_DEFAULT, + to="authentik_flows.flow", + ), + ), + ] diff --git a/authentik/stages/invitation/models.py b/authentik/stages/invitation/models.py index 6c69cbd36..6ea564ae0 100644 --- a/authentik/stages/invitation/models.py +++ b/authentik/stages/invitation/models.py @@ -55,6 +55,13 @@ class Invitation(SerializerModel, ExpiringModel): name = models.SlugField() + flow = models.ForeignKey( + "authentik_flows.Flow", + default=None, + null=True, + on_delete=models.SET_DEFAULT, + help_text=_("When set, only the configured flow can use this invitation."), + ) single_use = models.BooleanField( default=False, help_text=_("When enabled, the invitation will be deleted after usage."), diff --git a/authentik/stages/invitation/stage.py b/authentik/stages/invitation/stage.py index 44688cddd..d916ac68a 100644 --- a/authentik/stages/invitation/stage.py +++ b/authentik/stages/invitation/stage.py @@ -35,22 +35,30 @@ class InvitationStageView(StageView): return self.executor.plan.context[PLAN_CONTEXT_PROMPT][INVITATION_TOKEN_KEY_CONTEXT] return None + def get_invite(self) -> Optional[Invitation]: + """Check the token, find the invite and check it's flow""" + token = self.get_token() + if not token: + return None + invite: Invitation = Invitation.objects.filter(pk=token).first() + if not invite: + self.logger.debug("invalid invitation", token=token) + return None + if invite.flow and invite.flow.pk != self.executor.plan.flow_pk: + self.logger.debug("invite for incorrect flow", expected=invite.flow.slug) + return None + return invite + def get(self, request: HttpRequest) -> HttpResponse: """Apply data to the current flow based on a URL""" stage: InvitationStage = self.executor.current_stage - token = self.get_token() - if not token: - # No Invitation was given, raise error or continue + + invite = self.get_invite() + if not invite: if stage.continue_flow_without_invitation: return self.executor.stage_ok() return self.executor.stage_invalid() - invite: Invitation = Invitation.objects.filter(pk=token).first() - if not invite: - self.logger.debug("invalid invitation", token=token) - if stage.continue_flow_without_invitation: - return self.executor.stage_ok() - return self.executor.stage_invalid() self.executor.plan.context[INVITATION_IN_EFFECT] = True self.executor.plan.context[INVITATION] = invite diff --git a/authentik/stages/invitation/tests.py b/authentik/stages/invitation/tests.py index 0441b2921..75989a147 100644 --- a/authentik/stages/invitation/tests.py +++ b/authentik/stages/invitation/tests.py @@ -23,7 +23,7 @@ from authentik.stages.password import BACKEND_INBUILT from authentik.stages.password.stage import PLAN_CONTEXT_AUTHENTICATION_BACKEND -class TestUserLoginStage(FlowTestCase): +class TestInvitationStage(FlowTestCase): """Login tests""" def setUp(self): @@ -98,6 +98,33 @@ class TestUserLoginStage(FlowTestCase): self.assertEqual(response.status_code, 200) self.assertStageRedirects(response, reverse("authentik_core:root-redirect")) + def test_invalid_flow(self): + """Test with invitation, invalid flow limit""" + invalid_flow = create_test_flow(FlowDesignation.ENROLLMENT) + plan = FlowPlan(flow_pk=self.flow.pk.hex, bindings=[self.binding], markers=[StageMarker()]) + session = self.client.session + session[SESSION_KEY_PLAN] = plan + session.save() + + data = {"foo": "bar"} + invite = Invitation.objects.create( + created_by=get_anonymous_user(), fixed_data=data, flow=invalid_flow + ) + + with patch("authentik.flows.views.executor.FlowExecutorView.cancel", MagicMock()): + base_url = reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug}) + args = urlencode({INVITATION_TOKEN_KEY: invite.pk.hex}) + response = self.client.get(base_url + f"?query={args}") + + session = self.client.session + plan: FlowPlan = session[SESSION_KEY_PLAN] + + self.assertStageResponse( + response, + flow=self.flow, + component="ak-stage-access-denied", + ) + def test_with_invitation_prompt_data(self): """Test with invitation, check data in session""" data = {"foo": "bar"} diff --git a/schema.yml b/schema.yml index 8c3378ad9..3dc6f0655 100644 --- a/schema.yml +++ b/schema.yml @@ -22331,6 +22331,10 @@ paths: schema: type: string format: date-time + - in: query + name: flow__slug + schema: + type: string - in: query name: name schema: @@ -28457,8 +28461,18 @@ components: single_use: type: boolean description: When enabled, the invitation will be deleted after usage. + flow: + type: string + format: uuid + nullable: true + description: When set, only the configured flow can use this invitation. + flow_obj: + allOf: + - $ref: '#/components/schemas/Flow' + readOnly: true required: - created_by + - flow_obj - name - pk InvitationRequest: @@ -28479,6 +28493,11 @@ components: single_use: type: boolean description: When enabled, the invitation will be deleted after usage. + flow: + type: string + format: uuid + nullable: true + description: When set, only the configured flow can use this invitation. required: - name InvitationStage: @@ -33824,6 +33843,11 @@ components: single_use: type: boolean description: When enabled, the invitation will be deleted after usage. + flow: + type: string + format: uuid + nullable: true + description: When set, only the configured flow can use this invitation. PatchedInvitationStageRequest: type: object description: InvitationStage Serializer diff --git a/web/src/admin/stages/invitation/InvitationForm.ts b/web/src/admin/stages/invitation/InvitationForm.ts index 8a0066199..b8174faa2 100644 --- a/web/src/admin/stages/invitation/InvitationForm.ts +++ b/web/src/admin/stages/invitation/InvitationForm.ts @@ -9,8 +9,15 @@ import { t } from "@lingui/macro"; import { TemplateResult, html } from "lit"; import { customElement } from "lit/decorators.js"; +import { ifDefined } from "lit/directives/if-defined.js"; +import { until } from "lit/directives/until.js"; -import { Invitation, StagesApi } from "@goauthentik/api"; +import { + FlowsApi, + FlowsInstancesListDesignationEnum, + Invitation, + StagesApi, +} from "@goauthentik/api"; @customElement("ak-invitation-form") export class InvitationForm extends ModelForm<Invitation, string> { @@ -66,6 +73,34 @@ export class InvitationForm extends ModelForm<Invitation, string> { value="${dateTimeLocal(first(this.instance?.expires, new Date()))}" /> </ak-form-element-horizontal> + <ak-form-element-horizontal label=${t`Flow`} ?required=${true} name="flow"> + <select class="pf-c-form-control"> + <option value="" ?selected=${this.instance?.flow === undefined}> + --------- + </option> + ${until( + new FlowsApi(DEFAULT_CONFIG) + .flowsInstancesList({ + ordering: "slug", + designation: FlowsInstancesListDesignationEnum.Enrollment, + }) + .then((flows) => { + return flows.results.map((flow) => { + return html`<option + value=${ifDefined(flow.pk)} + ?selected=${this.instance?.flow === flow.pk} + > + ${flow.name} (${flow.slug}) + </option>`; + }); + }), + html`<option>${t`Loading...`}</option>`, + )} + </select> + <p class="pf-c-form__helper-text"> + ${t`When selected, the invite will only be usable with the flow. By default the invite is accepted on all flows with invitation stages.`} + </p> + </ak-form-element-horizontal> <ak-form-element-horizontal label=${t`Attributes`} name="fixedData"> <ak-codemirror mode="yaml" diff --git a/web/src/admin/stages/invitation/InvitationListLink.ts b/web/src/admin/stages/invitation/InvitationListLink.ts index 63187c481..b38d6adc6 100644 --- a/web/src/admin/stages/invitation/InvitationListLink.ts +++ b/web/src/admin/stages/invitation/InvitationListLink.ts @@ -14,12 +14,12 @@ import PFFormControl from "@patternfly/patternfly/components/FormControl/form-co import PFFlex from "@patternfly/patternfly/layouts/Flex/flex.css"; import PFBase from "@patternfly/patternfly/patternfly-base.css"; -import { StagesApi } from "@goauthentik/api"; +import { Invitation, StagesApi } from "@goauthentik/api"; @customElement("ak-stage-invitation-list-link") export class InvitationListLink extends AKElement { - @property() - invitation?: string; + @property({ attribute: false }) + invitation?: Invitation; @property() selectedFlow?: string; @@ -29,60 +29,67 @@ export class InvitationListLink extends AKElement { } renderLink(): string { - return `${window.location.protocol}//${window.location.host}/if/flow/${this.selectedFlow}/?itoken=${this.invitation}`; + if (this.invitation?.flowObj) { + this.selectedFlow = this.invitation.flowObj?.slug; + } + return `${window.location.protocol}//${window.location.host}/if/flow/${this.selectedFlow}/?itoken=${this.invitation?.pk}`; + } + + renderFlowSelector(): TemplateResult { + return html`<div class="pf-c-description-list__group"> + <dt class="pf-c-description-list__term"> + <span class="pf-c-description-list__text">${t`Select an enrollment flow`}</span> + </dt> + <dd class="pf-c-description-list__description"> + <div class="pf-c-description-list__text"> + <select + class="pf-c-form-control" + @change=${(ev: Event) => { + const current = (ev.target as HTMLInputElement).value; + this.selectedFlow = current; + }} + > + ${until( + new StagesApi(DEFAULT_CONFIG) + .stagesInvitationStagesList({ + ordering: "name", + noFlows: false, + }) + .then((stages) => { + if ( + !this.selectedFlow && + stages.results.length > 0 && + stages.results[0].flowSet + ) { + this.selectedFlow = stages.results[0].flowSet[0].slug; + } + const seenFlowSlugs: string[] = []; + return stages.results.map((stage) => { + return stage.flowSet?.map((flow) => { + if (seenFlowSlugs.includes(flow.slug)) { + return html``; + } + seenFlowSlugs.push(flow.slug); + return html`<option + value=${flow.slug} + ?selected=${flow.slug === this.selectedFlow} + > + ${flow.slug} + </option>`; + }); + }); + }), + html`<option>${t`Loading...`}</option>`, + )} + </select> + </div> + </dd> + </div>`; } render(): TemplateResult { return html`<dl class="pf-c-description-list pf-m-horizontal"> - <div class="pf-c-description-list__group"> - <dt class="pf-c-description-list__term"> - <span class="pf-c-description-list__text">${t`Select an enrollment flow`}</span> - </dt> - <dd class="pf-c-description-list__description"> - <div class="pf-c-description-list__text"> - <select - class="pf-c-form-control" - @change=${(ev: Event) => { - const current = (ev.target as HTMLInputElement).value; - this.selectedFlow = current; - }} - > - ${until( - new StagesApi(DEFAULT_CONFIG) - .stagesInvitationStagesList({ - ordering: "name", - noFlows: false, - }) - .then((stages) => { - if ( - !this.selectedFlow && - stages.results.length > 0 && - stages.results[0].flowSet - ) { - this.selectedFlow = stages.results[0].flowSet[0].slug; - } - const seenFlowSlugs: string[] = []; - return stages.results.map((stage) => { - return stage.flowSet?.map((flow) => { - if (seenFlowSlugs.includes(flow.slug)) { - return html``; - } - seenFlowSlugs.push(flow.slug); - return html`<option - value=${flow.slug} - ?selected=${flow.slug === this.selectedFlow} - > - ${flow.slug} - </option>`; - }); - }); - }), - html`<option>${t`Loading...`}</option>`, - )} - </select> - </div> - </dd> - </div> + ${this.invitation?.flow === undefined ? this.renderFlowSelector() : html``} <div class="pf-c-description-list__group"> <dt class="pf-c-description-list__term"> <span class="pf-c-description-list__text" diff --git a/web/src/admin/stages/invitation/InvitationListPage.ts b/web/src/admin/stages/invitation/InvitationListPage.ts index 1b3d7c319..801f4a71f 100644 --- a/web/src/admin/stages/invitation/InvitationListPage.ts +++ b/web/src/admin/stages/invitation/InvitationListPage.ts @@ -2,6 +2,7 @@ import "@goauthentik/admin/stages/invitation/InvitationForm"; import "@goauthentik/admin/stages/invitation/InvitationListLink"; import { DEFAULT_CONFIG } from "@goauthentik/common/api/config"; import { uiConfig } from "@goauthentik/common/ui/config"; +import { PFColor } from "@goauthentik/elements/Label"; import "@goauthentik/elements/buttons/ModalButton"; import "@goauthentik/elements/buttons/SpinnerButton"; import "@goauthentik/elements/forms/DeleteBulkForm"; @@ -18,7 +19,7 @@ import { ifDefined } from "lit/directives/if-defined.js"; import PFBanner from "@patternfly/patternfly/components/Banner/banner.css"; -import { Invitation, StagesApi } from "@goauthentik/api"; +import { FlowDesignationEnum, Invitation, StagesApi } from "@goauthentik/api"; @customElement("ak-stage-invitation-list") export class InvitationListPage extends TablePage<Invitation> { @@ -49,12 +50,24 @@ export class InvitationListPage extends TablePage<Invitation> { @state() invitationStageExists = false; + @state() + multipleEnrollmentFlows = false; + async apiEndpoint(page: number): Promise<PaginatedResponse<Invitation>> { + // Check if any invitation stages exist const stages = await new StagesApi(DEFAULT_CONFIG).stagesInvitationStagesList({ noFlows: false, }); this.invitationStageExists = stages.pagination.count > 0; this.expandable = this.invitationStageExists; + stages.results.forEach((stage) => { + const enrollmentFlows = (stage.flowSet || []).filter( + (flow) => flow.designation === FlowDesignationEnum.Enrollment, + ); + if (enrollmentFlows.length > 1) { + this.multipleEnrollmentFlows = true; + } + }); return new StagesApi(DEFAULT_CONFIG).stagesInvitationInvitationsList({ ordering: this.order, page: page, @@ -96,7 +109,14 @@ export class InvitationListPage extends TablePage<Invitation> { row(item: Invitation): TemplateResult[] { return [ - html`${item.name}`, + html`<div>${item.name}</div> + ${!item.flowObj && this.multipleEnrollmentFlows + ? html` + <ak-label color=${PFColor.Orange}> + ${t`Invitation not limited to any flow, and can be used with any enrollment flow.`} + </ak-label> + ` + : html``}`, html`${item.createdBy?.username}`, html`${item.expires?.toLocaleString() || t`-`}`, html` <ak-forms-modal> @@ -114,7 +134,7 @@ export class InvitationListPage extends TablePage<Invitation> { return html` <td role="cell" colspan="3"> <div class="pf-c-table__expandable-row-content"> <ak-stage-invitation-list-link - invitation=${item.pk} + .invitation=${item} ></ak-stage-invitation-list-link> </div> </td> diff --git a/website/docs/security/CVE-2022-23555.md b/website/docs/security/CVE-2022-23555.md new file mode 100644 index 000000000..0c922ac05 --- /dev/null +++ b/website/docs/security/CVE-2022-23555.md @@ -0,0 +1,29 @@ +# CVE-2022-23555 + +## Token reuse in invitation URLs leads to access control bypass via the use of a different enrollment flow + +### Summary + +Token reuse in invitation URLs leads to access control bypass via the use of a different enrollment flow than in the one provided. + +### Patches + +authentik 2022.11.4, 2022.10.4 and 2022.12.0 fix this issue, for other versions the workaround can be used. + +### Impact + +Only configurations using both invitations and have multiple enrollment flows with invitation stages that grant different permissions are affected. The default configuration is not vulnerable, and neither are configurations with a single enrollment flow. + +### Details + +The vulnerability allows an attacker that knows different invitation flows names (e.g. `enrollment-invitation-test` and `enrollment-invitation-admin`) via either different invite links or via brute forcing to signup via a single invitation url for any valid invite link received (it can even be a url for a third flow as long as it's a valid invite) as the token used in the `Invitations` section of the Admin interface does NOT change when a different `enrollment flow` is selected via the interface and it is NOT bound to the selected flow, so it will be valid for any flow when used. + +### Workarounds + +As a workaround, fixed data can be added to invitations which can be checked in the flow to deny requests. Alternatively, an identifier with high entropy (like a UUID) can be used as flow slug, mitigating the attack vector by exponentially decreasing the possibility of discovering other flows. + +### For more information + +If you have any questions or comments about this advisory: + +- Email us at [security@goauthentik.io](mailto:security@goauthentik.io) diff --git a/website/sidebars.js b/website/sidebars.js index aeab71f68..879e9682b 100644 --- a/website/sidebars.js +++ b/website/sidebars.js @@ -294,6 +294,7 @@ module.exports = { "security/policy", "security/CVE-2022-46145", "security/CVE-2022-46172", + "security/CVE-2022-23555", ], }, ],