Promote the 2026-08-30 review security fixes and supplies ordering to main
This commit is contained in:
@@ -98,7 +98,13 @@ from .plugins import plugin_manager
|
||||
# model internals. The endpoints plugin resolves a delivered script by name and
|
||||
# serves its body by digest to the agent. Additive name on the surface, minor
|
||||
# bump.
|
||||
__contract_version__ = '0.27.0'
|
||||
# 0.28.0: added client_ip and strip_port to shopdb.api so a plugin does not
|
||||
# hand-roll caller-IP extraction. Two plugins had, and both read the first
|
||||
# X-Forwarded-For hop, which the caller sends - so a rate limit keyed on it was
|
||||
# defeated by rotating the header. The helper resolves from remote_addr, which
|
||||
# ProxyFix has already filled from the header when TRUSTED_PROXY_COUNT says a
|
||||
# proxy is really in front. Additive names on the surface, minor bump.
|
||||
__contract_version__ = '0.28.0'
|
||||
|
||||
# Product release version (see ADR-007). The product version and the
|
||||
# plugin-contract version above are distinct series with independent
|
||||
@@ -211,7 +217,14 @@ def create_app(config_name: str = None) -> Flask:
|
||||
def user_lookup_callback(_jwt_header, jwt_data):
|
||||
from .core.models import User
|
||||
identity = jwt_data["sub"]
|
||||
return db.session.get(User, int(identity))
|
||||
user = db.session.get(User, int(identity))
|
||||
# Deactivating an account has to end its sessions. There is no token
|
||||
# denylist, so without this check the access token stays good until it
|
||||
# expires and the refresh token keeps minting new ones for 30 days.
|
||||
# /auth/refresh and the API-token path already check isactive.
|
||||
if user is None or not user.isactive:
|
||||
return None
|
||||
return user
|
||||
|
||||
@app.after_request
|
||||
def apply_security_headers(response):
|
||||
|
||||
@@ -104,6 +104,12 @@ def collector_authorized(pluginname: str):
|
||||
# as script in this origin - see shopdb/utils/uploads.py.
|
||||
from shopdb.utils.uploads import send_upload, harden_upload_response
|
||||
|
||||
# Caller IP for rate limiting and audit. On the surface so a plugin does not
|
||||
# reimplement it: the obvious hand-rolled version reads X-Forwarded-For, which
|
||||
# the caller supplies, so a rate limit keyed on it can be rotated away to
|
||||
# nothing. See shopdb/utils/clientip.py.
|
||||
from shopdb.utils.clientip import client_ip, strip_port
|
||||
|
||||
# Import-mode helpers: preserve legacy timestamps during a bulk data import
|
||||
from shopdb.utils.import_mode import (
|
||||
apply_import_timestamps,
|
||||
@@ -346,6 +352,8 @@ __all__ = [
|
||||
# Serving uploads
|
||||
'send_upload',
|
||||
'harden_upload_response',
|
||||
'client_ip',
|
||||
'strip_port',
|
||||
# Import-mode helpers
|
||||
'apply_import_timestamps',
|
||||
'import_mode_active',
|
||||
|
||||
@@ -656,13 +656,17 @@ def upload_application_package(app_id: int):
|
||||
|
||||
|
||||
@applications_bp.route('/package/<path:filename>', methods=['GET'])
|
||||
@require_permission('applications.view')
|
||||
@require_permission('applications.download')
|
||||
def serve_application_package(filename):
|
||||
"""Download an uploaded installer.
|
||||
|
||||
Authenticated, unlike the image: this is licensed vendor software, and an
|
||||
open URL would publish it to anything that can reach the site. Always sent
|
||||
as an attachment so a browser saves it rather than trying to render it.
|
||||
|
||||
Gated on applications.download, not applications.view: a guest may be
|
||||
granted the metadata permission, and "an open URL would publish it" is just
|
||||
as true of a URL any anonymous visitor can reach.
|
||||
"""
|
||||
return send_upload(_apppackage_dir(), filename, as_attachment=True)
|
||||
|
||||
|
||||
@@ -196,11 +196,15 @@ def upload_script_body(script_id: int):
|
||||
|
||||
|
||||
@scripts_bp.route('/body/<path:filename>', methods=['GET'])
|
||||
@require_permission('applications.view')
|
||||
@require_permission('applications.download')
|
||||
def download_script_body(filename: str):
|
||||
"""Operator-facing script body download (JWT/RBAC).
|
||||
|
||||
The endpoint agent uses the collector-authenticated plugin route instead;
|
||||
this one is for humans reviewing what a script does.
|
||||
|
||||
Gated on applications.download, not applications.view: this serves the
|
||||
SYSTEM-context PowerShell a site pushes to its bays, and a guest may be
|
||||
granted the metadata permission.
|
||||
"""
|
||||
return send_upload(_script_dir(), filename, as_attachment=True)
|
||||
|
||||
@@ -18,7 +18,7 @@ from shopdb.core.models import (
|
||||
from shopdb.core.api.settings import get_cached_settings
|
||||
from shopdb.core.services.spellfix import Vocabulary
|
||||
from shopdb.utils.responses import success_response
|
||||
from shopdb.utils.authz import require_permission
|
||||
from shopdb.utils.authz import require_permission, caller_holds
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -1233,22 +1233,42 @@ def global_search():
|
||||
results = []
|
||||
search_term = f'%{query}%'
|
||||
|
||||
# Run all search domains
|
||||
results.extend(_search_applications(query, search_term))
|
||||
results.extend(_search_knowledgebase(query, search_term))
|
||||
results.extend(_search_guides(query, search_term))
|
||||
results.extend(_search_employees(query, search_term))
|
||||
results.extend(_search_assets(query, search_term))
|
||||
results.extend(_search_measuringtools(query, search_term))
|
||||
results.extend(_search_usbdevices(query, search_term))
|
||||
results.extend(_search_printeditems(query, search_term))
|
||||
results.extend(_search_customfields(query, search_term))
|
||||
results.extend(_search_notifications(query, search_term))
|
||||
results.extend(_search_hostnames(query, search_term))
|
||||
results.extend(_search_vendor_model_type(query, search_term))
|
||||
# Every domain answers only to a caller who holds ITS permission.
|
||||
#
|
||||
# The route is gated on assets.view alone, and that used to be the whole
|
||||
# check: one permission then queried twelve domains, so it read out of the
|
||||
# staff directory, the USB checkout history, the notification log and the
|
||||
# network inventory to anybody who could look up an asset number. The
|
||||
# SEARCH_DOMAINS settings are feature toggles a site sets, not authorization,
|
||||
# and were never doing this job.
|
||||
#
|
||||
# caller_holds rather than require_permission: a search that refuses the
|
||||
# whole request because one domain is off-limits is useless. Each domain
|
||||
# simply contributes nothing.
|
||||
domains = [
|
||||
('assets.view', _search_assets),
|
||||
# Emit asset results routed to an asset page, so they belong to the
|
||||
# same grant as the assets themselves.
|
||||
('assets.view', _search_customfields),
|
||||
('assets.view', _search_vendor_model_type),
|
||||
('applications.view', _search_applications),
|
||||
('kb.view', _search_knowledgebase),
|
||||
('guides.view', _search_guides),
|
||||
('employees.view', _search_employees),
|
||||
('measuringtools.view', _search_measuringtools),
|
||||
('usb.view', _search_usbdevices),
|
||||
('printedparts.view', _search_printeditems),
|
||||
('notifications.view', _search_notifications),
|
||||
('computers.view', _search_hostnames),
|
||||
]
|
||||
for permission, search in domains:
|
||||
if caller_holds(permission):
|
||||
results.extend(search(query, search_term))
|
||||
|
||||
# IP-specific searches
|
||||
if classification['is_ip']:
|
||||
# IP-specific searches. An address and the subnet it sits in are network
|
||||
# inventory, and knowing what answers on 10.20.x is a different question
|
||||
# from looking up an asset number.
|
||||
if classification['is_ip'] and caller_holds('network.view'):
|
||||
results.extend(_search_by_ip(query, search_term))
|
||||
results.extend(_search_subnets(query))
|
||||
|
||||
|
||||
@@ -351,19 +351,20 @@ def update_role(roleid: int):
|
||||
# Update permissions
|
||||
if 'permissions' in data and role.rolename != 'admin':
|
||||
# The guest role is anyone with no account, so what it may hold is
|
||||
# bounded: reads, plus the handful of writes a plugin has explicitly
|
||||
# declared safe to do anonymously. Refused at the door rather than
|
||||
# warned about - a checkbox that quietly makes deleting an asset
|
||||
# anonymous is not a warning anybody reads twice.
|
||||
# bounded to what core or a plugin has explicitly declared grantable.
|
||||
# Refused at the door rather than warned about - a checkbox that
|
||||
# quietly makes deleting an asset anonymous is not a warning anybody
|
||||
# reads twice.
|
||||
if role.rolename == Role.GUEST:
|
||||
allowed = guest_grantable_permissions()
|
||||
refused = sorted(set(data['permissions']) - allowed)
|
||||
if refused:
|
||||
return error_response(
|
||||
ErrorCodes.VALIDATION_ERROR,
|
||||
'The guest role cannot hold {}. A guest may be granted any '
|
||||
'view permission, and only writes a plugin has declared '
|
||||
'safe to do without an account.'.format(', '.join(refused)))
|
||||
'The guest role cannot hold {}. A guest may only be '
|
||||
'granted permissions core or the owning plugin has '
|
||||
'declared grantable, which is a code change rather than a '
|
||||
'setting.'.format(', '.join(refused)))
|
||||
old_perms = [p.name for p in role.permissions]
|
||||
perms = Permission.query.filter(Permission.name.in_(data['permissions'])).all()
|
||||
new_perms = [p.name for p in perms]
|
||||
|
||||
@@ -46,6 +46,12 @@ class Permission(db.Model):
|
||||
('assets.delete', 'Delete assets', 'assets'),
|
||||
# Applications
|
||||
('applications.view', 'View applications', 'applications'),
|
||||
# Deliberately NOT '.view': reading the catalogue and pulling the bytes
|
||||
# are different acts. A script body is SYSTEM-context PowerShell and an
|
||||
# installer is a vendor binary, so the routes that serve them are gated
|
||||
# here rather than on the metadata permission a site may hand to guests.
|
||||
('applications.download',
|
||||
'Download application installers and script bodies', 'applications'),
|
||||
('applications.create', 'Create applications', 'applications'),
|
||||
('applications.edit', 'Edit applications', 'applications'),
|
||||
('applications.delete', 'Delete applications', 'applications'),
|
||||
@@ -152,28 +158,60 @@ def full_permission_catalog(include_disabled=False):
|
||||
return catalog
|
||||
|
||||
|
||||
# Core permissions a site MAY grant the guest role. Declared, not derived.
|
||||
# Each one has been read against what its routes actually return.
|
||||
#
|
||||
# Deliberately absent, with the reason, so nobody re-adds them by reflex:
|
||||
# settings.view - every setting row, and secret masking is a substring test
|
||||
# audit.view - usernames, addresses, and the before/after of every change
|
||||
# users.view - gates nothing; list_users is require_role('admin')
|
||||
CORE_GUEST_GRANTABLE = {
|
||||
# Aggregate counts only; no row-level data reaches the caller.
|
||||
'reports.view',
|
||||
# Catalogue metadata. Safe only because the bytes moved to
|
||||
# applications.download, which is not grantable.
|
||||
'applications.view',
|
||||
# Assets, and the global search over them. Safe only because search now
|
||||
# gates each domain on its own permission: holding this reaches assets and
|
||||
# nothing else, where it used to reach the staff directory and the USB,
|
||||
# notification and network domains too.
|
||||
'assets.view',
|
||||
}
|
||||
|
||||
|
||||
def guest_grantable_permissions():
|
||||
"""Which permissions the guest role may be granted.
|
||||
|
||||
Reading is the safe default and covers almost everything a site wants to
|
||||
open up: any `.view` or `.read` permission may be handed to guests.
|
||||
Every entry is DECLARED, by core below or by a plugin through
|
||||
get_guest_grantable_permissions(). Nothing qualifies by being spelled a
|
||||
particular way.
|
||||
|
||||
A write is different - it lets somebody with no account change this
|
||||
database, and the audit trail can then say when and from where but never
|
||||
who. So a write has to be BLESSED by whoever owns it, through the plugin's
|
||||
get_guest_grantable_permissions(). Secure Send is the case that exists:
|
||||
creating a one-view link for somebody is the whole point of the page, and
|
||||
the page has always been usable by people with no account here.
|
||||
It used to: any name ending `.view` or `.read` was grantable, on the
|
||||
reasoning that reading is the safe default. That is true of a permission
|
||||
that gates a list of asset numbers and false of one that gates bytes, and
|
||||
the suffix cannot tell them apart. `applications.view` reached the
|
||||
SYSTEM-context PowerShell bodies and the installer binaries;
|
||||
`backups.view` returned the full payload the separate backups.download
|
||||
permission exists to protect; `assets.view` gated a global search that
|
||||
queries the staff directory. None of those were decided. They were named.
|
||||
|
||||
Everything else is refused at the door rather than warned about, because a
|
||||
checkbox that quietly makes deleting an asset anonymous is not a warning
|
||||
anybody reads twice.
|
||||
So the rule is inverted: a permission is grantable when somebody wrote it
|
||||
down, having looked at what it actually serves. Adding one is a code
|
||||
change, which is the point - it puts the decision in a diff rather than in
|
||||
a suffix.
|
||||
|
||||
Naming one here does not open it. It makes the checkbox available in the
|
||||
role grid; a site still has to tick it.
|
||||
|
||||
The bar, for a read: could an anonymous visitor hold this, forever, and
|
||||
would the site be content with everything it returns? For a write, add
|
||||
that the act can never be attributed to anybody.
|
||||
"""
|
||||
from flask import current_app, has_app_context
|
||||
|
||||
catalog = full_permission_catalog()
|
||||
grantable = {name for name, _, _ in catalog
|
||||
if name.endswith('.view') or name.endswith('.read')}
|
||||
known = {name for name, _, _ in catalog}
|
||||
grantable = {name for name in CORE_GUEST_GRANTABLE if name in known}
|
||||
|
||||
if not has_app_context():
|
||||
return grantable
|
||||
@@ -181,7 +219,6 @@ def guest_grantable_permissions():
|
||||
if not pm:
|
||||
return grantable
|
||||
|
||||
known = {name for name, _, _ in catalog}
|
||||
for name, plugin in pm.get_all_plugins().items():
|
||||
if not pm.registry.is_enabled(name):
|
||||
continue
|
||||
|
||||
@@ -107,17 +107,24 @@ class BasePlugin(ABC):
|
||||
def get_guest_grantable_permissions(self) -> List[str]:
|
||||
"""Permissions this plugin allows a site to grant to the GUEST role.
|
||||
|
||||
A guest is anyone with no account. Any `.view` or `.read` permission is
|
||||
grantable without asking a plugin - reading is the safe default. This
|
||||
hook is for the exception: a WRITE that is genuinely meant to be usable
|
||||
by somebody who never signs in.
|
||||
A guest is anyone with no account. EVERY permission of this plugin's
|
||||
that a site may grant one has to be named here, reads included.
|
||||
|
||||
Reads used to be automatic: anything ending `.view` or `.read` was
|
||||
grantable. That treats a name as a decision, and it went wrong in the
|
||||
obvious way - permissions that gated installer bytes, backup payloads
|
||||
and a search across the staff directory were all anonymous-grantable
|
||||
because of how they were spelled. So the plugin that owns a permission
|
||||
now says, having looked at what its routes return.
|
||||
|
||||
Naming one here does not open it. It makes the checkbox available in
|
||||
the role grid; a site still has to tick it. Return [] (the default) and
|
||||
none of this plugin's writes can ever be made anonymous.
|
||||
nothing of this plugin's can be made anonymous.
|
||||
|
||||
The bar: could this action be attributed to nobody, forever, and still
|
||||
be defensible in the audit log? If not, leave it out.
|
||||
The bar for a read: could an anonymous visitor hold this, forever, and
|
||||
would the site be content with everything the routes return - every
|
||||
column of every serializer, not just the ones the UI happens to show?
|
||||
For a write, add that the act can never be attributed to anybody.
|
||||
"""
|
||||
return []
|
||||
|
||||
|
||||
@@ -6,6 +6,23 @@ every connection - so it must be stripped before the value is stored (audit),
|
||||
matched (dashboard IP fallback), or used as a rate-limit key (login). Left in,
|
||||
it makes every request look like a different client.
|
||||
|
||||
X-Forwarded-For is NOT read here. A client can send that header itself, so
|
||||
trusting it unconditionally hands any caller a free identity: the login rate
|
||||
limit and the account lockout are keyed on this value, and the audit log
|
||||
records it. Reading it directly also walked around TRUSTED_PROXY_COUNT, which
|
||||
exists precisely to decide whether a proxy is really in front (see the ProxyFix
|
||||
block in shopdb/__init__.py).
|
||||
|
||||
The trusted case is already handled upstream. When TRUSTED_PROXY_COUNT is set,
|
||||
ProxyFix resolves X-Forwarded-For into remote_addr before the request reaches a
|
||||
view, so remote_addr is the real client. When it is unset, remote_addr is the
|
||||
peer that actually connected. Either way remote_addr is the trustworthy source,
|
||||
which is why this module only strips the port from it.
|
||||
|
||||
A site behind IIS or nginx MUST set TRUSTED_PROXY_COUNT (1 for a single proxy),
|
||||
or every request will key on the proxy's own address and the per-client rate
|
||||
limit becomes one shared bucket.
|
||||
|
||||
Port stripping is IPv6-aware: a bracketed [2001:db8::1]:443 loses its port; a
|
||||
bare 2001:db8::1 (many colons, no brackets) is returned untouched.
|
||||
"""
|
||||
@@ -26,14 +43,11 @@ def strip_port(ipaddress):
|
||||
|
||||
|
||||
def client_ip(request_obj):
|
||||
"""First-hop client IP behind one proxy, port stripped.
|
||||
"""Client IP, port stripped, from the connecting peer.
|
||||
|
||||
Honors the first X-Forwarded-For hop, else remote_addr. Returns None only
|
||||
when neither is present (callers that need a non-null key coerce it).
|
||||
Resolved from remote_addr, which ProxyFix has already rewritten from
|
||||
X-Forwarded-For when TRUSTED_PROXY_COUNT says a proxy is really in front.
|
||||
Returns None only when remote_addr is absent (callers that need a non-null
|
||||
key coerce it).
|
||||
"""
|
||||
forwarded = request_obj.headers.get('X-Forwarded-For')
|
||||
if forwarded:
|
||||
raw = forwarded.split(',')[0].strip()
|
||||
else:
|
||||
raw = request_obj.remote_addr
|
||||
return strip_port(raw)
|
||||
return strip_port(request_obj.remote_addr)
|
||||
|
||||
Reference in New Issue
Block a user