security: fix CVE 2022 23555 (#4274)

* add flow to invitation

Signed-off-by: Jens Langhammer <jens.langhammer@beryju.org>

* show warning on invitation page

Signed-off-by: Jens Langhammer <jens.langhammer@beryju.org>

* add security advisory

Signed-off-by: Jens Langhammer <jens.langhammer@beryju.org>

* add tests

Signed-off-by: Jens Langhammer <jens.langhammer@beryju.org>

Signed-off-by: Jens Langhammer <jens.langhammer@beryju.org>
This commit is contained in:
Jens L 2022-12-23 14:13:49 +01:00 committed by GitHub
parent 84fbeb5721
commit 9f846d94be
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
11 changed files with 257 additions and 69 deletions

View file

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

View file

@ -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",
),
),
]

View file

@ -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."),

View file

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

View file

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

View file

@ -22391,6 +22391,10 @@ paths:
schema:
type: string
format: date-time
- in: query
name: flow__slug
schema:
type: string
- in: query
name: name
schema:
@ -28531,8 +28535,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:
@ -28553,6 +28567,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:
@ -33904,6 +33923,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

View file

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

View file

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

View file

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

View file

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

View file

@ -296,6 +296,7 @@ module.exports = {
"security/policy",
"security/CVE-2022-46145",
"security/CVE-2022-46172",
"security/CVE-2022-23555",
],
},
],