def get_participant(request, restrict=True): """Given a Request, raise Response or return Participant. If user is not None then we'll restrict access to owners and admins. """ user = request.context['user'] participant_id = request.line.uri.path['participant_id'] if restrict: if user.ANON: request.redirect(u'/%s/' % participant_id) participant = Participant.query.get(participant_id) if participant is None: raise Response(404) elif participant.claimed_time is None: # This is a stub participant record for someone on another platform who # hasn't actually registered with Gittip yet. Let's bounce the viewer # over to the appropriate platform page. to = participant.resolve_unclaimed() if to is None: raise Response(404) request.redirect(to) if restrict: if participant != user: if not user.ADMIN: raise Response(403) return participant
def get_user_info(username): """Get the given user's information from the DB or failing that, bitbucket. :param username: A unicode string representing a username in bitbucket. :returns: A dictionary containing bitbucket specific information for the user. """ typecheck(username, unicode) rec = gittip.db.fetchone( "SELECT user_info FROM elsewhere " "WHERE platform='bitbucket' " "AND user_info->'username' = %s", (username, )) if rec is not None: user_info = rec['user_info'] else: url = "%s/users/%s?pagelen=100" user_info = requests.get(url % (BASE_API_URL, username)) status = user_info.status_code content = user_info.content if status == 200: user_info = json.loads(content)['user'] elif status == 404: raise Response( 404, "Bitbucket identity '{0}' not found.".format(username)) else: log("Bitbucket api responded with {0}: {1}".format( status, content), level=logging.WARNING) raise Response(502, "Bitbucket lookup failed with %d." % status) return user_info
def cast(path_part, state): """This is an Aspen typecaster. Given a slug and a state dict, raise Response or return Team. """ redirect = state['website'].redirect request = state['request'] user = state['user'] slug = path_part qs = request.line.uri.querystring try: team = Team.from_slug(slug) except: raise Response(400, 'bad slug') if team is None: # Try to redirect to a Participant. from gratipay.models.participant import Participant # avoid circular import participant = Participant.from_username(slug) if participant is not None: qs = '?' + request.qs.raw if request.qs.raw else '' redirect('/~' + request.path.raw[1:] + qs) raise Response(404) canonicalize(redirect, request.line.uri.path.raw, '/', team.slug, slug, qs) if team.is_closed and not user.ADMIN: raise Response(410) return team
def get_account_elsewhere(website, state, api_lookup=True): _ = state['_'] path = state['request'].line.uri.path platform = getattr(website.platforms, path['platform'], None) if platform is None: raise Response(404) uid = path['user_name'] if "\t" in uid or "\r" in uid or "\n" in uid: raise Response(400, _("Invalid character in elsewhere account username.")) if uid[:1] == '~': key = 'user_id' uid = uid[1:] else: key = 'user_name' try: account = AccountElsewhere._from_thing(key, platform.name, uid) except UnknownAccountElsewhere: account = None if not account: if not api_lookup: raise Response(404) try: user_info = platform.get_user_info(key, uid) except Response as r: if r.code == 404: err = _("Account not found on {0}.", platform.display_name) raise Response(404, err) raise account = AccountElsewhere.upsert(user_info) return platform, account
def try_to_serve_304(dispatch_result, request, etag): """Try to serve a 304 for static resources. """ if not etag: # This is a request for a dynamic resource. return qs_etag = request.line.uri.querystring.get('etag') if qs_etag and qs_etag != etag: # Don't serve one version of a file as if it were another. raise Response(410) headers_etag = request.headers.get('If-None-Match') if not headers_etag: # This client doesn't want a 304. return if headers_etag != etag: # Cache miss, the client sent an old or invalid etag. return # Huzzah! # ======= # We can serve a 304! :D raise Response(304)
def get_account_elsewhere(website, state, api_lookup=True): path = state['request'].line.uri.path platform = getattr(website.platforms, path['platform'], None) if platform is None: raise Response(404) uid = path['user_name'] if uid[:1] == '~': key = 'user_id' uid = uid[1:] else: key = 'user_name' try: account = AccountElsewhere._from_thing(key, platform.name, uid) except UnknownAccountElsewhere: account = None if not account: if not api_lookup: raise Response(404) try: user_info = platform.get_user_info(key, uid) except Response as r: if r.code == 404: _ = state['_'] err = _("There doesn't seem to be a user named {0} on {1}.", uid, platform.display_name) raise Response(404, err) raise account = AccountElsewhere.upsert(user_info) return platform, account
def change_username(self, suggested): """Raise Response or return None. We want to be pretty loose with usernames. Unicode is allowed--XXX aspen bug :(. So are spaces.Control characters aren't. We also limit to 32 characters in length. """ for i, c in enumerate(suggested): if i == 32: raise Response(413) # Request Entity Too Large (more or less) elif ord(c) < 128 and c not in ASCII_ALLOWED_IN_USERNAME: raise Response(400) # Yeah, no. elif c not in ASCII_ALLOWED_IN_USERNAME: raise Response(400) # XXX Burned by an Aspen bug. :`-( # https://github.com/whit537/aspen/issues/102 if suggested in gittip.RESTRICTED_USERNAMES: raise Response(400) if suggested != self.username: # Will raise IntegrityError if the desired username is taken. rec = gittip.db.fetchone( "UPDATE participants " "SET username=%s WHERE username=%s " "RETURNING username", (suggested, self.username)) assert rec is not None # sanity check assert suggested == rec['username'] # sanity check self.username = suggested
def get_team(state): """Given a Request, raise Response or return Team. """ redirect = state['website'].redirect request = state['request'] user = state['user'] slug = request.line.uri.path['team'] qs = request.line.uri.querystring from gratipay.models.team import Team # avoid circular import team = Team.from_slug(slug) if team is None: # Try to redirect to a Participant. from gratipay.models.participant import Participant # avoid circular import participant = Participant.from_username(slug) if participant is not None: qs = '?' + request.qs.raw if request.qs.raw else '' redirect('/~' + request.path.raw[1:] + qs) raise Response(404) canonicalize(redirect, request.line.uri.path.raw, '/', team.slug, slug, qs) if team.is_closed and not user.ADMIN: raise Response(410) return team
def change_participant_id(website, old, suggested): """Raise response return None. We want to be pretty loose with usernames. Unicode is allowed. So are spaces. Control characters aren't. We also limit to 32 characters in length. """ for i, c in enumerate(suggested): if i == 32: raise Response(413) # Request Entity Too Large (more or less) elif ord(c) < 128 and c not in ALLOWED_ASCII: raise Response(400) # Yeah, no. elif c not in ALLOWED_ASCII: raise Response(400) # XXX Burned by an Aspen bug. :`-( # https://github.com/whit537/aspen/issues/102 if website is not None and suggested in os.listdir(website.www_root): raise Response(400) if suggested != old: rec = db.fetchone( "UPDATE participants SET id=%s WHERE id=%s " \ "RETURNING id", (suggested, old)) # May raise IntegrityError assert rec is not None # sanity check assert suggested == rec['id'] # sanity check
def get_community(state, restrict=False): request, response = state['request'], state['response'] user = state['user'] name = request.path['name'] c = Community.from_name(name) if request.method in ('GET', 'HEAD'): if not c: response.redirect('/for/new?name=' + urlquote(name)) if c.name != name: response.redirect('/for/' + c.name + request.line.uri[5 + len(name):]) elif not c: raise Response(404) elif user.ANON: raise AuthRequired if restrict: if user.ANON: raise AuthRequired if user.id != c.creator and not user.is_admin: _ = state['_'] raise Response(403, _("You are not authorized to access this page.")) return c
def respond(self, request): """Given a Request, return a Response. """ request.allow('GET', 'POST') if self.state == 0: # The client wants confirmation. response = Response(200, "1:::") self.state = 1 elif request.line.method == 'POST': # The client is sending us data. self.socket._send(request.body.raw) response = Response(200) elif request.line.method == 'GET': # The client is asking for data. bytes_iter = iter([""]) timeout = time.time() + self.timeout while time.time() < timeout: _bytes_iter = self.socket._recv() if _bytes_iter is not None: bytes_iter = _bytes_iter break request.website.network_engine.sleep(0.010) response = Response(200, bytes_iter) return response
def get_participant(state, restrict=True, redirect_stub=True, allow_member=False): """Given a Request, raise Response or return Participant. If restrict is True then we'll restrict access to owners and admins. """ request = state['request'] user = state['user'] slug = request.line.uri.path['username'] _ = state['_'] if restrict and user.ANON: raise AuthRequired if slug.startswith('~'): thing = 'id' value = slug[1:] participant = user if user and str(user.id) == value else None else: thing = 'lower(username)' value = slug.lower() participant = user if user and user.username.lower() == value else None if participant is None: from liberapay.models.participant import Participant # avoid circular import participant = Participant._from_thing(thing, value) if value else None if participant is None or participant.kind == 'community': raise Response(404) if request.method in ('GET', 'HEAD'): if slug != participant.username: canon = '/' + participant.username + request.line.uri[len(slug) + 1:] raise Response(302, headers={'Location': canon}) status = participant.status if status == 'closed': if user.is_admin: return participant raise Response(410) elif status == 'stub': if redirect_stub: to = participant.resolve_stub() assert to raise Response(302, headers={'Location': to}) if restrict: if participant != user: if allow_member and participant.kind == 'group' and user.member_of( participant): pass elif not user.is_admin: raise Response( 403, _("You are not authorized to access this page.")) return participant
def get_csrf_token_from_request(request): """Given a Request object, reject it if it's a forgery. """ if request.line.uri.startswith('/assets/'): return if request.line.uri.startswith('/callbacks/'): return try: csrf_token = _sanitize_token( request.headers.cookie['csrf_token'].value) except KeyError: csrf_token = None request.context['csrf_token'] = csrf_token or _get_new_csrf_key() # Assume that anything not defined as 'safe' by RC2616 needs protection if request.line.method not in ('GET', 'HEAD', 'OPTIONS', 'TRACE'): if _is_secure(request): # Suppose user visits http://example.com/ # An active network attacker (man-in-the-middle, MITM) sends a # POST form that targets https://example.com/detonate-bomb/ and # submits it via JavaScript. # # The attacker will need to provide a CSRF cookie and token, but # that's no problem for a MITM and the session-independent # nonce we're using. So the MITM can circumvent the CSRF # protection. This is true for any HTTP connection, but anyone # using HTTPS expects better! For this reason, for # https://example.com/ we need additional protection that treats # http://example.com/ as completely untrusted. Under HTTPS, # Barth et al. found that the Referer header is missing for # same-domain requests in only about 0.2% of cases or less, so # we can use strict Referer checking. referer = request.headers.get('Referer') if referer is None: raise Response(403, REASON_NO_REFERER) good_referer = 'https://%s/' % _get_host(request) if not same_origin(referer, good_referer): reason = REASON_BAD_REFERER % (referer, good_referer) log_dammit(reason) raise Response(403, reason) if csrf_token is None: raise Response(403, REASON_NO_CSRF_COOKIE) # Check non-cookie token for match. request_csrf_token = "" if request.line.method == "POST": if isinstance(request.body, dict): request_csrf_token = request.body.get('csrf_token', '') if request_csrf_token == "": # Fall back to X-CSRF-TOKEN, to make things easier for AJAX, # and possible for PUT/DELETE. request_csrf_token = request.headers.get('X-CSRF-TOKEN', '') if not constant_time_compare(request_csrf_token, csrf_token): raise Response(403, REASON_BAD_TOKEN)
def cast(path_part, state): """This is an Aspen typecaster. Given an id and a state dict, raise Response or return PaymentForOpenSource. """ try: pfos = PaymentForOpenSource.from_id(path_part) except: raise Response(404) if pfos is None: raise Response(404) return pfos
def test_get_response_doesnt_reset_content_type_when_negotiating(mk): mk(('index.spt', NEGOTIATED_RESOURCE)) request = StubRequest.from_fs('index.spt') request.headers['Accept'] = 'text/html' response = Response() response.headers['Content-Type'] = 'never/mind' actual = get_response(request, response).headers['Content-Type'] response = Response() response.headers['Content-Type'] = 'never/mind' actual = get_response(request, response).headers['Content-Type'] assert actual == "never/mind"
def api_get(self, path, sess=None, **kw): """ Given a `path` (e.g. /users/foo), this function sends a GET request to the platform's API (e.g. https://api.github.com/users/foo). The response is returned, after checking its status code and ratelimit headers. """ url = self.api_url + path is_user_session = bool(sess) if not sess: sess = self.get_auth_session() if self.name == 'github': url += '?' if '?' not in url else '&' url += 'client_id=%s&client_secret=%s' % (self.api_key, self.api_secret) response = sess.get(url, **kw) limit, remaining, reset = self.get_ratelimit_headers(response) if not is_user_session: self.log_ratelimit_headers(limit, remaining, reset) # Check response status status = response.status_code if status == 401 and isinstance(self, PlatformOAuth1): # https://tools.ietf.org/html/rfc5849#section-3.2 if is_user_session: raise TokenExpiredError raise Response(500) if status == 404: raise Response(404, response.text) if status == 429 and is_user_session: def msg(_, to_age): if remaining == 0 and reset: return _( "You've consumed your quota of requests, you can try again in {0}.", to_age(reset)) else: return _( "You're making requests too fast, please try again later." ) raise LazyResponse(status, msg) if status != 200: log('{} api responded with {}:\n{}'.format(self.name, status, response.text), level=logging.ERROR) msg = lambda _: _("{0} returned an error, please try again later.", self.display_name) raise LazyResponse(502, msg) return response
def check_api_response_status(self, response): """Pass through any 404, convert any other non-200 into a 500. """ status = response.status_code if status == 404: raise Response(404, response.text) elif status != 200: log('{} api responded with {}:\n{}'.format(self.name, status, response.text), level=logging.ERROR) raise Response( 500, '{} lookup failed with {}'.format(self.name, status))
def api_get(self, path, sess=None, **kw): """ Given a `path` (e.g. /users/foo), this function sends a GET request to the platform's API (e.g. https://api.github.com/users/foo). The response is returned, after checking its status code and ratelimit headers. """ if not sess: sess = self.get_auth_session() response = sess.get(self.api_url + path, **kw) # Check status status = response.status_code if status == 404: raise Response(404) elif status != 200: log('{} api responded with {}:\n{}'.format(self.name, status, response.text), level=logging.ERROR) raise Response( 500, '{} lookup failed with {}'.format(self.name, status)) # Check ratelimit headers prefix = getattr(self, 'ratelimit_headers_prefix', None) if prefix: limit = response.headers[prefix + 'limit'] remaining = response.headers[prefix + 'remaining'] reset = response.headers[prefix + 'reset'] try: limit, remaining, reset = int(limit), int(remaining), int( reset) except (TypeError, ValueError): d = dict(limit=limit, remaining=remaining, reset=reset) log('Got weird rate headers from %s: %s' % (self.name, d)) else: percent_remaining = remaining / limit if percent_remaining < 0.5: reset = to_age(datetime.fromtimestamp(reset, tz=utc)) log_msg = ( '{0} API: {1:.1%} of ratelimit has been consumed, ' '{2} requests remaining, resets {3}.').format( self.name, 1 - percent_remaining, remaining, reset) log_lvl = logging.WARNING if percent_remaining < 0.2: log_lvl = logging.ERROR elif percent_remaining < 0.05: log_lvl = logging.CRITICAL log(log_msg, log_lvl) return response
def get_participant(state, restrict=True, resolve_unclaimed=True): """Given a Request, raise Response or return Participant. If restrict is True then we'll restrict access to owners and admins. """ redirect = state['website'].redirect request = state['request'] user = state['user'] slug = request.line.uri.path['username'] qs = request.line.uri.querystring _ = state['_'] if restrict: if user.ANON: raise Response(401, _("You need to log in to access this page.")) from gratipay.models.participant import Participant # avoid circular import participant = Participant.from_username(slug) if participant is None: raise Response(404) canonicalize(redirect, request.line.uri.path.raw, '/~/', participant.username, slug, qs) if participant.is_closed: if user.ADMIN: return participant raise Response(410) if participant.claimed_time is None and resolve_unclaimed: to = participant.resolve_unclaimed() if to: # This is a stub account (someone on another platform who hasn't # actually registered with Gratipay yet) redirect(to) else: # This is an archived account (result of take_over) if user.ADMIN: return participant raise Response(404) if restrict: if participant != user.participant: if not user.ADMIN: raise Response( 403, _("You are not authorized to access this page.")) return participant
def export_history(participant, year, key, back_as='namedtuple', require_key=False): db = participant.db params = dict(username=participant.username, year=year) out = {} out['given'] = lambda: db.all(""" SELECT CONCAT('~', tippee) as tippee, sum(amount) AS amount FROM transfers WHERE tipper = %(username)s AND extract(year from timestamp) = %(year)s GROUP BY tippee UNION SELECT team as tippee, sum(amount) AS amount FROM payments WHERE participant = %(username)s AND direction = 'to-team' AND extract(year from timestamp) = %(year)s GROUP BY tippee """, params, back_as=back_as) # FIXME: Include values from the `payments` table out['taken'] = lambda: db.all(""" SELECT tipper AS team, sum(amount) AS amount FROM transfers WHERE tippee = %(username)s AND context = 'take' AND extract(year from timestamp) = %(year)s GROUP BY tipper """, params, back_as=back_as) if key: try: return out[key]() except KeyError: raise Response(400, "bad key `%s`" % key) elif require_key: raise Response(400, "missing `key` parameter") else: return {k: v() for k, v in out.items()}
def _typecast(key, value): """Given two strings, return a string, and an int or string. """ if key.endswith('.int'): # you can typecast to int key = key[:-4] try: value = int(value) except ValueError: raise Response(404) else: # otherwise it's ASCII try: value = value.decode('ASCII') except UnicodeDecodeError: raise Response(400) return key, value
def sign_in_with_form_data(body, state): p = None _, website = state['_'], state['website'] if body.get('log-in.id'): id = body.pop('log-in.id') k = 'email' if '@' in id else 'username' p = Participant.authenticate(k, 'password', id, body.pop('log-in.password')) if not p: state['sign-in.error'] = _("Bad username or password.") if p and p.status == 'closed': p.update_status('active') elif body.get('sign-in.username'): if body.pop('sign-in.terms') != 'agree': raise Response(400, 'you have to agree to the terms') kind = body.pop('sign-in.kind') if kind not in ('individual', 'organization'): raise Response(400, 'bad kind') with website.db.get_cursor() as c: p = Participant.make_active(body.pop('sign-in.username'), kind, body.pop('sign-in.password'), cursor=c) p.add_email(body.pop('sign-in.email'), cursor=c) p.authenticated = True elif body.get('email-login.email'): email = body.pop('email-login.email') p = Participant._from_thing('email', email) if p: p.start_session() qs = {'log-in.id': p.id, 'log-in.token': p.session_token} p.send_email( 'password_reset', email=email, link=p.url('settings/', qs), link_validity=SESSION_TIMEOUT, ) state['email-login.sent-to'] = email else: state['sign-in.error'] = _( "We didn't find any account whose primary email address is {0}.", email) p = None return p
def test_get_response_406_gives_list_of_acceptable_types(mk): mk(('index.spt', NEGOTIATED_RESOURCE)) request = StubRequest.from_fs('index.spt') request.headers['Accept'] = 'cheese/head' actual = raises(Response, get_response, request, Response()).value.body expected = "The following media types are available: text/plain, text/html." assert actual == expected
def api_error_handler(self, response, is_user_session): status = response.status_code if status == 404: raise Response(404, response.text) if status == 429 and is_user_session: limit, remaining, reset = self.get_ratelimit_headers(response) def msg(_, to_age): if remaining == 0 and reset: return _( "You've consumed your quota of requests, you can try again in {0}.", to_age(reset)) else: return _( "You're making requests too fast, please try again later." ) raise LazyResponse(status, msg) if status != 200: log('{} api responded with {}:\n{}'.format(self.name, status, response.text), level=logging.ERROR) msg = lambda _: _("{0} returned an error, please try again later.", self.display_name) raise LazyResponse(502, msg)
def get_query_id(self, querystring): token = querystring['access_token'] i = token.rfind('.') data, data_hash = token[:i], token[i + 1:] if data_hash != hashlib.md5(data + '.' + self.api_secret).hexdigest(): raise Response(400, 'Invalid hash in access_token') return querystring['query_id']
def test_response_headers_protect_against_crlf_injection(): response = Response() def inject(): response.headers['Location'] = 'foo\r\nbar' raises(CRLFInjection, inject)
def outbound(response): from gittip import db session = {} if 'user' in response.request.context: user = response.request.context['user'] if not isinstance(user, User): raise Response( 400, "If you define 'user' in a simplate it has to " "be a User instance.") session = user.session if not session: # user is anonymous if 'session' not in response.request.headers.cookie: # no cookie in the request, don't set one on response return else: # expired cookie in the request, instruct browser to delete it response.headers.cookie['session'] = '' expires = 0 else: # user is authenticated response.headers['Expires'] = BEGINNING_OF_EPOCH # don't cache response.headers.cookie['session'] = session['session_token'] expires = session['session_expires'] = time.time() + TIMEOUT SQL = """ UPDATE participants SET session_expires=%s WHERE session_token=%s """ db.execute(SQL, (datetime.datetime.fromtimestamp(expires), session['session_token'])) cookie = response.headers.cookie['session'] # I am not setting domain, because it is supposed to default to what we # want: the domain of the object requested. #cookie['domain'] cookie['path'] = '/' cookie['expires'] = rfc822.formatdate(expires) cookie['httponly'] = "Yes, please."
def test_handles_busted_accept(mk): mk(('index.spt', NEGOTIATED_RESOURCE)) request = StubRequest.from_fs('index.spt') # Set an invalid Accept header so it will return default (text/plain) request.headers['Accept'] = 'text/html;' actual = get_response(request, Response()).body assert actual == "Greetings, program!\n"
def rebuild_url(self): """Return a full URL for this request, per PEP 333: http://www.python.org/dev/peps/pep-0333/#url-reconstruction This function is kind of naive. """ # http://docs.python.org/library/wsgiref.html#wsgiref.util.guess_scheme scheme = self.headers.one('HTTPS') and 'https' or 'http' url = scheme url += '://' if 'X-Forwarded-Host' in self.headers: url += self.headers.one('X-Forwarded-Host') elif 'Host' in self.headers: url += self.headers.one('Host') else: # per spec, return 400 if no Host header given raise Response(400) url += urllib.quote(self.path.raw) # screw params, fragment? if self.raw_querystring: url += '?' + self.raw_querystring return url
def test_can_override_default_renderer_entirely(mk): mk(('.aspen/configure-aspen.py', OVERRIDE_SIMPLATE), ('index.spt', NEGOTIATED_RESOURCE)) request = StubRequest.from_fs('index.spt') request.headers['Accept'] = 'text/plain' actual = get_response(request, Response()).body assert actual == "glubber"