Skip to content

Commit eb0472a

Browse files
dcramermattrobenolt
authored andcommitted
Rate limit organization creation (#4080)
* Rate limit organization creation This adds rate limits to org creation (to prevent abuse from HackerOne kids). They default at 5 per hour.
1 parent 8794162 commit eb0472a

File tree

7 files changed

+58
-46
lines changed

7 files changed

+58
-46
lines changed

src/sentry/api/endpoints/organization_index.py

Lines changed: 32 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,14 +7,15 @@
77
from rest_framework import serializers, status
88
from rest_framework.response import Response
99

10-
from sentry import roles
10+
from sentry import features, options, roles
11+
from sentry.app import ratelimiter
1112
from sentry.api.base import DocSection, Endpoint
1213
from sentry.api.bases.organization import OrganizationPermission
1314
from sentry.api.paginator import DateTimePaginator, OffsetPaginator
1415
from sentry.api.serializers import serialize
1516
from sentry.models import (
16-
AuditLogEntryEvent, Organization, OrganizationMember, OrganizationStatus,
17-
ProjectPlatform
17+
AuditLogEntryEvent, Organization, OrganizationMember,
18+
OrganizationMemberTeam, OrganizationStatus, ProjectPlatform
1819
)
1920
from sentry.search.utils import tokenize_query, in_iexact
2021
from sentry.utils.apidocs import scenario, attach_scenarios
@@ -32,6 +33,7 @@ class OrganizationSerializer(serializers.Serializer):
3233
name = serializers.CharField(max_length=64, required=True)
3334
slug = serializers.RegexField(r'^[a-z0-9_\-]+$', max_length=50,
3435
required=False)
36+
defaultTeam = serializers.BooleanField(required=False)
3537

3638

3739
class OrganizationIndexEndpoint(Endpoint):
@@ -154,6 +156,20 @@ def post(self, request):
154156
return Response({'detail': 'This endpoint requires user info'},
155157
status=401)
156158

159+
if not features.has('organizations:create', actor=request.user):
160+
return Response({
161+
'detail': 'Organizations are not allowed to be created by this user.'
162+
}, status=401)
163+
164+
limit = options.get('api.rate-limit.org-create')
165+
if limit and ratelimiter.is_limited(
166+
u'org-create:{}'.format(request.user.id),
167+
limit=5, window=3600,
168+
):
169+
return Response({
170+
'detail': 'You are attempting to create too many organizations too quickly.'
171+
}, status=429)
172+
157173
serializer = OrganizationSerializer(data=request.DATA)
158174

159175
if serializer.is_valid():
@@ -171,12 +187,23 @@ def post(self, request):
171187
status=409,
172188
)
173189

174-
OrganizationMember.objects.create(
175-
user=request.user,
190+
om = OrganizationMember.objects.create(
176191
organization=org,
192+
user=request.user,
177193
role=roles.get_top_dog().id,
178194
)
179195

196+
if result.get('defaultTeam'):
197+
team = org.team_set.create(
198+
name=org.name,
199+
)
200+
201+
OrganizationMemberTeam.objects.create(
202+
team=team,
203+
organizationmember=om,
204+
is_active=True
205+
)
206+
180207
self.create_audit_entry(
181208
request=request,
182209
organization=org,

src/sentry/options/defaults.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,3 +78,5 @@
7878

7979
register('auth.ip-rate-limit', default=0, flags=FLAG_ALLOW_EMPTY | FLAG_PRIORITIZE_DISK)
8080
register('auth.user-rate-limit', default=0, flags=FLAG_ALLOW_EMPTY | FLAG_PRIORITIZE_DISK)
81+
82+
register('api.rate-limit.org-create', default=5, flags=FLAG_ALLOW_EMPTY | FLAG_PRIORITIZE_DISK)

src/sentry/options/manager.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -194,6 +194,9 @@ def register(self, key, default=None, type=None, flags=DEFAULT_FLAGS,
194194
ttl=DEFAULT_KEY_TTL, grace=DEFAULT_KEY_GRACE):
195195
assert key not in self.registry, 'Option already registered: %r' % key
196196

197+
if len(key) > 64:
198+
raise ValueError('Option key has max length of 64 characters')
199+
197200
# If our default is a callable, execute it to
198201
# see what value is returns, so we can use that to derive the type
199202
if not callable(default):

src/sentry/static/sentry/app/options.jsx

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,12 @@ const definitions = [
5151
placeholder: 'e.g. 10',
5252
help: t('The maximum number of times an authentication attempt may be made against a single account in a 60 second window.'),
5353
},
54+
{
55+
key: 'api.rate-limit.org-create',
56+
label: 'Organization Creation Rate Limit',
57+
placeholder: 'e.g. 5',
58+
help: t('The maximum number of organizations which may be created by a single account in a one hour window.'),
59+
},
5460
{
5561
key: 'mail.from',
5662
label: t('Email From'),

src/sentry/static/sentry/app/views/adminSettings.jsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ const optionsAvailable = [
1515
'system.rate-limit',
1616
'auth.ip-rate-limit',
1717
'auth.user-rate-limit',
18+
'api.rate-limit.org-create',
1819
];
1920

2021
const SettingsList = React.createClass({
@@ -75,9 +76,10 @@ const SettingsList = React.createClass({
7576
{fields['system.admin-email']}
7677
{fields['system.rate-limit']}
7778

78-
<h4>Authentication</h4>
79+
<h4>Security &amp; Abuse</h4>
7980
{fields['auth.ip-rate-limit']}
8081
{fields['auth.user-rate-limit']}
82+
{fields['api.rate-limit.org-create']}
8183
</Form>
8284
);
8385
}

src/sentry/web/frontend/create_organization.py

Lines changed: 9 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -5,22 +5,15 @@
55
from django.http import HttpResponseRedirect
66
from django.utils.translation import ugettext_lazy as _
77

8-
from sentry import features, roles
9-
from sentry.models import (
10-
AuditLogEntryEvent, Organization, OrganizationMember,
11-
OrganizationMemberTeam
12-
)
8+
from sentry import features
9+
from sentry.api import client
1310
from sentry.web.frontend.base import BaseView
1411

1512

16-
class NewOrganizationForm(forms.ModelForm):
13+
class NewOrganizationForm(forms.Form):
1714
name = forms.CharField(label=_('Organization Name'), max_length=200,
1815
widget=forms.TextInput(attrs={'placeholder': _('My Company')}))
1916

20-
class Meta:
21-
fields = ('name',)
22-
model = Organization
23-
2417

2518
class CreateOrganizationView(BaseView):
2619
def get_form(self, request):
@@ -32,35 +25,14 @@ def has_permission(self, request):
3225
def handle(self, request):
3326
form = self.get_form(request)
3427
if form.is_valid():
35-
org = form.save()
36-
37-
om = OrganizationMember.objects.create(
38-
organization=org,
39-
user=request.user,
40-
role=roles.get_top_dog().id,
41-
)
42-
43-
team = org.team_set.create(
44-
name=org.name,
45-
)
46-
47-
OrganizationMemberTeam.objects.create(
48-
team=team,
49-
organizationmember=om,
50-
is_active=True
51-
)
52-
53-
self.create_audit_entry(
54-
request,
55-
organization=org,
56-
target_object=org.id,
57-
event=AuditLogEntryEvent.ORG_ADD,
58-
data=org.get_audit_log_data(),
59-
)
28+
resp = client.post('/organizations/', data={
29+
'name': form.cleaned_data['name'],
30+
'defaultTeam': True,
31+
}, request=request)
6032

61-
url = reverse('sentry-create-project', args=[org.slug])
33+
url = reverse('sentry-create-project', args=[resp.data['slug']])
6234

63-
return HttpResponseRedirect('{}?team={}'.format(url, team.slug))
35+
return HttpResponseRedirect(url)
6436

6537
context = {
6638
'form': form,

tests/sentry/web/frontend/test_create_organization.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -34,9 +34,9 @@ def test_valid_params(self):
3434
role='owner',
3535
).exists()
3636

37-
team = org.team_set.get()
37+
assert org.team_set.exists()
3838

3939
redirect_uri = reverse('sentry-create-project', args=[org.slug])
40-
assert resp['Location'] == 'http://testserver%s?team=%s' % (
41-
redirect_uri, team.slug,
40+
assert resp['Location'] == 'http://testserver%s' % (
41+
redirect_uri,
4242
)

0 commit comments

Comments
 (0)