##// END OF EJS Templates
auth: don't trust clients too much - only trust the *last* IP in the X-Forwarded-For header...
Mads Kiilerich -
r8678:f08fbf42 default
parent child Browse files
Show More
@@ -1,639 +1,641 b''
1 # -*- coding: utf-8 -*-
1 # -*- coding: utf-8 -*-
2 # This program is free software: you can redistribute it and/or modify
2 # This program is free software: you can redistribute it and/or modify
3 # it under the terms of the GNU General Public License as published by
3 # it under the terms of the GNU General Public License as published by
4 # the Free Software Foundation, either version 3 of the License, or
4 # the Free Software Foundation, either version 3 of the License, or
5 # (at your option) any later version.
5 # (at your option) any later version.
6 #
6 #
7 # This program is distributed in the hope that it will be useful,
7 # This program is distributed in the hope that it will be useful,
8 # but WITHOUT ANY WARRANTY; without even the implied warranty of
8 # but WITHOUT ANY WARRANTY; without even the implied warranty of
9 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
9 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
10 # GNU General Public License for more details.
10 # GNU General Public License for more details.
11 #
11 #
12 # You should have received a copy of the GNU General Public License
12 # You should have received a copy of the GNU General Public License
13 # along with this program. If not, see <http://www.gnu.org/licenses/>.
13 # along with this program. If not, see <http://www.gnu.org/licenses/>.
14
14
15 """
15 """
16 kallithea.controllers.base
16 kallithea.controllers.base
17 ~~~~~~~~~~~~~~~~~~~~~~~~~~
17 ~~~~~~~~~~~~~~~~~~~~~~~~~~
18
18
19 The base Controller API
19 The base Controller API
20 Provides the BaseController class for subclassing. And usage in different
20 Provides the BaseController class for subclassing. And usage in different
21 controllers
21 controllers
22
22
23 This file was forked by the Kallithea project in July 2014.
23 This file was forked by the Kallithea project in July 2014.
24 Original author and date, and relevant copyright and licensing information is below:
24 Original author and date, and relevant copyright and licensing information is below:
25 :created_on: Oct 06, 2010
25 :created_on: Oct 06, 2010
26 :author: marcink
26 :author: marcink
27 :copyright: (c) 2013 RhodeCode GmbH, and others.
27 :copyright: (c) 2013 RhodeCode GmbH, and others.
28 :license: GPLv3, see LICENSE.md for more details.
28 :license: GPLv3, see LICENSE.md for more details.
29 """
29 """
30
30
31 import base64
31 import base64
32 import datetime
32 import datetime
33 import logging
33 import logging
34 import traceback
34 import traceback
35 import warnings
35 import warnings
36
36
37 import decorator
37 import decorator
38 import paste.auth.basic
38 import paste.auth.basic
39 import paste.httpexceptions
39 import paste.httpexceptions
40 import paste.httpheaders
40 import paste.httpheaders
41 import webob.exc
41 import webob.exc
42 from tg import TGController, config, render_template, request, response, session
42 from tg import TGController, config, render_template, request, response, session
43 from tg import tmpl_context as c
43 from tg import tmpl_context as c
44 from tg.i18n import ugettext as _
44 from tg.i18n import ugettext as _
45
45
46 import kallithea
46 import kallithea
47 from kallithea.lib import auth_modules, ext_json, webutils
47 from kallithea.lib import auth_modules, ext_json, webutils
48 from kallithea.lib.auth import AuthUser, HasPermissionAnyMiddleware
48 from kallithea.lib.auth import AuthUser, HasPermissionAnyMiddleware
49 from kallithea.lib.exceptions import UserCreationError
49 from kallithea.lib.exceptions import UserCreationError
50 from kallithea.lib.utils import get_repo_slug, is_valid_repo
50 from kallithea.lib.utils import get_repo_slug, is_valid_repo
51 from kallithea.lib.utils2 import AttributeDict, asbool, ascii_bytes, safe_int, safe_str, set_hook_environment
51 from kallithea.lib.utils2 import AttributeDict, asbool, ascii_bytes, safe_int, safe_str, set_hook_environment
52 from kallithea.lib.vcs.exceptions import ChangesetDoesNotExistError, EmptyRepositoryError, RepositoryError
52 from kallithea.lib.vcs.exceptions import ChangesetDoesNotExistError, EmptyRepositoryError, RepositoryError
53 from kallithea.lib.webutils import url
53 from kallithea.lib.webutils import url
54 from kallithea.model import db, meta
54 from kallithea.model import db, meta
55 from kallithea.model.scm import ScmModel
55 from kallithea.model.scm import ScmModel
56
56
57
57
58 log = logging.getLogger(__name__)
58 log = logging.getLogger(__name__)
59
59
60
60
61 def render(template_path):
61 def render(template_path):
62 return render_template({'url': url}, 'mako', template_path)
62 return render_template({'url': url}, 'mako', template_path)
63
63
64
64
65 def _filter_proxy(ip):
65 def _filter_proxy(ip):
66 """
66 """
67 HEADERS can have multiple ips inside the left-most being the original
67 HTTP_X_FORWARDED_FOR headers can have multiple IP addresses, with the
68 client, and each successive proxy that passed the request adding the IP
68 leftmost being the original client. Each proxy that is forwarding the
69 address where it received the request from.
69 request will usually add the IP address it sees the request coming from.
70
70
71 :param ip:
71 The client might have provided a fake leftmost value before hitting the
72 first proxy, so if we have a proxy that is adding one IP address, we can
73 only trust the rightmost address.
72 """
74 """
73 if ',' in ip:
75 if ',' in ip:
74 _ips = ip.split(',')
76 _ips = ip.split(',')
75 _first_ip = _ips[0].strip()
77 _first_ip = _ips[-1].strip()
76 log.debug('Got multiple IPs %s, using %s', ','.join(_ips), _first_ip)
78 log.debug('Got multiple IPs %s, using %s', ','.join(_ips), _first_ip)
77 return _first_ip
79 return _first_ip
78 return ip
80 return ip
79
81
80
82
81 def get_ip_addr(environ):
83 def get_ip_addr(environ):
82 proxy_key = 'HTTP_X_REAL_IP'
84 proxy_key = 'HTTP_X_REAL_IP'
83 proxy_key2 = 'HTTP_X_FORWARDED_FOR'
85 proxy_key2 = 'HTTP_X_FORWARDED_FOR'
84 def_key = 'REMOTE_ADDR'
86 def_key = 'REMOTE_ADDR'
85
87
86 ip = environ.get(proxy_key)
88 ip = environ.get(proxy_key)
87 if ip:
89 if ip:
88 return _filter_proxy(ip)
90 return _filter_proxy(ip)
89
91
90 ip = environ.get(proxy_key2)
92 ip = environ.get(proxy_key2)
91 if ip:
93 if ip:
92 return _filter_proxy(ip)
94 return _filter_proxy(ip)
93
95
94 ip = environ.get(def_key, '0.0.0.0')
96 ip = environ.get(def_key, '0.0.0.0')
95 return _filter_proxy(ip)
97 return _filter_proxy(ip)
96
98
97
99
98 def get_path_info(environ):
100 def get_path_info(environ):
99 """Return PATH_INFO from environ ... using tg.original_request if available.
101 """Return PATH_INFO from environ ... using tg.original_request if available.
100
102
101 In Python 3 WSGI, PATH_INFO is a unicode str, but kind of contains encoded
103 In Python 3 WSGI, PATH_INFO is a unicode str, but kind of contains encoded
102 bytes. The code points are guaranteed to only use the lower 8 bit bits, and
104 bytes. The code points are guaranteed to only use the lower 8 bit bits, and
103 encoding the string with the 1:1 encoding latin1 will give the
105 encoding the string with the 1:1 encoding latin1 will give the
104 corresponding byte string ... which then can be decoded to proper unicode.
106 corresponding byte string ... which then can be decoded to proper unicode.
105 """
107 """
106 org_req = environ.get('tg.original_request')
108 org_req = environ.get('tg.original_request')
107 if org_req is not None:
109 if org_req is not None:
108 environ = org_req.environ
110 environ = org_req.environ
109 return safe_str(environ['PATH_INFO'].encode('latin1'))
111 return safe_str(environ['PATH_INFO'].encode('latin1'))
110
112
111
113
112 def log_in_user(user, remember, is_external_auth, ip_addr):
114 def log_in_user(user, remember, is_external_auth, ip_addr):
113 """
115 """
114 Log a `User` in and update session and cookies. If `remember` is True,
116 Log a `User` in and update session and cookies. If `remember` is True,
115 the session cookie is set to expire in a year; otherwise, it expires at
117 the session cookie is set to expire in a year; otherwise, it expires at
116 the end of the browser session.
118 the end of the browser session.
117
119
118 Returns populated `AuthUser` object.
120 Returns populated `AuthUser` object.
119 """
121 """
120 # It should not be possible to explicitly log in as the default user.
122 # It should not be possible to explicitly log in as the default user.
121 assert not user.is_default_user, user
123 assert not user.is_default_user, user
122
124
123 auth_user = AuthUser.make(dbuser=user, is_external_auth=is_external_auth, ip_addr=ip_addr)
125 auth_user = AuthUser.make(dbuser=user, is_external_auth=is_external_auth, ip_addr=ip_addr)
124 if auth_user is None:
126 if auth_user is None:
125 return None
127 return None
126
128
127 user.update_lastlogin()
129 user.update_lastlogin()
128 meta.Session().commit()
130 meta.Session().commit()
129
131
130 # Start new session to prevent session fixation attacks.
132 # Start new session to prevent session fixation attacks.
131 session.invalidate()
133 session.invalidate()
132 session['authuser'] = cookie = auth_user.to_cookie()
134 session['authuser'] = cookie = auth_user.to_cookie()
133
135
134 # If they want to be remembered, update the cookie.
136 # If they want to be remembered, update the cookie.
135 # NOTE: Assumes that beaker defaults to browser session cookie.
137 # NOTE: Assumes that beaker defaults to browser session cookie.
136 if remember:
138 if remember:
137 t = datetime.datetime.now() + datetime.timedelta(days=365)
139 t = datetime.datetime.now() + datetime.timedelta(days=365)
138 session._set_cookie_expires(t)
140 session._set_cookie_expires(t)
139
141
140 session.save()
142 session.save()
141
143
142 log.info('user %s is now authenticated and stored in '
144 log.info('user %s is now authenticated and stored in '
143 'session, session attrs %s', user.username, cookie)
145 'session, session attrs %s', user.username, cookie)
144
146
145 # dumps session attrs back to cookie
147 # dumps session attrs back to cookie
146 session._update_cookie_out()
148 session._update_cookie_out()
147
149
148 return auth_user
150 return auth_user
149
151
150
152
151 class BasicAuth(paste.auth.basic.AuthBasicAuthenticator):
153 class BasicAuth(paste.auth.basic.AuthBasicAuthenticator):
152
154
153 def __init__(self, realm, authfunc, auth_http_code=None):
155 def __init__(self, realm, authfunc, auth_http_code=None):
154 self.realm = realm
156 self.realm = realm
155 self.authfunc = authfunc
157 self.authfunc = authfunc
156 self._rc_auth_http_code = auth_http_code
158 self._rc_auth_http_code = auth_http_code
157
159
158 def build_authentication(self, environ):
160 def build_authentication(self, environ):
159 head = paste.httpheaders.WWW_AUTHENTICATE.tuples('Basic realm="%s"' % self.realm)
161 head = paste.httpheaders.WWW_AUTHENTICATE.tuples('Basic realm="%s"' % self.realm)
160 # Consume the whole body before sending a response
162 # Consume the whole body before sending a response
161 try:
163 try:
162 request_body_size = int(environ.get('CONTENT_LENGTH', 0))
164 request_body_size = int(environ.get('CONTENT_LENGTH', 0))
163 except (ValueError):
165 except (ValueError):
164 request_body_size = 0
166 request_body_size = 0
165 environ['wsgi.input'].read(request_body_size)
167 environ['wsgi.input'].read(request_body_size)
166 if self._rc_auth_http_code and self._rc_auth_http_code == '403':
168 if self._rc_auth_http_code and self._rc_auth_http_code == '403':
167 # return 403 if alternative http return code is specified in
169 # return 403 if alternative http return code is specified in
168 # Kallithea config
170 # Kallithea config
169 return paste.httpexceptions.HTTPForbidden(headers=head)
171 return paste.httpexceptions.HTTPForbidden(headers=head)
170 return paste.httpexceptions.HTTPUnauthorized(headers=head)
172 return paste.httpexceptions.HTTPUnauthorized(headers=head)
171
173
172 def authenticate(self, environ):
174 def authenticate(self, environ):
173 authorization = paste.httpheaders.AUTHORIZATION(environ)
175 authorization = paste.httpheaders.AUTHORIZATION(environ)
174 if not authorization:
176 if not authorization:
175 return self.build_authentication(environ)
177 return self.build_authentication(environ)
176 (authmeth, auth) = authorization.split(' ', 1)
178 (authmeth, auth) = authorization.split(' ', 1)
177 if 'basic' != authmeth.lower():
179 if 'basic' != authmeth.lower():
178 return self.build_authentication(environ)
180 return self.build_authentication(environ)
179 auth = safe_str(base64.b64decode(auth.strip()))
181 auth = safe_str(base64.b64decode(auth.strip()))
180 _parts = auth.split(':', 1)
182 _parts = auth.split(':', 1)
181 if len(_parts) == 2:
183 if len(_parts) == 2:
182 username, password = _parts
184 username, password = _parts
183 if self.authfunc(username, password, environ) is not None:
185 if self.authfunc(username, password, environ) is not None:
184 return username
186 return username
185 return self.build_authentication(environ)
187 return self.build_authentication(environ)
186
188
187 __call__ = authenticate
189 __call__ = authenticate
188
190
189
191
190 class BaseVCSController(object):
192 class BaseVCSController(object):
191 """Base controller for handling Mercurial/Git protocol requests
193 """Base controller for handling Mercurial/Git protocol requests
192 (coming from a VCS client, and not a browser).
194 (coming from a VCS client, and not a browser).
193 """
195 """
194
196
195 scm_alias = None # 'hg' / 'git'
197 scm_alias = None # 'hg' / 'git'
196
198
197 def __init__(self, application, config):
199 def __init__(self, application, config):
198 self.application = application
200 self.application = application
199 self.config = config
201 self.config = config
200 # base path of repo locations
202 # base path of repo locations
201 self.basepath = self.config['base_path']
203 self.basepath = self.config['base_path']
202 # authenticate this VCS request using the authentication modules
204 # authenticate this VCS request using the authentication modules
203 self.authenticate = BasicAuth('', auth_modules.authenticate,
205 self.authenticate = BasicAuth('', auth_modules.authenticate,
204 config.get('auth_ret_code'))
206 config.get('auth_ret_code'))
205
207
206 @classmethod
208 @classmethod
207 def parse_request(cls, environ):
209 def parse_request(cls, environ):
208 """If request is parsed as a request for this VCS, return a namespace with the parsed request.
210 """If request is parsed as a request for this VCS, return a namespace with the parsed request.
209 If the request is unknown, return None.
211 If the request is unknown, return None.
210 """
212 """
211 raise NotImplementedError()
213 raise NotImplementedError()
212
214
213 def _authorize(self, environ, action, repo_name, ip_addr):
215 def _authorize(self, environ, action, repo_name, ip_addr):
214 """Authenticate and authorize user.
216 """Authenticate and authorize user.
215
217
216 Since we're dealing with a VCS client and not a browser, we only
218 Since we're dealing with a VCS client and not a browser, we only
217 support HTTP basic authentication, either directly via raw header
219 support HTTP basic authentication, either directly via raw header
218 inspection, or by using container authentication to delegate the
220 inspection, or by using container authentication to delegate the
219 authentication to the web server.
221 authentication to the web server.
220
222
221 Returns (user, None) on successful authentication and authorization.
223 Returns (user, None) on successful authentication and authorization.
222 Returns (None, wsgi_app) to send the wsgi_app response to the client.
224 Returns (None, wsgi_app) to send the wsgi_app response to the client.
223 """
225 """
224 # Use anonymous access if allowed for action on repo.
226 # Use anonymous access if allowed for action on repo.
225 default_user = db.User.get_default_user()
227 default_user = db.User.get_default_user()
226 default_authuser = AuthUser.make(dbuser=default_user, ip_addr=ip_addr)
228 default_authuser = AuthUser.make(dbuser=default_user, ip_addr=ip_addr)
227 if default_authuser is None:
229 if default_authuser is None:
228 log.debug('No anonymous access at all') # move on to proper user auth
230 log.debug('No anonymous access at all') # move on to proper user auth
229 else:
231 else:
230 if self._check_permission(action, default_authuser, repo_name):
232 if self._check_permission(action, default_authuser, repo_name):
231 return default_authuser, None
233 return default_authuser, None
232 log.debug('Not authorized to access this repository as anonymous user')
234 log.debug('Not authorized to access this repository as anonymous user')
233
235
234 username = None
236 username = None
235 #==============================================================
237 #==============================================================
236 # DEFAULT PERM FAILED OR ANONYMOUS ACCESS IS DISABLED SO WE
238 # DEFAULT PERM FAILED OR ANONYMOUS ACCESS IS DISABLED SO WE
237 # NEED TO AUTHENTICATE AND ASK FOR AUTH USER PERMISSIONS
239 # NEED TO AUTHENTICATE AND ASK FOR AUTH USER PERMISSIONS
238 #==============================================================
240 #==============================================================
239
241
240 # try to auth based on environ, container auth methods
242 # try to auth based on environ, container auth methods
241 log.debug('Running PRE-AUTH for container based authentication')
243 log.debug('Running PRE-AUTH for container based authentication')
242 pre_auth = auth_modules.authenticate('', '', environ)
244 pre_auth = auth_modules.authenticate('', '', environ)
243 if pre_auth is not None and pre_auth.get('username'):
245 if pre_auth is not None and pre_auth.get('username'):
244 username = pre_auth['username']
246 username = pre_auth['username']
245 log.debug('PRE-AUTH got %s as username', username)
247 log.debug('PRE-AUTH got %s as username', username)
246
248
247 # If not authenticated by the container, running basic auth
249 # If not authenticated by the container, running basic auth
248 if not username:
250 if not username:
249 self.authenticate.realm = self.config['realm']
251 self.authenticate.realm = self.config['realm']
250 result = self.authenticate(environ)
252 result = self.authenticate(environ)
251 if isinstance(result, str):
253 if isinstance(result, str):
252 paste.httpheaders.AUTH_TYPE.update(environ, 'basic')
254 paste.httpheaders.AUTH_TYPE.update(environ, 'basic')
253 paste.httpheaders.REMOTE_USER.update(environ, result)
255 paste.httpheaders.REMOTE_USER.update(environ, result)
254 username = result
256 username = result
255 else:
257 else:
256 return None, result.wsgi_application
258 return None, result.wsgi_application
257
259
258 #==============================================================
260 #==============================================================
259 # CHECK PERMISSIONS FOR THIS REQUEST USING GIVEN USERNAME
261 # CHECK PERMISSIONS FOR THIS REQUEST USING GIVEN USERNAME
260 #==============================================================
262 #==============================================================
261 try:
263 try:
262 user = db.User.get_by_username_or_email(username)
264 user = db.User.get_by_username_or_email(username)
263 except Exception:
265 except Exception:
264 log.error(traceback.format_exc())
266 log.error(traceback.format_exc())
265 return None, webob.exc.HTTPInternalServerError()
267 return None, webob.exc.HTTPInternalServerError()
266
268
267 authuser = AuthUser.make(dbuser=user, ip_addr=ip_addr)
269 authuser = AuthUser.make(dbuser=user, ip_addr=ip_addr)
268 if authuser is None:
270 if authuser is None:
269 return None, webob.exc.HTTPForbidden()
271 return None, webob.exc.HTTPForbidden()
270 if not self._check_permission(action, authuser, repo_name):
272 if not self._check_permission(action, authuser, repo_name):
271 return None, webob.exc.HTTPForbidden()
273 return None, webob.exc.HTTPForbidden()
272
274
273 return user, None
275 return user, None
274
276
275 def _handle_request(self, environ, start_response):
277 def _handle_request(self, environ, start_response):
276 raise NotImplementedError()
278 raise NotImplementedError()
277
279
278 def _check_permission(self, action, authuser, repo_name):
280 def _check_permission(self, action, authuser, repo_name):
279 """
281 """
280 :param action: 'push' or 'pull'
282 :param action: 'push' or 'pull'
281 :param user: `AuthUser` instance
283 :param user: `AuthUser` instance
282 :param repo_name: repository name
284 :param repo_name: repository name
283 """
285 """
284 if action == 'push':
286 if action == 'push':
285 if not HasPermissionAnyMiddleware('repository.write',
287 if not HasPermissionAnyMiddleware('repository.write',
286 'repository.admin')(authuser,
288 'repository.admin')(authuser,
287 repo_name):
289 repo_name):
288 return False
290 return False
289
291
290 elif action == 'pull':
292 elif action == 'pull':
291 #any other action need at least read permission
293 #any other action need at least read permission
292 if not HasPermissionAnyMiddleware('repository.read',
294 if not HasPermissionAnyMiddleware('repository.read',
293 'repository.write',
295 'repository.write',
294 'repository.admin')(authuser,
296 'repository.admin')(authuser,
295 repo_name):
297 repo_name):
296 return False
298 return False
297
299
298 else:
300 else:
299 assert False, action
301 assert False, action
300
302
301 return True
303 return True
302
304
303 def __call__(self, environ, start_response):
305 def __call__(self, environ, start_response):
304 try:
306 try:
305 # try parsing a request for this VCS - if it fails, call the wrapped app
307 # try parsing a request for this VCS - if it fails, call the wrapped app
306 parsed_request = self.parse_request(environ)
308 parsed_request = self.parse_request(environ)
307 if parsed_request is None:
309 if parsed_request is None:
308 return self.application(environ, start_response)
310 return self.application(environ, start_response)
309
311
310 # skip passing error to error controller
312 # skip passing error to error controller
311 environ['pylons.status_code_redirect'] = True
313 environ['pylons.status_code_redirect'] = True
312
314
313 # quick check if repo exists...
315 # quick check if repo exists...
314 if not is_valid_repo(parsed_request.repo_name, self.basepath, self.scm_alias):
316 if not is_valid_repo(parsed_request.repo_name, self.basepath, self.scm_alias):
315 raise webob.exc.HTTPNotFound()
317 raise webob.exc.HTTPNotFound()
316
318
317 if parsed_request.action is None:
319 if parsed_request.action is None:
318 # Note: the client doesn't get the helpful error message
320 # Note: the client doesn't get the helpful error message
319 raise webob.exc.HTTPBadRequest('Unable to detect pull/push action for %r! Are you using a nonstandard command or client?' % parsed_request.repo_name)
321 raise webob.exc.HTTPBadRequest('Unable to detect pull/push action for %r! Are you using a nonstandard command or client?' % parsed_request.repo_name)
320
322
321 #======================================================================
323 #======================================================================
322 # CHECK PERMISSIONS
324 # CHECK PERMISSIONS
323 #======================================================================
325 #======================================================================
324 ip_addr = get_ip_addr(environ)
326 ip_addr = get_ip_addr(environ)
325 user, response_app = self._authorize(environ, parsed_request.action, parsed_request.repo_name, ip_addr)
327 user, response_app = self._authorize(environ, parsed_request.action, parsed_request.repo_name, ip_addr)
326 if response_app is not None:
328 if response_app is not None:
327 return response_app(environ, start_response)
329 return response_app(environ, start_response)
328
330
329 #======================================================================
331 #======================================================================
330 # REQUEST HANDLING
332 # REQUEST HANDLING
331 #======================================================================
333 #======================================================================
332 set_hook_environment(user.username, ip_addr,
334 set_hook_environment(user.username, ip_addr,
333 parsed_request.repo_name, self.scm_alias, parsed_request.action)
335 parsed_request.repo_name, self.scm_alias, parsed_request.action)
334
336
335 try:
337 try:
336 log.info('%s action on %s repo "%s" by "%s" from %s',
338 log.info('%s action on %s repo "%s" by "%s" from %s',
337 parsed_request.action, self.scm_alias, parsed_request.repo_name, user.username, ip_addr)
339 parsed_request.action, self.scm_alias, parsed_request.repo_name, user.username, ip_addr)
338 app = self._make_app(parsed_request)
340 app = self._make_app(parsed_request)
339 return app(environ, start_response)
341 return app(environ, start_response)
340 except Exception:
342 except Exception:
341 log.error(traceback.format_exc())
343 log.error(traceback.format_exc())
342 raise webob.exc.HTTPInternalServerError()
344 raise webob.exc.HTTPInternalServerError()
343
345
344 except webob.exc.HTTPException as e:
346 except webob.exc.HTTPException as e:
345 return e(environ, start_response)
347 return e(environ, start_response)
346
348
347
349
348 class BaseController(TGController):
350 class BaseController(TGController):
349
351
350 def _before(self, *args, **kwargs):
352 def _before(self, *args, **kwargs):
351 """
353 """
352 _before is called before controller methods and after __call__
354 _before is called before controller methods and after __call__
353 """
355 """
354 if request.needs_csrf_check:
356 if request.needs_csrf_check:
355 # CSRF protection: Whenever a request has ambient authority (whether
357 # CSRF protection: Whenever a request has ambient authority (whether
356 # through a session cookie or its origin IP address), it must include
358 # through a session cookie or its origin IP address), it must include
357 # the correct token, unless the HTTP method is GET or HEAD (and thus
359 # the correct token, unless the HTTP method is GET or HEAD (and thus
358 # guaranteed to be side effect free. In practice, the only situation
360 # guaranteed to be side effect free. In practice, the only situation
359 # where we allow side effects without ambient authority is when the
361 # where we allow side effects without ambient authority is when the
360 # authority comes from an API key; and that is handled above.
362 # authority comes from an API key; and that is handled above.
361 token = request.POST.get(webutils.session_csrf_secret_name)
363 token = request.POST.get(webutils.session_csrf_secret_name)
362 if not token or token != webutils.session_csrf_secret_token():
364 if not token or token != webutils.session_csrf_secret_token():
363 log.error('CSRF check failed')
365 log.error('CSRF check failed')
364 raise webob.exc.HTTPForbidden()
366 raise webob.exc.HTTPForbidden()
365
367
366 c.kallithea_version = kallithea.__version__
368 c.kallithea_version = kallithea.__version__
367 settings = db.Setting.get_app_settings()
369 settings = db.Setting.get_app_settings()
368
370
369 # Visual options
371 # Visual options
370 c.visual = AttributeDict({})
372 c.visual = AttributeDict({})
371
373
372 ## DB stored
374 ## DB stored
373 c.visual.show_public_icon = asbool(settings.get('show_public_icon'))
375 c.visual.show_public_icon = asbool(settings.get('show_public_icon'))
374 c.visual.show_private_icon = asbool(settings.get('show_private_icon'))
376 c.visual.show_private_icon = asbool(settings.get('show_private_icon'))
375 c.visual.stylify_metalabels = asbool(settings.get('stylify_metalabels'))
377 c.visual.stylify_metalabels = asbool(settings.get('stylify_metalabels'))
376 c.visual.page_size = safe_int(settings.get('dashboard_items', 100))
378 c.visual.page_size = safe_int(settings.get('dashboard_items', 100))
377 c.visual.admin_grid_items = safe_int(settings.get('admin_grid_items', 100))
379 c.visual.admin_grid_items = safe_int(settings.get('admin_grid_items', 100))
378 c.visual.repository_fields = asbool(settings.get('repository_fields'))
380 c.visual.repository_fields = asbool(settings.get('repository_fields'))
379 c.visual.show_version = asbool(settings.get('show_version'))
381 c.visual.show_version = asbool(settings.get('show_version'))
380 c.visual.use_gravatar = asbool(settings.get('use_gravatar'))
382 c.visual.use_gravatar = asbool(settings.get('use_gravatar'))
381 c.visual.gravatar_url = settings.get('gravatar_url')
383 c.visual.gravatar_url = settings.get('gravatar_url')
382
384
383 c.ga_code = settings.get('ga_code')
385 c.ga_code = settings.get('ga_code')
384 # TODO: replace undocumented backwards compatibility hack with db upgrade and rename ga_code
386 # TODO: replace undocumented backwards compatibility hack with db upgrade and rename ga_code
385 if c.ga_code and '<' not in c.ga_code:
387 if c.ga_code and '<' not in c.ga_code:
386 c.ga_code = '''<script type="text/javascript">
388 c.ga_code = '''<script type="text/javascript">
387 var _gaq = _gaq || [];
389 var _gaq = _gaq || [];
388 _gaq.push(['_setAccount', '%s']);
390 _gaq.push(['_setAccount', '%s']);
389 _gaq.push(['_trackPageview']);
391 _gaq.push(['_trackPageview']);
390
392
391 (function() {
393 (function() {
392 var ga = document.createElement('script'); ga.type = 'text/javascript'; ga.async = true;
394 var ga = document.createElement('script'); ga.type = 'text/javascript'; ga.async = true;
393 ga.src = ('https:' == document.location.protocol ? 'https://ssl' : 'http://www') + '.google-analytics.com/ga.js';
395 ga.src = ('https:' == document.location.protocol ? 'https://ssl' : 'http://www') + '.google-analytics.com/ga.js';
394 var s = document.getElementsByTagName('script')[0]; s.parentNode.insertBefore(ga, s);
396 var s = document.getElementsByTagName('script')[0]; s.parentNode.insertBefore(ga, s);
395 })();
397 })();
396 </script>''' % c.ga_code
398 </script>''' % c.ga_code
397 c.site_name = settings.get('title')
399 c.site_name = settings.get('title')
398 c.clone_uri_tmpl = settings.get('clone_uri_tmpl') or db.Repository.DEFAULT_CLONE_URI
400 c.clone_uri_tmpl = settings.get('clone_uri_tmpl') or db.Repository.DEFAULT_CLONE_URI
399 c.clone_ssh_tmpl = settings.get('clone_ssh_tmpl') or db.Repository.DEFAULT_CLONE_SSH
401 c.clone_ssh_tmpl = settings.get('clone_ssh_tmpl') or db.Repository.DEFAULT_CLONE_SSH
400
402
401 ## INI stored
403 ## INI stored
402 c.visual.allow_repo_location_change = asbool(config.get('allow_repo_location_change', True))
404 c.visual.allow_repo_location_change = asbool(config.get('allow_repo_location_change', True))
403 c.visual.allow_custom_hooks_settings = asbool(config.get('allow_custom_hooks_settings', True))
405 c.visual.allow_custom_hooks_settings = asbool(config.get('allow_custom_hooks_settings', True))
404 c.ssh_enabled = asbool(config.get('ssh_enabled', False))
406 c.ssh_enabled = asbool(config.get('ssh_enabled', False))
405
407
406 c.instance_id = config.get('instance_id')
408 c.instance_id = config.get('instance_id')
407 c.issues_url = config.get('bugtracker', url('issues_url'))
409 c.issues_url = config.get('bugtracker', url('issues_url'))
408 # END CONFIG VARS
410 # END CONFIG VARS
409
411
410 c.repo_name = get_repo_slug(request) # can be empty
412 c.repo_name = get_repo_slug(request) # can be empty
411 c.backends = list(kallithea.BACKENDS)
413 c.backends = list(kallithea.BACKENDS)
412
414
413 self.cut_off_limit = safe_int(config.get('cut_off_limit'))
415 self.cut_off_limit = safe_int(config.get('cut_off_limit'))
414
416
415 c.my_pr_count = db.PullRequest.query(reviewer_id=request.authuser.user_id, include_closed=False).count()
417 c.my_pr_count = db.PullRequest.query(reviewer_id=request.authuser.user_id, include_closed=False).count()
416
418
417 self.scm_model = ScmModel()
419 self.scm_model = ScmModel()
418
420
419 @staticmethod
421 @staticmethod
420 def _determine_auth_user(session_authuser, ip_addr):
422 def _determine_auth_user(session_authuser, ip_addr):
421 """
423 """
422 Create an `AuthUser` object given the API key/bearer token
424 Create an `AuthUser` object given the API key/bearer token
423 (if any) and the value of the authuser session cookie.
425 (if any) and the value of the authuser session cookie.
424 Returns None if no valid user is found (like not active or no access for IP).
426 Returns None if no valid user is found (like not active or no access for IP).
425 """
427 """
426
428
427 # Authenticate by session cookie
429 # Authenticate by session cookie
428 # In ancient login sessions, 'authuser' may not be a dict.
430 # In ancient login sessions, 'authuser' may not be a dict.
429 # In that case, the user will have to log in again.
431 # In that case, the user will have to log in again.
430 # v0.3 and earlier included an 'is_authenticated' key; if present,
432 # v0.3 and earlier included an 'is_authenticated' key; if present,
431 # this must be True.
433 # this must be True.
432 if isinstance(session_authuser, dict) and session_authuser.get('is_authenticated', True):
434 if isinstance(session_authuser, dict) and session_authuser.get('is_authenticated', True):
433 return AuthUser.from_cookie(session_authuser, ip_addr=ip_addr)
435 return AuthUser.from_cookie(session_authuser, ip_addr=ip_addr)
434
436
435 # Authenticate by auth_container plugin (if enabled)
437 # Authenticate by auth_container plugin (if enabled)
436 if any(
438 if any(
437 plugin.is_container_auth
439 plugin.is_container_auth
438 for plugin in auth_modules.get_auth_plugins()
440 for plugin in auth_modules.get_auth_plugins()
439 ):
441 ):
440 try:
442 try:
441 user_info = auth_modules.authenticate('', '', request.environ)
443 user_info = auth_modules.authenticate('', '', request.environ)
442 except UserCreationError as e:
444 except UserCreationError as e:
443 webutils.flash(e, 'error', logf=log.error)
445 webutils.flash(e, 'error', logf=log.error)
444 else:
446 else:
445 if user_info is not None:
447 if user_info is not None:
446 username = user_info['username']
448 username = user_info['username']
447 user = db.User.get_by_username(username, case_insensitive=True)
449 user = db.User.get_by_username(username, case_insensitive=True)
448 return log_in_user(user, remember=False, is_external_auth=True, ip_addr=ip_addr)
450 return log_in_user(user, remember=False, is_external_auth=True, ip_addr=ip_addr)
449
451
450 # User is default user (if active) or anonymous
452 # User is default user (if active) or anonymous
451 default_user = db.User.get_default_user()
453 default_user = db.User.get_default_user()
452 authuser = AuthUser.make(dbuser=default_user, ip_addr=ip_addr)
454 authuser = AuthUser.make(dbuser=default_user, ip_addr=ip_addr)
453 if authuser is None: # fall back to anonymous
455 if authuser is None: # fall back to anonymous
454 authuser = AuthUser(dbuser=default_user) # TODO: somehow use .make?
456 authuser = AuthUser(dbuser=default_user) # TODO: somehow use .make?
455 return authuser
457 return authuser
456
458
457 @staticmethod
459 @staticmethod
458 def _basic_security_checks():
460 def _basic_security_checks():
459 """Perform basic security/sanity checks before processing the request."""
461 """Perform basic security/sanity checks before processing the request."""
460
462
461 # Only allow the following HTTP request methods.
463 # Only allow the following HTTP request methods.
462 if request.method not in ['GET', 'HEAD', 'POST']:
464 if request.method not in ['GET', 'HEAD', 'POST']:
463 raise webob.exc.HTTPMethodNotAllowed()
465 raise webob.exc.HTTPMethodNotAllowed()
464
466
465 # Also verify the _method override - no longer allowed.
467 # Also verify the _method override - no longer allowed.
466 if request.params.get('_method') is None:
468 if request.params.get('_method') is None:
467 pass # no override, no problem
469 pass # no override, no problem
468 else:
470 else:
469 raise webob.exc.HTTPMethodNotAllowed()
471 raise webob.exc.HTTPMethodNotAllowed()
470
472
471 # Make sure CSRF token never appears in the URL. If so, invalidate it.
473 # Make sure CSRF token never appears in the URL. If so, invalidate it.
472 if webutils.session_csrf_secret_name in request.GET:
474 if webutils.session_csrf_secret_name in request.GET:
473 log.error('CSRF key leak detected')
475 log.error('CSRF key leak detected')
474 session.pop(webutils.session_csrf_secret_name, None)
476 session.pop(webutils.session_csrf_secret_name, None)
475 session.save()
477 session.save()
476 webutils.flash(_('CSRF token leak has been detected - all form tokens have been expired'),
478 webutils.flash(_('CSRF token leak has been detected - all form tokens have been expired'),
477 category='error')
479 category='error')
478
480
479 # WebOb already ignores request payload parameters for anything other
481 # WebOb already ignores request payload parameters for anything other
480 # than POST/PUT, but double-check since other Kallithea code relies on
482 # than POST/PUT, but double-check since other Kallithea code relies on
481 # this assumption.
483 # this assumption.
482 if request.method not in ['POST', 'PUT'] and request.POST:
484 if request.method not in ['POST', 'PUT'] and request.POST:
483 log.error('%r request with payload parameters; WebOb should have stopped this', request.method)
485 log.error('%r request with payload parameters; WebOb should have stopped this', request.method)
484 raise webob.exc.HTTPBadRequest()
486 raise webob.exc.HTTPBadRequest()
485
487
486 def __call__(self, environ, context):
488 def __call__(self, environ, context):
487 try:
489 try:
488 ip_addr = get_ip_addr(environ)
490 ip_addr = get_ip_addr(environ)
489 self._basic_security_checks()
491 self._basic_security_checks()
490
492
491 api_key = request.GET.get('api_key')
493 api_key = request.GET.get('api_key')
492 try:
494 try:
493 # Request.authorization may raise ValueError on invalid input
495 # Request.authorization may raise ValueError on invalid input
494 type, params = request.authorization
496 type, params = request.authorization
495 except (ValueError, TypeError):
497 except (ValueError, TypeError):
496 pass
498 pass
497 else:
499 else:
498 if type.lower() == 'bearer':
500 if type.lower() == 'bearer':
499 api_key = params # bearer token is an api key too
501 api_key = params # bearer token is an api key too
500
502
501 if api_key is None:
503 if api_key is None:
502 authuser = self._determine_auth_user(
504 authuser = self._determine_auth_user(
503 session.get('authuser'),
505 session.get('authuser'),
504 ip_addr=ip_addr,
506 ip_addr=ip_addr,
505 )
507 )
506 needs_csrf_check = request.method not in ['GET', 'HEAD']
508 needs_csrf_check = request.method not in ['GET', 'HEAD']
507
509
508 else:
510 else:
509 dbuser = db.User.get_by_api_key(api_key)
511 dbuser = db.User.get_by_api_key(api_key)
510 if dbuser is None:
512 if dbuser is None:
511 log.info('No db user found for authentication with API key ****%s from %s',
513 log.info('No db user found for authentication with API key ****%s from %s',
512 api_key[-4:], ip_addr)
514 api_key[-4:], ip_addr)
513 authuser = AuthUser.make(dbuser=dbuser, is_external_auth=True, ip_addr=ip_addr)
515 authuser = AuthUser.make(dbuser=dbuser, is_external_auth=True, ip_addr=ip_addr)
514 needs_csrf_check = False # API key provides CSRF protection
516 needs_csrf_check = False # API key provides CSRF protection
515
517
516 if authuser is None:
518 if authuser is None:
517 log.info('No valid user found')
519 log.info('No valid user found')
518 raise webob.exc.HTTPForbidden()
520 raise webob.exc.HTTPForbidden()
519
521
520 # set globals for auth user
522 # set globals for auth user
521 request.authuser = authuser
523 request.authuser = authuser
522 request.ip_addr = ip_addr
524 request.ip_addr = ip_addr
523 request.needs_csrf_check = needs_csrf_check
525 request.needs_csrf_check = needs_csrf_check
524
526
525 log.info('IP: %s User: %s Request: %s',
527 log.info('IP: %s User: %s Request: %s',
526 request.ip_addr, request.authuser,
528 request.ip_addr, request.authuser,
527 get_path_info(environ),
529 get_path_info(environ),
528 )
530 )
529 return super(BaseController, self).__call__(environ, context)
531 return super(BaseController, self).__call__(environ, context)
530 except webob.exc.HTTPException as e:
532 except webob.exc.HTTPException as e:
531 return e
533 return e
532
534
533
535
534 class BaseRepoController(BaseController):
536 class BaseRepoController(BaseController):
535 """
537 """
536 Base class for controllers responsible for loading all needed data for
538 Base class for controllers responsible for loading all needed data for
537 repository loaded items are
539 repository loaded items are
538
540
539 c.db_repo_scm_instance: instance of scm repository
541 c.db_repo_scm_instance: instance of scm repository
540 c.db_repo: instance of db
542 c.db_repo: instance of db
541 c.repository_followers: number of followers
543 c.repository_followers: number of followers
542 c.repository_forks: number of forks
544 c.repository_forks: number of forks
543 c.repository_following: weather the current user is following the current repo
545 c.repository_following: weather the current user is following the current repo
544 """
546 """
545
547
546 def _before(self, *args, **kwargs):
548 def _before(self, *args, **kwargs):
547 super(BaseRepoController, self)._before(*args, **kwargs)
549 super(BaseRepoController, self)._before(*args, **kwargs)
548 if c.repo_name: # extracted from request by base-base BaseController._before
550 if c.repo_name: # extracted from request by base-base BaseController._before
549 _dbr = db.Repository.get_by_repo_name(c.repo_name)
551 _dbr = db.Repository.get_by_repo_name(c.repo_name)
550 if not _dbr:
552 if not _dbr:
551 return
553 return
552
554
553 log.debug('Found repository in database %s with state `%s`',
555 log.debug('Found repository in database %s with state `%s`',
554 _dbr, _dbr.repo_state)
556 _dbr, _dbr.repo_state)
555 route = getattr(request.environ.get('routes.route'), 'name', '')
557 route = getattr(request.environ.get('routes.route'), 'name', '')
556
558
557 # allow to delete repos that are somehow damages in filesystem
559 # allow to delete repos that are somehow damages in filesystem
558 if route in ['delete_repo']:
560 if route in ['delete_repo']:
559 return
561 return
560
562
561 if _dbr.repo_state in [db.Repository.STATE_PENDING]:
563 if _dbr.repo_state in [db.Repository.STATE_PENDING]:
562 if route in ['repo_creating_home']:
564 if route in ['repo_creating_home']:
563 return
565 return
564 check_url = url('repo_creating_home', repo_name=c.repo_name)
566 check_url = url('repo_creating_home', repo_name=c.repo_name)
565 raise webob.exc.HTTPFound(location=check_url)
567 raise webob.exc.HTTPFound(location=check_url)
566
568
567 dbr = c.db_repo = _dbr
569 dbr = c.db_repo = _dbr
568 c.db_repo_scm_instance = c.db_repo.scm_instance
570 c.db_repo_scm_instance = c.db_repo.scm_instance
569 if c.db_repo_scm_instance is None:
571 if c.db_repo_scm_instance is None:
570 log.error('%s this repository is present in database but it '
572 log.error('%s this repository is present in database but it '
571 'cannot be created as an scm instance', c.repo_name)
573 'cannot be created as an scm instance', c.repo_name)
572 webutils.flash(_('Repository not found in the filesystem'),
574 webutils.flash(_('Repository not found in the filesystem'),
573 category='error')
575 category='error')
574 raise webob.exc.HTTPNotFound()
576 raise webob.exc.HTTPNotFound()
575
577
576 # some globals counter for menu
578 # some globals counter for menu
577 c.repository_followers = self.scm_model.get_followers(dbr)
579 c.repository_followers = self.scm_model.get_followers(dbr)
578 c.repository_forks = self.scm_model.get_forks(dbr)
580 c.repository_forks = self.scm_model.get_forks(dbr)
579 c.repository_pull_requests = self.scm_model.get_pull_requests(dbr)
581 c.repository_pull_requests = self.scm_model.get_pull_requests(dbr)
580 c.repository_following = self.scm_model.is_following_repo(
582 c.repository_following = self.scm_model.is_following_repo(
581 c.repo_name, request.authuser.user_id)
583 c.repo_name, request.authuser.user_id)
582
584
583 @staticmethod
585 @staticmethod
584 def _get_ref_rev(repo, ref_type, ref_name, returnempty=False):
586 def _get_ref_rev(repo, ref_type, ref_name, returnempty=False):
585 """
587 """
586 Safe way to get changeset. If error occurs show error.
588 Safe way to get changeset. If error occurs show error.
587 """
589 """
588 try:
590 try:
589 return repo.scm_instance.get_ref_revision(ref_type, ref_name)
591 return repo.scm_instance.get_ref_revision(ref_type, ref_name)
590 except EmptyRepositoryError as e:
592 except EmptyRepositoryError as e:
591 if returnempty:
593 if returnempty:
592 return repo.scm_instance.EMPTY_CHANGESET
594 return repo.scm_instance.EMPTY_CHANGESET
593 webutils.flash(_('There are no changesets yet'), category='error')
595 webutils.flash(_('There are no changesets yet'), category='error')
594 raise webob.exc.HTTPNotFound()
596 raise webob.exc.HTTPNotFound()
595 except ChangesetDoesNotExistError as e:
597 except ChangesetDoesNotExistError as e:
596 webutils.flash(_('Changeset for %s %s not found in %s') %
598 webutils.flash(_('Changeset for %s %s not found in %s') %
597 (ref_type, ref_name, repo.repo_name),
599 (ref_type, ref_name, repo.repo_name),
598 category='error')
600 category='error')
599 raise webob.exc.HTTPNotFound()
601 raise webob.exc.HTTPNotFound()
600 except RepositoryError as e:
602 except RepositoryError as e:
601 log.error(traceback.format_exc())
603 log.error(traceback.format_exc())
602 webutils.flash(e, category='error')
604 webutils.flash(e, category='error')
603 raise webob.exc.HTTPBadRequest()
605 raise webob.exc.HTTPBadRequest()
604
606
605
607
606 @decorator.decorator
608 @decorator.decorator
607 def jsonify(func, *args, **kwargs):
609 def jsonify(func, *args, **kwargs):
608 """Action decorator that formats output for JSON
610 """Action decorator that formats output for JSON
609
611
610 Given a function that will return content, this decorator will turn
612 Given a function that will return content, this decorator will turn
611 the result into JSON, with a content-type of 'application/json' and
613 the result into JSON, with a content-type of 'application/json' and
612 output it.
614 output it.
613 """
615 """
614 response.headers['Content-Type'] = 'application/json; charset=utf-8'
616 response.headers['Content-Type'] = 'application/json; charset=utf-8'
615 data = func(*args, **kwargs)
617 data = func(*args, **kwargs)
616 if isinstance(data, (list, tuple)):
618 if isinstance(data, (list, tuple)):
617 # A JSON list response is syntactically valid JavaScript and can be
619 # A JSON list response is syntactically valid JavaScript and can be
618 # loaded and executed as JavaScript by a malicious third-party site
620 # loaded and executed as JavaScript by a malicious third-party site
619 # using <script>, which can lead to cross-site data leaks.
621 # using <script>, which can lead to cross-site data leaks.
620 # JSON responses should therefore be scalars or objects (i.e. Python
622 # JSON responses should therefore be scalars or objects (i.e. Python
621 # dicts), because a JSON object is a syntax error if intepreted as JS.
623 # dicts), because a JSON object is a syntax error if intepreted as JS.
622 msg = "JSON responses with Array envelopes are susceptible to " \
624 msg = "JSON responses with Array envelopes are susceptible to " \
623 "cross-site data leak attacks, see " \
625 "cross-site data leak attacks, see " \
624 "https://web.archive.org/web/20120519231904/http://wiki.pylonshq.com/display/pylonsfaq/Warnings"
626 "https://web.archive.org/web/20120519231904/http://wiki.pylonshq.com/display/pylonsfaq/Warnings"
625 warnings.warn(msg, Warning, 2)
627 warnings.warn(msg, Warning, 2)
626 log.warning(msg)
628 log.warning(msg)
627 log.debug("Returning JSON wrapped action output")
629 log.debug("Returning JSON wrapped action output")
628 return ascii_bytes(ext_json.dumps(data))
630 return ascii_bytes(ext_json.dumps(data))
629
631
630 @decorator.decorator
632 @decorator.decorator
631 def IfSshEnabled(func, *args, **kwargs):
633 def IfSshEnabled(func, *args, **kwargs):
632 """Decorator for functions that can only be called if SSH access is enabled.
634 """Decorator for functions that can only be called if SSH access is enabled.
633
635
634 If SSH access is disabled in the configuration file, HTTPNotFound is raised.
636 If SSH access is disabled in the configuration file, HTTPNotFound is raised.
635 """
637 """
636 if not c.ssh_enabled:
638 if not c.ssh_enabled:
637 webutils.flash(_("SSH access is disabled."), category='warning')
639 webutils.flash(_("SSH access is disabled."), category='warning')
638 raise webob.exc.HTTPNotFound()
640 raise webob.exc.HTTPNotFound()
639 return func(*args, **kwargs)
641 return func(*args, **kwargs)
General Comments 0
You need to be logged in to leave comments. Login now