comparison roundup/cgi/client.py @ 7814:9adf37c63b56

chore(refactor): use with open, consolidate/un-nest if ... Various cleanups: use 'with open(...)' rather than 'try: .. finally' to close file. use "return ..." rather than "x = ...; return x" Change if/else to if ternaries Swap ' for " to prevent escaping nested " Add lint silencing comments Fix bare return that needs to be return None.
author John Rouillard <rouilj@ieee.org>
date Sat, 16 Mar 2024 22:57:19 -0400
parents 928c20d4344b
children f11c982f01c8
comparison
equal deleted inserted replaced
7813:928c20d4344b 7814:9adf37c63b56
440 # PATCH verbs. They are processed like POST. So FieldStorage 440 # PATCH verbs. They are processed like POST. So FieldStorage
441 # hangs on these verbs trying to read posted data that 441 # hangs on these verbs trying to read posted data that
442 # will never arrive. 442 # will never arrive.
443 # If not defined, set CONTENT_LENGTH to 0 so it doesn't 443 # If not defined, set CONTENT_LENGTH to 0 so it doesn't
444 # hang reading the data. 444 # hang reading the data.
445 if self.env['REQUEST_METHOD'] in ['OPTIONS', 'DELETE', 'PATCH']: 445 if self.env['REQUEST_METHOD'] in ['OPTIONS', 'DELETE', 'PATCH'] \
446 if 'CONTENT_LENGTH' not in self.env: 446 and 'CONTENT_LENGTH' not in self.env:
447 self.env['CONTENT_LENGTH'] = 0 447 self.env['CONTENT_LENGTH'] = 0
448 logger.debug("Setting CONTENT_LENGTH to 0 for method: %s", 448 logger.debug("Setting CONTENT_LENGTH to 0 for method: %s",
449 self.env['REQUEST_METHOD']) 449 self.env['REQUEST_METHOD'])
450 450
451 # cgi.FieldStorage must save all data as 451 # cgi.FieldStorage must save all data as
452 # binary/bytes. Subclass BinaryFieldStorage does this. 452 # binary/bytes. Subclass BinaryFieldStorage does this.
453 # It's a workaround for a bug in cgi.FieldStorage. See class 453 # It's a workaround for a bug in cgi.FieldStorage. See class
454 # def for details. 454 # def for details.
494 self._ok_message = [] 494 self._ok_message = []
495 self._error_message = [] 495 self._error_message = []
496 496
497 def _gen_nonce(self): 497 def _gen_nonce(self):
498 """ generate a unique nonce """ 498 """ generate a unique nonce """
499 n = b2s(base64.b32encode(random_.token_bytes(40))) 499 return b2s(base64.b32encode(random_.token_bytes(40)))
500 return n
501 500
502 def setTranslator(self, translator=None): 501 def setTranslator(self, translator=None):
503 """Replace the translation engine 502 """Replace the translation engine
504 503
505 'translator' 504 'translator'
692 # because handle_rest is called. Preflight requests 691 # because handle_rest is called. Preflight requests
693 # are unauthenticated, so no need to check permissions. 692 # are unauthenticated, so no need to check permissions.
694 if (self.is_cors_preflight()): 693 if (self.is_cors_preflight()):
695 self.handle_preflight() 694 self.handle_preflight()
696 return 695 return
697 elif not self.db.security.hasPermission('Rest Access', self.userid): 696
697 if not self.db.security.hasPermission('Rest Access', self.userid):
698 output = s2b('{ "error": { "status": 403, "msg": "Forbidden." } }') 698 output = s2b('{ "error": { "status": 403, "msg": "Forbidden." } }')
699 self.reject_request(output, 699 self.reject_request(output,
700 message_type="application/json", 700 message_type="application/json",
701 status=403) 701 status=403)
702 return 702 return
809 except (UsageError, Unauthorised) as msg: 809 except (UsageError, Unauthorised) as msg:
810 csrf_ok = False 810 csrf_ok = False
811 self.form_wins = True 811 self.form_wins = True
812 self._error_message = msg.args 812 self._error_message = msg.args
813 813
814 if csrf_ok: 814 # If csrf checks pass. Run actions etc.
815 # csrf checks pass. Run actions etc. 815 # handle_action() may handle a form submit action.
816 # possibly handle a form submit action (may change 816 # It can change self.classname and self.template,
817 # self.classname and self.template, and may also 817 # and may also append error/ok_messages.
818 # append error/ok_messages) 818 html = self.handle_action() if csrf_ok else None
819 html = self.handle_action()
820 else:
821 html = None
822 819
823 if html: 820 if html:
824 self.write_html(html) 821 self.write_html(html)
825 return 822 return
826 823
881 # browser to prompt the user again. 878 # browser to prompt the user again.
882 if self.instance.config.WEB_HTTP_AUTH: 879 if self.instance.config.WEB_HTTP_AUTH:
883 self.response_code = http_.client.UNAUTHORIZED 880 self.response_code = http_.client.UNAUTHORIZED
884 realm = self.instance.config.TRACKER_NAME 881 realm = self.instance.config.TRACKER_NAME
885 self.setHeader("WWW-Authenticate", 882 self.setHeader("WWW-Authenticate",
886 "Basic realm=\"%s\"" % realm) 883 'Basic realm="%s"' % realm)
887 else: 884 else:
888 self.response_code = http_.client.FORBIDDEN 885 self.response_code = http_.client.FORBIDDEN
889 self.renderFrontPage(str(message)) 886 self.renderFrontPage(str(message))
890 except Unauthorised as message: 887 except Unauthorised as message:
891 # users may always see the front page 888 # users may always see the front page
1039 encoder = codecs.getencoder(self.STORAGE_CHARSET) 1036 encoder = codecs.getencoder(self.STORAGE_CHARSET)
1040 re_charref = re.compile('&#([0-9]+|x[0-9a-f]+);', re.IGNORECASE) 1037 re_charref = re.compile('&#([0-9]+|x[0-9a-f]+);', re.IGNORECASE)
1041 1038
1042 def _decode_charref(matchobj): 1039 def _decode_charref(matchobj):
1043 num = matchobj.group(1) 1040 num = matchobj.group(1)
1044 if num[0].lower() == 'x': 1041 uc = int(num[1:], 16) if num[0].lower() == 'x' else int(num)
1045 uc = int(num[1:], 16)
1046 else:
1047 uc = int(num)
1048 return uchr(uc) 1042 return uchr(uc)
1049 1043
1050 for field_name in self.form: 1044 for field_name in self.form:
1051 field = self.form[field_name] 1045 field = self.form[field_name]
1052 if (field.type == 'text/plain') and not field.filename: 1046 if (field.type == 'text/plain') and not field.filename:
1233 if r.lower() not in all_rolenames: 1227 if r.lower() not in all_rolenames:
1234 raise LoginError("Token roles are invalid.") 1228 raise LoginError("Token roles are invalid.")
1235 1229
1236 # will be used later to override the get_roles method 1230 # will be used later to override the get_roles method
1237 # having it defined as truthy allows it to be used. 1231 # having it defined as truthy allows it to be used.
1238 override_get_roles = lambda self: iter_roles( # noqa: E731 1232 override_get_roles = lambda self: iter_roles( # noqa: ARG005
1239 ','.join(token['roles'])) 1233 ','.join(token['roles']))
1240 1234
1241 # if user was not set by http authorization, try session lookup 1235 # if user was not set by http authorization, try session lookup
1242 if not user: 1236 if not user:
1243 user = self.session_api.get('user') 1237 user = self.session_api.get('user')
1289 pass 1283 pass
1290 if isinstance(action, list): 1284 if isinstance(action, list):
1291 raise SeriousError( 1285 raise SeriousError(
1292 self._('broken form: multiple @action values submitted')) 1286 self._('broken form: multiple @action values submitted'))
1293 elif action != '': 1287 elif action != '':
1288 # '' is value when no action parameter was found so run
1289 # this to extract action string value when action found.
1294 action = action.value.lower() 1290 action = action.value.lower()
1295 if action in ('login', 'register'): 1291 if action in ('login', 'register'):
1296 return 1292 return
1297 1293
1298 # allow Anonymous to view the "user" "register" template if they're 1294 # allow Anonymous to view the "user" "register" template if they're
1300 if (self.db.security.hasPermission('Register', self.userid, 'user') 1296 if (self.db.security.hasPermission('Register', self.userid, 'user')
1301 and self.classname == 'user' and self.template == 'register'): 1297 and self.classname == 'user' and self.template == 'register'):
1302 return 1298 return
1303 1299
1304 # otherwise for everything else 1300 # otherwise for everything else
1305 if self.user == 'anonymous': 1301 if self.user == 'anonymous' and \
1306 if not self.db.security.hasPermission('Web Access', self.userid): 1302 not self.db.security.hasPermission('Web Access', self.userid):
1307 raise Unauthorised(self._("Anonymous users are not " 1303 raise Unauthorised(self._("Anonymous users are not "
1308 "allowed to use the web interface")) 1304 "allowed to use the web interface"))
1309 1305
1310 def is_origin_header_ok(self, api=False, credentials=False): 1306 def is_origin_header_ok(self, api=False, credentials=False):
1311 """Determine if origin is valid for the context 1307 """Determine if origin is valid for the context
1324 """ 1320 """
1325 1321
1326 try: 1322 try:
1327 origin = self.env['HTTP_ORIGIN'] 1323 origin = self.env['HTTP_ORIGIN']
1328 except KeyError: 1324 except KeyError:
1329 if self.env['REQUEST_METHOD'] == 'GET': 1325 return self.env['REQUEST_METHOD'] == 'GET'
1330 return True
1331 else:
1332 return False
1333 1326
1334 # note base https://host/... ends host with with a /, 1327 # note base https://host/... ends host with with a /,
1335 # so add it to origin. 1328 # so add it to origin.
1336 foundat = self.base.find(origin + '/') 1329 foundat = self.base.find(origin + '/')
1337 if foundat == 0: 1330 if foundat == 0:
1476 # same for example) so we don't include here. 1469 # same for example) so we don't include here.
1477 header_names = [ 1470 header_names = [
1478 "ORIGIN", 1471 "ORIGIN",
1479 "REFERER", 1472 "REFERER",
1480 "X-FORWARDED-HOST", 1473 "X-FORWARDED-HOST",
1481 "HOST" 1474 "HOST",
1482 ] 1475 ]
1483 1476
1484 header_pass = 0 # count of passing header checks 1477 header_pass = 0 # count of passing header checks
1485 1478
1486 # If required headers are missing, raise an error 1479 # If required headers are missing, raise an error
1579 if header_pass < enforce: 1572 if header_pass < enforce:
1580 logger.error(self._("Csrf: unable to verify sufficient headers")) 1573 logger.error(self._("Csrf: unable to verify sufficient headers"))
1581 raise UsageError(self._("Unable to verify sufficient headers")) 1574 raise UsageError(self._("Unable to verify sufficient headers"))
1582 1575
1583 enforce = config['WEB_CSRF_ENFORCE_HEADER_X-REQUESTED-WITH'] 1576 enforce = config['WEB_CSRF_ENFORCE_HEADER_X-REQUESTED-WITH']
1584 if api: 1577 if api and enforce in ['required', 'yes']:
1585 if enforce in ['required', 'yes']: 1578 # if we get here we have usually passed at least one
1586 # if we get here we have usually passed at least one 1579 # header check. We check for presence of this custom
1587 # header check. We check for presence of this custom 1580 # header for xmlrpc/rest calls only.
1588 # header for xmlrpc/rest calls only. 1581 # E.G. X-Requested-With: XMLHttpRequest
1589 # E.G. X-Requested-With: XMLHttpRequest 1582 # Note we do not use CSRF nonces for xmlrpc/rest requests.
1590 # Note we do not use CSRF nonces for xmlrpc/rest requests. 1583 #
1591 # 1584 # see: https://www.owasp.org/index.php/Cross-Site_Request_Forgery_(CSRF)_Prevention_Cheat_Sheet#Protecting_REST_Services:_Use_of_Custom_Request_Headers
1592 # see: https://www.owasp.org/index.php/Cross-Site_Request_Forgery_(CSRF)_Prevention_Cheat_Sheet#Protecting_REST_Services:_Use_of_Custom_Request_Headers 1585 if 'HTTP_X_REQUESTED_WITH' not in self.env:
1593 if 'HTTP_X_REQUESTED_WITH' not in self.env: 1586 logger.error(self._(
1594 logger.error(self._( 1587 ''"csrf X-REQUESTED-WITH xmlrpc required header "
1595 ''"csrf X-REQUESTED-WITH xmlrpc required header " 1588 ''"check failed for user%s."),
1596 ''"check failed for user%s."), 1589 current_user)
1597 current_user) 1590 raise UsageError(self._("Required Header Missing"))
1598 raise UsageError(self._("Required Header Missing"))
1599 1591
1600 # Expire old csrf tokens now so we don't use them. These will 1592 # Expire old csrf tokens now so we don't use them. These will
1601 # be committed after the otks.destroy below. Note that the 1593 # be committed after the otks.destroy below. Note that the
1602 # self.clean_up run as part of determine_user() will run only 1594 # self.clean_up run as part of determine_user() will run only
1603 # once an hour. If we have short lived (e.g. 5 minute) keys 1595 # once an hour. If we have short lived (e.g. 5 minute) keys
1701 1693
1702 # open the database or only set the user 1694 # open the database or only set the user
1703 if not hasattr(self, 'db'): 1695 if not hasattr(self, 'db'):
1704 self.db = self.instance.open(username) 1696 self.db = self.instance.open(username)
1705 self.db.tx_Source = "web" 1697 self.db.tx_Source = "web"
1706 else: 1698 elif self.instance.optimize:
1707 if self.instance.optimize:
1708 self.db.setCurrentUser(username) 1699 self.db.setCurrentUser(username)
1709 self.db.tx_Source = "web" 1700 self.db.tx_Source = "web"
1710 else: 1701 else:
1711 self.db.close() 1702 self.db.close()
1712 self.db = self.instance.open(username) 1703 self.db = self.instance.open(username)
1713 self.db.tx_Source = "web" 1704 self.db.tx_Source = "web"
1714 # The old session API refers to the closed database; 1705 # The old session API refers to the closed database;
1715 # we can no longer use it. 1706 # we can no longer use it.
1716 self.session_api = Session(self) 1707 self.session_api = Session(self)
1717 1708
1718 # match designator in URL stripping leading 0's. So: 1709 # match designator in URL stripping leading 0's. So:
1719 # https://issues.roundup-tracker.org/issue002551190 is the same as 1710 # https://issues.roundup-tracker.org/issue002551190 is the same as
1720 # https://issues.roundup-tracker.org/issue2551190 1711 # https://issues.roundup-tracker.org/issue2551190
1721 # Note: id's are strings not numbers so "02" != "2" but 02 == 2 1712 # Note: id's are strings not numbers so "02" != "2" but 02 == 2
1791 if template_override is not None: 1782 if template_override is not None:
1792 self.template = template_override 1783 self.template = template_override
1793 else: 1784 else:
1794 self.template = '' 1785 self.template = ''
1795 return 1786 return
1796 elif path[0] in ('_file', '@@file'): 1787 if path[0] in ('_file', '@@file'):
1797 raise SendStaticFile(os.path.join(*path[1:])) 1788 raise SendStaticFile(os.path.join(*path[1:]))
1798 else: 1789 else:
1799 self.classname = path[0] 1790 self.classname = path[0]
1800 if len(path) > 1: 1791 if len(path) > 1:
1801 # send the file identified by the designator in path[0] 1792 # send the file identified by the designator in path[0]
1971 1962
1972 # detemine meta-type 1963 # detemine meta-type
1973 file = str(file) 1964 file = str(file)
1974 mime_type = mimetypes.guess_type(file)[0] 1965 mime_type = mimetypes.guess_type(file)[0]
1975 if not mime_type: 1966 if not mime_type:
1976 if file.endswith('.css'): 1967 mime_type = 'text/css' if file.endswith('.css') else 'text/plain'
1977 mime_type = 'text/css'
1978 else:
1979 mime_type = 'text/plain'
1980 1968
1981 # get filename: given a/b/c.js extract c.js 1969 # get filename: given a/b/c.js extract c.js
1982 fn = file.rpartition("/")[2] 1970 fn = file.rpartition("/")[2]
1983 if fn in self.Cache_Control: 1971 if fn in self.Cache_Control:
1984 # if filename matches, don't use cache control 1972 # if filename matches, don't use cache control
2077 # right one to the view parameter. If we don't have alternate 2065 # right one to the view parameter. If we don't have alternate
2078 # templates, just leave view alone. 2066 # templates, just leave view alone.
2079 if (view and view.find('|') != -1): 2067 if (view and view.find('|') != -1):
2080 # we have alternate templates, parse them apart. 2068 # we have alternate templates, parse them apart.
2081 (oktmpl, errortmpl) = view.split("|", 2) 2069 (oktmpl, errortmpl) = view.split("|", 2)
2082 if self._error_message: 2070
2083 # we have an error, use errortmpl 2071 # Choose the right template
2084 view = errortmpl 2072 view = errortmpl if self._error_message else oktmpl
2085 else:
2086 # no error message recorded, use oktmpl
2087 view = oktmpl
2088 2073
2089 loader = self.instance.templates 2074 loader = self.instance.templates
2090 2075
2091 # if classname is not set, use "home" template 2076 # if classname is not set, use "home" template
2092 if name is None: 2077 if name is None:
2133 tplname = self.selectTemplate(self.classname, self.template) 2118 tplname = self.selectTemplate(self.classname, self.template)
2134 2119
2135 # catch errors so we can handle PT rendering errors more nicely 2120 # catch errors so we can handle PT rendering errors more nicely
2136 args = { 2121 args = {
2137 'ok_message': self._ok_message, 2122 'ok_message': self._ok_message,
2138 'error_message': self._error_message 2123 'error_message': self._error_message,
2139 } 2124 }
2140 pt = self.instance.templates.load(tplname) 2125 pt = self.instance.templates.load(tplname)
2141 # let the template render figure stuff out 2126 # let the template render figure stuff out
2142 try: 2127 try:
2143 result = pt.render(self, None, None, **args) 2128 result = pt.render(self, None, None, **args)
2187 # receive an error message, and the adminstrator will 2172 # receive an error message, and the adminstrator will
2188 # receive a traceback, albeit with less information 2173 # receive a traceback, albeit with less information
2189 # than the one we tried to generate above. 2174 # than the one we tried to generate above.
2190 if sys.version_info[0] > 2: 2175 if sys.version_info[0] > 2:
2191 raise exc_info[0](exc_info[1]).with_traceback(exc_info[2]) 2176 raise exc_info[0](exc_info[1]).with_traceback(exc_info[2])
2192 else: 2177 exec('raise exc_info[0], exc_info[1], exc_info[2]') # nosec
2193 exec('raise exc_info[0], exc_info[1], exc_info[2]') # nosec
2194 2178
2195 def renderError(self, error, response_code=400, use_template=True): 2179 def renderError(self, error, response_code=400, use_template=True):
2196 self.response_code = response_code 2180 self.response_code = response_code
2197 2181
2198 # see if error message already logged add if not 2182 # see if error message already logged add if not
2220 # response. 2204 # response.
2221 return str(SeriousError(error)) 2205 return str(SeriousError(error))
2222 2206
2223 args = { 2207 args = {
2224 'ok_message': self._ok_message, 2208 'ok_message': self._ok_message,
2225 'error_message': self._error_message 2209 'error_message': self._error_message,
2226 } 2210 }
2227 2211
2228 try: 2212 try:
2229 pt = self.instance.templates.load(tplname) 2213 pt = self.instance.templates.load(tplname)
2230 return pt.render(self, None, None, **args) 2214 return pt.render(self, None, None, **args)
2571 or not if_range.endswith('"')): 2555 or not if_range.endswith('"')):
2572 return None 2556 return None
2573 # If the condition doesn't match the entity tag, then we 2557 # If the condition doesn't match the entity tag, then we
2574 # must send the client the entire file. 2558 # must send the client the entire file.
2575 if if_range != etag: 2559 if if_range != etag:
2576 return 2560 return None
2577 # The grammar for the Range header value is: 2561 # The grammar for the Range header value is:
2578 # 2562 #
2579 # ranges-specifier = byte-ranges-specifier 2563 # ranges-specifier = byte-ranges-specifier
2580 # byte-ranges-specifier = bytes-unit "=" byte-range-set 2564 # byte-ranges-specifier = bytes-unit "=" byte-range-set
2581 # byte-range-set = 1#( byte-range-spec | suffix-byte-range-spec ) 2565 # byte-range-set = 1#( byte-range-spec | suffix-byte-range-spec )
2759 if hasattr(self.request, "sendfile"): 2743 if hasattr(self.request, "sendfile"):
2760 self.header() 2744 self.header()
2761 self._socket_op(self.request.sendfile, filename, offset, length) 2745 self._socket_op(self.request.sendfile, filename, offset, length)
2762 return 2746 return
2763 # Fallback to the "write" operation. 2747 # Fallback to the "write" operation.
2764 f = open(filename, 'rb') 2748 with open(filename, 'rb') as f:
2765 try:
2766 if offset: 2749 if offset:
2767 f.seek(offset) 2750 f.seek(offset)
2768 content = f.read(length) 2751 content = f.read(length)
2769 finally:
2770 f.close()
2771 self.write(content) 2752 self.write(content)
2772 2753
2773 def setHeader(self, header, value): 2754 def setHeader(self, header, value):
2774 """Override or delete a header to be returned to the user's browser. 2755 """Override or delete a header to be returned to the user's browser.
2775 """ 2756 """

Roundup Issue Tracker: http://roundup-tracker.org/