Validate what a form accepts, and keep a diff to one asset
The validation tail from the 2026-09-03 run. Each is small; together they are the difference between a field that checks its input and one that looks like it does. Subnets accepted any string containing a slash, so 'notanip/xx' was stored - and _devices_in_subnet then swallowed the ValueError from ip_network and returned an empty device list, so the subnet looked real and permanently empty. It is parsed with the same library that has to read it later, a prefix length is required (ipaddress reads a bare 10.1.1.0 as a /32, which is legal and never what somebody typing a subnet meant), and the stored form is normalised so 10.1.1.5/24 and 10.1.1.0/24 cannot become two rows nobody can tell apart. VLAN numbers had no range check: 99999 and -5 were stored. Worse, a non-numeric value reached a duplicate probe that compares against an integer column, which MySQL coerces to 0 - so 'xyz' matched an existing VLAN 0 and the 409 named a row that had nothing to do with it. 1-4094 per 802.1Q, checked before the probe. Deleting a VLAN that subnets still reference now answers 409 like every sibling in-use guard, rather than 400, which told the caller their request was malformed when the request was fine and the state was the problem. An appversion sha256 was stored raw, so an over-long value was truncated to the column width and came back as 64 plausible hex characters - indistinguishable from a real digest, and wrong. A digest is a claim the delivery path checks against, so it is 64 hex characters or it is refused. The releasedate goes through the strict parser for the same reason it did everywhere else. PUT /api/settings/<key> ignored the declared valuetype. "A6-not-a-number" went into an integer key verbatim and every consumer then read 0, because get_typed_value swallows the ValueError - the setting looked set and the behaviour was the default. /api/maplevels told a caller who omitted the level name that 'buildingname and buildingid are required'; buildingname is a field that handler never reads. It now names the fields it actually wants. Restoring a guide revision that is already current writes nothing, because save() dedups on the content hash - and the response still said "Restored version N as a new draft version", describing a revision that does not exist while the unchanged revision list disagreed. GET /api/backups/revisions/<id>/diff?against=<id> accepted any revision id, so pointing it at another asset's revision rendered that asset's configuration as the "before" side of this asset's history: a diff that reads as a change on this machine and describes a different one. Same asset and same kind, or refused. The default branch was always asset-scoped; only the explicit one was not. Two frontend fixes: the per-page selector rendered blank on any list defaulting to a size not in [10,20,50,100] (network devices uses 25), so the operator could not see the current page size; and an emptied PC alias was dropped from the payload instead of sent, so it could not be cleared - the form came back showing the alias that had just been deleted. Twenty-two tests. The backups guard verified by planting. Scope: product
This commit is contained in:
@@ -309,6 +309,23 @@ def diff_revision(backuprevisionid):
|
||||
againstid = request.args.get('against', type=int)
|
||||
if againstid:
|
||||
other = db.session.get(BackupRevision, againstid)
|
||||
# It has to belong to the SAME asset. Any revision id was accepted, so
|
||||
# ?against=<another asset's revision> rendered that asset's
|
||||
# configuration values as the "before" side of this asset's history -
|
||||
# a diff that reads as a change on this machine and describes a
|
||||
# different one. The default branch below has always been asset-scoped;
|
||||
# only the explicit one was not.
|
||||
if other is not None and other.assetid != revision.assetid:
|
||||
return error_response(
|
||||
ErrorCodes.VALIDATION_ERROR,
|
||||
'Revision {} belongs to a different asset and cannot be '
|
||||
'compared against this one'.format(againstid))
|
||||
if other is not None and other.backupkind != revision.backupkind:
|
||||
return error_response(
|
||||
ErrorCodes.VALIDATION_ERROR,
|
||||
'Revision {} is a {} backup and this one is {} - there is '
|
||||
'nothing to compare'.format(againstid, other.backupkind,
|
||||
revision.backupkind))
|
||||
else:
|
||||
# The previous revision FROM THE SAME PC. Several PCs can share a
|
||||
# machine number, and diffing across them reported one device's COM port
|
||||
|
||||
@@ -661,8 +661,14 @@ async function savePC() {
|
||||
// had never sent.
|
||||
levelid: form.value.levelid
|
||||
}
|
||||
// only set display name when an alias is given, so we don't clobber it
|
||||
if (form.value.alias) {
|
||||
// An emptied alias has to be SENT, or it cannot be cleared: the payload
|
||||
// simply omitted `name`, the old value survived, and the form came back
|
||||
// showing the alias the operator had just deleted, with no error and no
|
||||
// toast. On CREATE the field is still omitted when blank, because there
|
||||
// the server may fill it in and sending null would overwrite that.
|
||||
if (isEdit.value) {
|
||||
payload.name = form.value.alias || null
|
||||
} else if (form.value.alias) {
|
||||
payload.name = form.value.alias
|
||||
}
|
||||
|
||||
|
||||
@@ -503,8 +503,19 @@ def restore_revision(guideid, revisionid):
|
||||
'You do not have permission to edit this guide',
|
||||
http_code=403)
|
||||
|
||||
before = guide.revision
|
||||
authoring.restore(guide, revision, userid=_userid())
|
||||
db.session.commit()
|
||||
|
||||
# save() dedups on the content hash, so restoring the version that is
|
||||
# already current writes nothing. Saying "Restored version N as a new draft
|
||||
# version" in that case describes a revision that does not exist - and the
|
||||
# revision list, unchanged, quietly disagrees with the message.
|
||||
if guide.revision == before:
|
||||
return success_response(
|
||||
guide.to_dict(includeblocks=True),
|
||||
message='Version {} is already the current content, so nothing '
|
||||
'changed'.format(revision.revision))
|
||||
return success_response(guide.to_dict(includeblocks=True),
|
||||
message='Restored version {} as a new draft version'
|
||||
.format(revision.revision))
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
from flask import Blueprint, request
|
||||
from flask_jwt_extended import jwt_required
|
||||
|
||||
import ipaddress
|
||||
|
||||
from shopdb.api import db, Asset, AssetType, Vendor, Communication, CommunicationType, AuditLog, success_response, error_response, paginated_response, ErrorCodes, get_pagination_params, paginate_query
|
||||
|
||||
from ..services.assetnumbers import generate_for_type
|
||||
@@ -697,11 +699,27 @@ def create_vlan():
|
||||
if not data.get('name'):
|
||||
return error_response(ErrorCodes.VALIDATION_ERROR, 'name is required')
|
||||
|
||||
# 802.1Q says 1-4094, and nothing checked. 99999 and -5 were stored, and a
|
||||
# non-numeric value was worse: the duplicate probe below compared a string
|
||||
# against an integer column, which MySQL coerces to 0, so 'xyz' matched an
|
||||
# existing VLAN 0 and the error said "VLAN xyz already exists" about a row
|
||||
# that had nothing to do with it.
|
||||
try:
|
||||
vlannumber = int(data['vlannumber'])
|
||||
except (TypeError, ValueError):
|
||||
return error_response(ErrorCodes.VALIDATION_ERROR,
|
||||
'vlannumber must be a whole number')
|
||||
if not 1 <= vlannumber <= 4094:
|
||||
return error_response(
|
||||
ErrorCodes.VALIDATION_ERROR,
|
||||
'vlannumber must be between 1 and 4094 (802.1Q)')
|
||||
data['vlannumber'] = vlannumber
|
||||
|
||||
# Check for duplicate VLAN number
|
||||
if VLAN.query.filter_by(vlannumber=data['vlannumber']).first():
|
||||
if VLAN.query.filter_by(vlannumber=vlannumber).first():
|
||||
return error_response(
|
||||
ErrorCodes.CONFLICT,
|
||||
f"VLAN {data['vlannumber']} already exists",
|
||||
f"VLAN {vlannumber} already exists",
|
||||
http_code=409
|
||||
)
|
||||
|
||||
@@ -784,10 +802,14 @@ def delete_vlan(vlan_id: int):
|
||||
|
||||
# Check if VLAN has associated subnets
|
||||
if vlan.subnets.filter_by(isactive=True).count() > 0:
|
||||
# 409 like every other in-use guard in the product (network types,
|
||||
# machine types, vendors). It answered 400, which tells the caller
|
||||
# their request was malformed when the request was fine and the state
|
||||
# was the problem.
|
||||
return error_response(
|
||||
ErrorCodes.VALIDATION_ERROR,
|
||||
ErrorCodes.CONFLICT,
|
||||
'Cannot delete VLAN with associated subnets',
|
||||
http_code=400
|
||||
http_code=409
|
||||
)
|
||||
|
||||
vlan.isactive = False
|
||||
@@ -936,10 +958,30 @@ def create_subnet():
|
||||
if not data.get('name'):
|
||||
return error_response(ErrorCodes.VALIDATION_ERROR, 'name is required')
|
||||
|
||||
# Validate CIDR format (basic check)
|
||||
cidr = data['cidr']
|
||||
# Parsed, not eyeballed for a slash. 'notanip/xx' passed the old check and
|
||||
# was stored; _devices_in_subnet then caught the ValueError from
|
||||
# ip_network and returned an empty device list, so the subnet looked real
|
||||
# and permanently empty. ipaddress is the same library that has to read it
|
||||
# later, so agreeing with it is the whole test.
|
||||
cidr = (data['cidr'] or '').strip()
|
||||
# An explicit prefix is required. ipaddress reads a bare '10.1.1.0' as a
|
||||
# /32, which is legal and almost never what somebody typing a SUBNET meant
|
||||
# - it would silently create a subnet holding one address.
|
||||
if '/' not in cidr:
|
||||
return error_response(ErrorCodes.VALIDATION_ERROR, 'cidr must be in CIDR notation (e.g., 10.1.1.0/24)')
|
||||
return error_response(
|
||||
ErrorCodes.VALIDATION_ERROR,
|
||||
'cidr needs a prefix length, e.g. 10.1.1.0/24')
|
||||
try:
|
||||
network = ipaddress.ip_network(cidr, strict=False)
|
||||
except ValueError as bad:
|
||||
return error_response(
|
||||
ErrorCodes.VALIDATION_ERROR,
|
||||
'cidr must be a network in CIDR notation, e.g. 10.1.1.0/24 (%s)'
|
||||
% bad)
|
||||
# Store the normalised form so 10.1.1.5/24 and 10.1.1.0/24 are one subnet
|
||||
# rather than two rows nobody can tell apart.
|
||||
cidr = str(network)
|
||||
data['cidr'] = cidr
|
||||
|
||||
# Check for duplicate CIDR
|
||||
if Subnet.query.filter_by(cidr=cidr).first():
|
||||
|
||||
Reference in New Issue
Block a user