Skip to content

Commit a0be261

Browse files
fix: Block unlimited users for admins with max limits
1 parent 579c1ee commit a0be261

2 files changed

Lines changed: 219 additions & 18 deletions

File tree

app/operation/user.py

Lines changed: 103 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -281,6 +281,7 @@ async def _persist_bulk_users(
281281
admin,
282282
data_limit=user_to_create.data_limit,
283283
expire=user_to_create.expire,
284+
status=user_to_create.status,
284285
on_hold_expire_duration=user_to_create.on_hold_expire_duration,
285286
hwid_limit=user_to_create.hwid_limit,
286287
data_limit_reset_strategy=user_to_create.data_limit_reset_strategy,
@@ -374,11 +375,15 @@ async def _enforce_user_limits(
374375
*,
375376
data_limit: int | None = None,
376377
expire: dt | int | None = None,
378+
status: UserStatus | None = None,
377379
on_hold_expire_duration: int | None = None,
378380
hwid_limit: int | None = None,
379381
data_limit_reset_strategy=None,
380382
next_plan=None,
381383
check_max_users: bool = False,
384+
require_finite_data_limit: bool = True,
385+
require_finite_expire: bool = True,
386+
require_finite_hwid_limit: bool = True,
382387
) -> None:
383388
"""Enforce role-level limits and feature flags. No-op for owner."""
384389
if admin.is_owner:
@@ -391,6 +396,13 @@ async def _enforce_user_limits(
391396
if current_count >= limits.max_users:
392397
await self.raise_error(message=f"User limit reached ({limits.max_users})", code=400, db=db)
393398

399+
if limits.data_limit_max is not None and require_finite_data_limit and (data_limit is None or data_limit <= 0):
400+
await self.raise_error(
401+
message=f"Data limit cannot be unlimited; maximum is {readable_size(limits.data_limit_max)}",
402+
code=400,
403+
db=db,
404+
)
405+
394406
if data_limit is not None and data_limit > 0:
395407
if limits.data_limit_min is not None and data_limit < limits.data_limit_min:
396408
await self.raise_error(
@@ -401,6 +413,26 @@ async def _enforce_user_limits(
401413
message=f"Data limit cannot exceed {readable_size(limits.data_limit_max)}", code=400, db=db
402414
)
403415

416+
status_value = getattr(status, "value", status)
417+
uses_on_hold_duration = status_value == UserStatus.on_hold.value
418+
419+
if limits.expire_max is not None and require_finite_expire:
420+
if uses_on_hold_duration:
421+
if on_hold_expire_duration is None or on_hold_expire_duration <= 0:
422+
await self.raise_error(
423+
message=(
424+
f"On-hold duration cannot be unlimited; maximum is {readable_duration(limits.expire_max)}"
425+
),
426+
code=400,
427+
db=db,
428+
)
429+
elif expire is None or expire == 0:
430+
await self.raise_error(
431+
message=f"Expire cannot be unlimited; maximum is {readable_duration(limits.expire_max)} from now",
432+
code=400,
433+
db=db,
434+
)
435+
404436
if expire is not None and expire != 0:
405437
expire_dt = fix_datetime_timezone(expire)
406438
seconds = (expire_dt - datetime.now(timezone.utc)).total_seconds()
@@ -431,6 +463,17 @@ async def _enforce_user_limits(
431463
db=db,
432464
)
433465

466+
if (
467+
limits.max_hwid_per_user is not None
468+
and require_finite_hwid_limit
469+
and (hwid_limit is None or hwid_limit <= 0)
470+
):
471+
await self.raise_error(
472+
message=f"HWID limit cannot be unlimited; maximum is {limits.max_hwid_per_user}",
473+
code=400,
474+
db=db,
475+
)
476+
434477
if hwid_limit is not None:
435478
if limits.min_hwid_per_user is not None and hwid_limit < limits.min_hwid_per_user:
436479
await self.raise_error(
@@ -456,25 +499,26 @@ async def create_user(
456499
if new_user.hwid_limit is None:
457500
new_user.hwid_limit = hwid_conf.fallback_limit
458501

459-
if new_user.hwid_limit is not None and not admin.is_owner:
460-
if new_user.hwid_limit < hwid_conf.min_limit:
461-
await self.raise_error(message=f"HWID limit cannot be less than {hwid_conf.min_limit}", code=400, db=db)
462-
if hwid_conf.max_limit > 0 and (new_user.hwid_limit > hwid_conf.max_limit or new_user.hwid_limit == 0):
463-
await self.raise_error(message=f"HWID limit cannot exceed {hwid_conf.max_limit}", code=400, db=db)
464-
465502
if not skip_role_limits:
466503
await self._enforce_user_limits(
467504
db,
468505
admin,
469506
data_limit=new_user.data_limit,
470507
expire=new_user.expire,
508+
status=new_user.status,
471509
on_hold_expire_duration=new_user.on_hold_expire_duration,
472510
hwid_limit=new_user.hwid_limit,
473511
data_limit_reset_strategy=new_user.data_limit_reset_strategy,
474512
next_plan=new_user.next_plan,
475513
check_max_users=True,
476514
)
477515

516+
if new_user.hwid_limit is not None and not admin.is_owner:
517+
if new_user.hwid_limit < hwid_conf.min_limit:
518+
await self.raise_error(message=f"HWID limit cannot be less than {hwid_conf.min_limit}", code=400, db=db)
519+
if hwid_conf.max_limit > 0 and (new_user.hwid_limit > hwid_conf.max_limit or new_user.hwid_limit == 0):
520+
await self.raise_error(message=f"HWID limit cannot exceed {hwid_conf.max_limit}", code=400, db=db)
521+
478522
if new_user.next_plan is not None and new_user.next_plan.user_template_id is not None:
479523
await self.get_validated_user_template(db, new_user.next_plan.user_template_id)
480524

@@ -513,27 +557,61 @@ async def _prepare_modified_user(
513557
db=db,
514558
)
515559

516-
if modified_user.hwid_limit is not None and not admin.is_owner:
517-
hwid_conf = await hwid_settings()
518-
if modified_user.hwid_limit < hwid_conf.min_limit:
519-
await self.raise_error(message=f"HWID limit cannot be less than {hwid_conf.min_limit}", code=400, db=db)
520-
if hwid_conf.max_limit > 0 and (
521-
modified_user.hwid_limit > hwid_conf.max_limit or modified_user.hwid_limit == 0
522-
):
523-
await self.raise_error(message=f"HWID limit cannot exceed {hwid_conf.max_limit}", code=400, db=db)
524-
525560
if not skip_role_limits:
561+
modified_fields = modified_user.model_fields_set
562+
data_limit_was_changed = "data_limit" in modified_fields and modified_user.data_limit is not None
563+
expire_was_changed = "expire" in modified_fields and modified_user.expire is not None
564+
status_was_changed = modified_user.status is not None
565+
modified_status_value = getattr(modified_user.status, "value", modified_user.status)
566+
status_requires_finite_expire = modified_status_value in {
567+
UserStatus.active.value,
568+
UserStatus.on_hold.value,
569+
}
570+
on_hold_duration_was_changed = (
571+
"on_hold_expire_duration" in modified_fields and modified_user.on_hold_expire_duration is not None
572+
)
573+
expire_requires_finite_limit = (
574+
expire_was_changed
575+
or (status_was_changed and status_requires_finite_expire)
576+
or on_hold_duration_was_changed
577+
)
578+
hwid_limit_was_changed = "hwid_limit" in modified_fields and modified_user.hwid_limit is not None
579+
580+
effective_status = modified_user.status if modified_user.status is not None else db_user.status
581+
effective_expire = modified_user.expire
582+
effective_on_hold_expire_duration = modified_user.on_hold_expire_duration
583+
if status_was_changed and status_requires_finite_expire:
584+
if modified_status_value == UserStatus.on_hold.value:
585+
effective_expire = None
586+
if effective_on_hold_expire_duration is None:
587+
effective_on_hold_expire_duration = db_user.on_hold_expire_duration
588+
elif effective_expire is None:
589+
effective_expire = db_user.expire
590+
526591
await self._enforce_user_limits(
527592
db,
528593
admin,
529594
data_limit=modified_user.data_limit,
530-
expire=modified_user.expire,
531-
on_hold_expire_duration=modified_user.on_hold_expire_duration,
595+
expire=effective_expire,
596+
status=effective_status,
597+
on_hold_expire_duration=effective_on_hold_expire_duration,
532598
hwid_limit=modified_user.hwid_limit,
533599
data_limit_reset_strategy=modified_user.data_limit_reset_strategy,
534600
next_plan=modified_user.next_plan,
601+
require_finite_data_limit=data_limit_was_changed,
602+
require_finite_expire=expire_requires_finite_limit,
603+
require_finite_hwid_limit=hwid_limit_was_changed,
535604
)
536605

606+
if modified_user.hwid_limit is not None and not admin.is_owner:
607+
hwid_conf = await hwid_settings()
608+
if modified_user.hwid_limit < hwid_conf.min_limit:
609+
await self.raise_error(message=f"HWID limit cannot be less than {hwid_conf.min_limit}", code=400, db=db)
610+
if hwid_conf.max_limit > 0 and (
611+
modified_user.hwid_limit > hwid_conf.max_limit or modified_user.hwid_limit == 0
612+
):
613+
await self.raise_error(message=f"HWID limit cannot exceed {hwid_conf.max_limit}", code=400, db=db)
614+
537615
validated_groups = None
538616
if modified_user.group_ids:
539617
validated_groups = await self.validate_all_groups(db, modified_user)
@@ -1250,7 +1328,14 @@ async def create_user_from_template(
12501328
raise exc
12511329

12521330
# Template defines data_limit/expire/etc — only check max_users
1253-
await self._enforce_user_limits(db, admin, check_max_users=True)
1331+
await self._enforce_user_limits(
1332+
db,
1333+
admin,
1334+
check_max_users=True,
1335+
require_finite_data_limit=False,
1336+
require_finite_expire=False,
1337+
require_finite_hwid_limit=False,
1338+
)
12541339
return await self.create_user(db, new_user, admin, skip_role_limits=True)
12551340

12561341
async def _modify_user_with_template(

tests/api/test_user.py

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,34 @@ def _build_v2_subscription_token(user_id: int, secret: str) -> str:
9999
return data_b64 + sign
100100

101101

102+
def _create_limited_user_creator_role(access_token: str) -> dict:
103+
response = client.post(
104+
"/api/admin-role",
105+
headers=auth_headers(access_token),
106+
json={
107+
"name": unique_name("bounded_user_creator"),
108+
"permissions": {"users": {"create": True, "read": True, "update": True, "delete": True}},
109+
"limits": {
110+
"max_users": None,
111+
"data_limit_min": None,
112+
"data_limit_max": 10 * 1024 * 1024,
113+
"expire_min": None,
114+
"expire_max": 24 * 60 * 60,
115+
"min_hwid_per_user": None,
116+
"max_hwid_per_user": 3,
117+
},
118+
"features": {"can_use_reset_strategy": True, "can_use_next_plan": True},
119+
"access": {"require_template": False, "allowed_template_ids": None, "allowed_group_ids": None},
120+
},
121+
)
122+
assert response.status_code == status.HTTP_201_CREATED
123+
return response.json()
124+
125+
126+
def _delete_role(access_token: str, role_id: int) -> None:
127+
client.delete(f"/api/admin-role/{role_id}", headers=auth_headers(access_token))
128+
129+
102130
def test_subscription_token_generation_avoids_trailing_dash_or_underscore_and_keeps_v2_compatibility(monkeypatch):
103131
secret = "test-secret"
104132

@@ -152,6 +180,94 @@ def test_user_create_active(access_token):
152180
cleanup_groups(access_token, core, groups)
153181

154182

183+
def test_limited_admin_cannot_create_or_modify_user_to_unlimited_data_or_expire(access_token):
184+
role = _create_limited_user_creator_role(access_token)
185+
admin = create_admin(access_token, role_id=role["id"])
186+
admin_token = _login(admin["username"], admin["password"])
187+
username = unique_name("bounded_limit_user")
188+
finite_expire = (datetime.now(timezone.utc).replace(microsecond=0) + timedelta(hours=1)).isoformat()
189+
190+
try:
191+
response = client.post(
192+
"/api/user",
193+
headers=auth_headers(admin_token),
194+
json={
195+
"username": unique_name("bounded_no_data"),
196+
"proxy_settings": {},
197+
"expire": finite_expire,
198+
"data_limit": 0,
199+
"data_limit_reset_strategy": "no_reset",
200+
"status": "active",
201+
},
202+
)
203+
assert response.status_code == status.HTTP_400_BAD_REQUEST
204+
assert "Data limit cannot be unlimited" in response.json()["detail"]
205+
206+
response = client.post(
207+
"/api/user",
208+
headers=auth_headers(admin_token),
209+
json={
210+
"username": unique_name("bounded_no_expire"),
211+
"proxy_settings": {},
212+
"expire": 0,
213+
"data_limit": 1024 * 1024,
214+
"data_limit_reset_strategy": "no_reset",
215+
"status": "active",
216+
},
217+
)
218+
assert response.status_code == status.HTTP_400_BAD_REQUEST
219+
assert "Expire cannot be unlimited" in response.json()["detail"]
220+
221+
response = client.post(
222+
"/api/user",
223+
headers=auth_headers(admin_token),
224+
json={
225+
"username": unique_name("bounded_no_hwid"),
226+
"proxy_settings": {},
227+
"expire": finite_expire,
228+
"data_limit": 1024 * 1024,
229+
"data_limit_reset_strategy": "no_reset",
230+
"hwid_limit": 0,
231+
"status": "active",
232+
},
233+
)
234+
assert response.status_code == status.HTTP_400_BAD_REQUEST
235+
assert "HWID limit cannot be unlimited" in response.json()["detail"]
236+
237+
response = client.post(
238+
"/api/user",
239+
headers=auth_headers(admin_token),
240+
json={
241+
"username": username,
242+
"proxy_settings": {},
243+
"expire": finite_expire,
244+
"data_limit": 1024 * 1024,
245+
"data_limit_reset_strategy": "no_reset",
246+
"status": "active",
247+
},
248+
)
249+
assert response.status_code == status.HTTP_201_CREATED
250+
251+
response = client.put(f"/api/user/{username}", headers=auth_headers(admin_token), json={"note": "allowed"})
252+
assert response.status_code == status.HTTP_200_OK
253+
254+
response = client.put(f"/api/user/{username}", headers=auth_headers(admin_token), json={"data_limit": 0})
255+
assert response.status_code == status.HTTP_400_BAD_REQUEST
256+
assert "Data limit cannot be unlimited" in response.json()["detail"]
257+
258+
response = client.put(f"/api/user/{username}", headers=auth_headers(admin_token), json={"expire": 0})
259+
assert response.status_code == status.HTTP_400_BAD_REQUEST
260+
assert "Expire cannot be unlimited" in response.json()["detail"]
261+
262+
response = client.put(f"/api/user/{username}", headers=auth_headers(admin_token), json={"hwid_limit": 0})
263+
assert response.status_code == status.HTTP_400_BAD_REQUEST
264+
assert "HWID limit cannot be unlimited" in response.json()["detail"]
265+
finally:
266+
client.delete(f"/api/user/{username}", headers=auth_headers(access_token))
267+
delete_admin(access_token, admin["username"])
268+
_delete_role(access_token, role["id"])
269+
270+
155271
def test_user_create_expire_timezone_offset_normalized_to_utc(access_token):
156272
"""Expire with non-UTC offset should be persisted as the same UTC instant."""
157273
core, groups = setup_groups(access_token, 1)

0 commit comments

Comments
 (0)