Skip to content

Commit a32c57e

Browse files
committed
fix(REST): issue2551383; improve errors for bad json, fix PUT docs
While adding fuzz testing for email addresses via REST /rest/data/user/1/address, I had an error when setting the address to the same value it currently had. Traced this to a bug in userauditor.py. Fixed the bug. Documented in upgrading.txt. While trying to track down issue, I realized invalid json was being accepted without error. So I fixed the code that parses the json and have it return an error. Also modified some tests that broke (used invalid json, or passed body (e.g. DELETE) but shouldn't have. Add tests for bad json to verify new code. Fixed test that wasn't initializing the body_file in each loop, so the test wasn't actually supplying a body. Also realised PUT documentation was not correct. Output format isn't quite like GET. Fuss tests for email address also added.
1 parent 8011187 commit a32c57e

File tree

11 files changed

+227
-22
lines changed

11 files changed

+227
-22
lines changed

CHANGES.txt

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,12 @@ Fixed:
4343
incorrectly. (John Rouillard)
4444
- issue2551382 - invalid @verbose, @page_* values in rest uri's
4545
generate 409 not 400 error. (John Rouillard)
46+
- fix issues with rest doc and use of PUT on a property item. Response
47+
is similar to use of PUT on the item, not a GET on the
48+
item. Discovered while fuzz testing. (John Rouillard)
49+
- issue2551383 - Setting same address via REST PUT command results in
50+
an error. Now the userauditor does not trigger an error if a user
51+
sets the primary address to the existing value. (John Rouillard)
4652

4753
Features:
4854

@@ -71,7 +77,10 @@ Features:
7177
endpoint. Raw file/msg data can be retrieved using the
7278
/binary_content attribute and an Accept header to select the mime
7379
type for the data (e.g. image/png for a png file). The existing html
74-
interface method still works and is supported, but is legacy.
80+
interface method still works and is supported, but is legacy. (John
81+
Rouillard)
82+
- added fuzz testing for some code. Found issue2551382 and
83+
others. (John Rouillard)
7584

7685
2024-07-13 2.4.0
7786

doc/rest.txt

Lines changed: 35 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1392,10 +1392,12 @@ Other Supported Methods for Items
13921392
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
13931393

13941394
The method ``PUT`` is allowed on individual items, e.g.
1395-
``/data/issue/42`` On success it returns the same parameters as the
1396-
respective ``GET`` method. Note that for ``PUT`` an Etag has to be
1397-
supplied, either in the request header or as an @etag parameter. An
1398-
example::
1395+
``/data/issue/42`` On success it returns a data structure similar to
1396+
the respective ``GET`` method. However it is only concerned with the
1397+
changes that have occurred. Since it is not a full ``GET`` request, it
1398+
doesn't include an etag or unchanged attributes. Note that for ``PUT``
1399+
an Etag has to be supplied, either in the request header or as an
1400+
@etag parameter. An example::
13991401

14001402
curl -u admin:admin -X PUT \
14011403
--header 'Referer: https://example.com/demo/' \
@@ -1615,7 +1617,22 @@ Other Supported Methods for fields
16151617

16161618
The method ``PUT`` is allowed on a property e.g.,
16171619
``/data/issue/42/title``. On success it returns the same parameters as
1618-
the respective ``GET`` method. Note that for ``PUT`` an Etag has to be
1620+
the respective ``PUT`` method on the item. For example::
1621+
1622+
{
1623+
"data": {
1624+
"id": "42",
1625+
"type": "issue",
1626+
"link": "https://.../demo/rest/data/issue/42",
1627+
"attribute": {
1628+
"title": "this is a new title"
1629+
}
1630+
}
1631+
}
1632+
1633+
If the new value for the title was the same as on the server, the
1634+
attribute property would be empty since the value was not changed.
1635+
Note that for ``PUT`` an Etag has to be
16191636
supplied, either in the request header or as an @etag parameter.
16201637
Example using multipart/form-data rather than json::
16211638

@@ -2438,3 +2455,16 @@ Rate limit tests::
24382455

24392456
will show you the number of remaining requests to the REST interface
24402457
for the user identified by password.
2458+
2459+
2460+
Notes V2 API
2461+
~~~~~~~~~~~~
2462+
2463+
These may never be implemented but, some nits to consider.
2464+
2465+
The shape of a GET and PUT/PATCH responses are different. "attributes"
2466+
is used for GET and "attribute" is used with PATCH/PUT. A PATCH or a
2467+
PUT can update multiple properties when used with an item endpoint.
2468+
"attribute" kind of makes sense when used with a property endpoint
2469+
but.... Maybe standardize on one shape so the client doesn't have to
2470+
special case?

doc/upgrading.txt

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -133,6 +133,32 @@ to::
133133
at the top of both files. The icing macro used in other tracker
134134
templates was renamed to frame in this tracker template.
135135

136+
Update userauditor.py detector (recommended)
137+
--------------------------------------------
138+
139+
When using the REST interface, setting the address property of the
140+
user to the same value it currently has resulted in an error.
141+
142+
If you have not changed your userauditor, you can copy one from any of
143+
the supplied templates in the ``detectors/userauditor.py`` file. Use
144+
``roundup-admin templates`` to find a list of template directories.
145+
146+
If you have changed your userauditor from the stock version, apply the
147+
following diff::
148+
149+
raise ValueError('Email address syntax is invalid
150+
"%s"'%address)
151+
152+
check_main = db.user.stringFind(address=address)
153+
+ # allow user to set same address via rest
154+
+ if check_main:
155+
+ check_main = nodeid not in check_main
156+
+
157+
# make sure none of the alts are owned by anyone other than us (x!=nodeid)
158+
159+
add the lines marked with ``+`` in the file in the location after
160+
check_main is assigned.
161+
136162
More secure session cookie handling (info)
137163
------------------------------------------
138164

roundup/rest.py

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2729,17 +2729,27 @@ class SimulateFieldStorageFromJson():
27292729
27302730
'''
27312731
def __init__(self, json_string):
2732-
''' Parse the json string into an internal dict. '''
2732+
'''Parse the json string into an internal dict.
2733+
2734+
Because spec for rest post once exactly (POE) shows
2735+
posting empty content. An empty string results in an empty
2736+
dict, not a json parse error.
2737+
'''
27332738
def raise_error_on_constant(x):
27342739
raise ValueError("Unacceptable number: %s" % x)
2740+
if json_string == "":
2741+
self.json_dict = {}
2742+
self.value = None
2743+
return
2744+
27352745
try:
27362746
self.json_dict = json.loads(json_string,
27372747
parse_constant=raise_error_on_constant)
27382748
self.value = [self.FsValue(index, self.json_dict[index])
27392749
for index in self.json_dict]
2740-
except ValueError:
2741-
self.json_dict = {}
2742-
self.value = None
2750+
except (json.decoder.JSONDecodeError, ValueError) as e:
2751+
raise ValueError(e.args[0] + ". JSON is: " + json_string)
2752+
27432753

27442754
class FsValue:
27452755
'''Class that does nothing but response to a .value property '''

share/roundup/templates/classic/detectors/userauditor.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,10 @@ def audit_user_fields(db, cl, nodeid, newvalues):
6868
raise ValueError('Email address syntax is invalid "%s"'%address)
6969

7070
check_main = db.user.stringFind(address=address)
71+
# allow user to set same address via rest
72+
if check_main:
73+
check_main = nodeid not in check_main
74+
7175
# make sure none of the alts are owned by anyone other than us (x!=nodeid)
7276
check_alts = [x for x in db.user.filter(None, {'alternate_addresses' : address}) if x != nodeid]
7377
if check_main or check_alts:

share/roundup/templates/devel/detectors/userauditor.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,10 @@ def audit_user_fields(db, cl, nodeid, newvalues):
6868
raise ValueError('Email address syntax is invalid "%s"'%address)
6969

7070
check_main = db.user.stringFind(address=address)
71+
# allow user to set same address via rest
72+
if check_main:
73+
check_main = nodeid not in check_main
74+
7175
# make sure none of the alts are owned by anyone other than us (x!=nodeid)
7276
check_alts = [x for x in db.user.filter(None, {'alternate_addresses' : address}) if x != nodeid]
7377
if check_main or check_alts:

share/roundup/templates/jinja2/detectors/userauditor.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,10 @@ def audit_user_fields(db, cl, nodeid, newvalues):
6868
raise ValueError('Email address syntax is invalid "%s"'%address)
6969

7070
check_main = db.user.stringFind(address=address)
71+
# allow user to set same address via rest
72+
if check_main:
73+
check_main = nodeid not in check_main
74+
7175
# make sure none of the alts are owned by anyone other than us (x!=nodeid)
7276
check_alts = [x for x in db.user.filter(None, {'alternate_addresses' : address}) if x != nodeid]
7377
if check_main or check_alts:

share/roundup/templates/minimal/detectors/userauditor.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,10 @@ def audit_user_fields(db, cl, nodeid, newvalues):
6868
raise ValueError('Email address syntax is invalid "%s"'%address)
6969

7070
check_main = db.user.stringFind(address=address)
71+
# allow user to set same address via rest
72+
if check_main:
73+
check_main = nodeid not in check_main
74+
7175
# make sure none of the alts are owned by anyone other than us (x!=nodeid)
7276
check_alts = [x for x in db.user.filter(None, {'alternate_addresses' : address}) if x != nodeid]
7377
if check_main or check_alts:

share/roundup/templates/responsive/detectors/userauditor.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,10 @@ def audit_user_fields(db, cl, nodeid, newvalues):
6868
raise ValueError('Email address syntax is invalid "%s"'%address)
6969

7070
check_main = db.user.stringFind(address=address)
71+
# allow user to set same address via rest
72+
if check_main:
73+
check_main = nodeid not in check_main
74+
7175
# make sure none of the alts are owned by anyone other than us (x!=nodeid)
7276
check_alts = [x for x in db.user.filter(None, {'alternate_addresses' : address}) if x != nodeid]
7377
if check_main or check_alts:

test/rest_common.py

Lines changed: 87 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -2427,6 +2427,89 @@ def testDispatchBadAccept(self):
24272427
json_dict = json.loads(b2s(results))
24282428
self.assertIn('Unable to parse Accept Header. Invalid param: foo. Acceptable types: */*, application/json', json_dict['error']['msg'])
24292429

2430+
def testBadJson(self):
2431+
'''Run some JSON we don't accept through the wringer
2432+
'''
2433+
body=b'{ "title": "Joe Doe has problems", \
2434+
"nosy": [ "1", "3" ], \
2435+
"assignedto": "2", \
2436+
"abool": true, \
2437+
"afloat": 2.3, \
2438+
"anint": Infinity }'
2439+
2440+
expected={ "error":
2441+
{"status": 400,
2442+
"msg": ("Unacceptable number: Infinity. JSON is: "
2443+
+ b2s(body)),
2444+
}
2445+
}
2446+
2447+
env = { "CONTENT_TYPE": "application/json",
2448+
"CONTENT_LENGTH": len(body),
2449+
"REQUEST_METHOD": "PUT"
2450+
}
2451+
self.server.client.env.update(env)
2452+
headers={"accept": "application/zot; version=1; q=0.5",
2453+
"content-type": env['CONTENT_TYPE'],
2454+
"content-length": env['CONTENT_LENGTH'],
2455+
}
2456+
2457+
self.headers=headers
2458+
# we need to generate a FieldStorage the looks like
2459+
# FieldStorage(None, None, 'string') rather than
2460+
# FieldStorage(None, None, [])
2461+
body_file=BytesIO(body) # FieldStorage needs a file
2462+
form = client.BinaryFieldStorage(body_file,
2463+
headers=headers,
2464+
environ=env)
2465+
self.server.client.request.headers.get=self.get_header
2466+
results = self.server.dispatch(env["REQUEST_METHOD"],
2467+
"/rest/data/issue/1",
2468+
form)
2469+
2470+
self.assertEqual(json.loads(results), expected)
2471+
2472+
body=b'{ "title": "Joe Doe has problems", \
2473+
nosy: [ "1", "3" ], \
2474+
"assignedto": "2", \
2475+
"abool": true, \
2476+
"afloat": 2.3, \
2477+
"anint": Infinity }'
2478+
self.maxDiff = None
2479+
expected={ "error":
2480+
{"status": 400,
2481+
"msg": ("Expecting property name enclosed in double "
2482+
"quotes: line 1 column 53 (char 52). JSON is: "
2483+
+ b2s(body)),
2484+
}
2485+
}
2486+
2487+
env = { "CONTENT_TYPE": "application/json",
2488+
"CONTENT_LENGTH": len(body),
2489+
"REQUEST_METHOD": "PUT"
2490+
}
2491+
self.server.client.env.update(env)
2492+
headers={"accept": "application/zot; version=1; q=0.5",
2493+
"content-type": env['CONTENT_TYPE'],
2494+
"content-length": env['CONTENT_LENGTH'],
2495+
}
2496+
2497+
self.headers=headers
2498+
# we need to generate a FieldStorage the looks like
2499+
# FieldStorage(None, None, 'string') rather than
2500+
# FieldStorage(None, None, [])
2501+
body_file=BytesIO(body) # FieldStorage needs a file
2502+
form = client.BinaryFieldStorage(body_file,
2503+
headers=headers,
2504+
environ=env)
2505+
self.server.client.request.headers.get=self.get_header
2506+
results = self.server.dispatch(env["REQUEST_METHOD"],
2507+
"/rest/data/issue/1",
2508+
form)
2509+
2510+
self.assertEqual(json.loads(results), expected)
2511+
2512+
24302513
def testStatsGen(self):
24312514
# check stats being returned by put and get ops
24322515
# using dispatch which parses the @stats query param
@@ -2835,27 +2918,20 @@ def testDispatch(self):
28352918
etag = calculate_etag(self.db.issue.getnode("1"),
28362919
self.db.config['WEB_SECRET_KEY'])
28372920
etagb = etag.strip ('"')
2838-
env = {"CONTENT_TYPE": "application/json",
2839-
"CONTENT_LEN": 0,
2840-
"REQUEST_METHOD": "DELETE" }
2921+
env = {"REQUEST_METHOD": "DELETE" }
28412922
self.server.client.env.update(env)
28422923
# use text/plain header and request json output by appending
28432924
# .json to the url.
28442925
headers={"accept": "text/plain",
2845-
"content-type": env['CONTENT_TYPE'],
28462926
"if-match": '"%s"'%etagb,
28472927
"content-length": 0,
28482928
}
28492929
self.headers=headers
2850-
body_file=BytesIO(b'') # FieldStorage needs a file
2851-
form = client.BinaryFieldStorage(body_file,
2852-
headers=headers,
2853-
environ=env)
28542930
self.server.client.request.headers.get=self.get_header
28552931
self.db.setCurrentUser('admin') # must be admin to delete issue
28562932
results = self.server.dispatch('DELETE',
28572933
"/rest/data/issue/1.json",
2858-
form)
2934+
self.empty_form)
28592935
self.assertEqual(self.server.client.response_code, 200)
28602936
print(results)
28612937
json_dict = json.loads(b2s(results))
@@ -3213,14 +3289,15 @@ def testMethodOverride(self):
32133289
"CONTENT_LENGTH": len(body),
32143290
"REQUEST_METHOD": "POST"
32153291
}
3216-
body_file=BytesIO(body) # FieldStorage needs a file
32173292
self.server.client.request.headers.get=self.get_header
32183293
for method in ( "GET", "PUT", "PATCH" ):
32193294
headers={"accept": "application/json; version=1",
32203295
"content-type": env['CONTENT_TYPE'],
32213296
"content-length": len(body),
32223297
"x-http-method-override": "DElETE",
32233298
}
3299+
body_file=BytesIO(body) # FieldStorage needs a file
3300+
32243301
self.headers=headers
32253302
form = client.BinaryFieldStorage(body_file,
32263303
headers=headers,

0 commit comments

Comments
 (0)