Skip to content
This repository has been archived by the owner on Sep 5, 2019. It is now read-only.

Commit

Permalink
Move the host checking code from the core to the server
Browse files Browse the repository at this point in the history
  • Loading branch information
Denis Krienbühl committed Feb 20, 2015
1 parent 7f9b9dc commit a6392dc
Show file tree
Hide file tree
Showing 5 changed files with 131 additions and 3 deletions.
44 changes: 43 additions & 1 deletion onegov/server/application.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,21 @@
import re


class Application(object):
""" WSGI applications inheriting from this class can be served by
onegov.server.
"""

#: If the host passed by the request is not localhost, then it is
#: checked against the allowed_hosts expression. If it doesn't match,
#: the request is denied.
allowed_hosts_expression = None

#: Additional allowed hosts may be added to this set. Those are not
#: expressions, but straight hostnames.
allowed_hosts = None

def __call__(self, environ, start_respnose):
raise NotImplementedError

Expand All @@ -14,8 +26,18 @@ def configure_application(self, **configuration):
This will be called *before* set_application_base_path is called, as
this happens *before* the request.
Be sure to call this method from any subclasses you create. The server
adds its own configuration here!
"""
pass
self.configuration = configuration

if 'allowed_hosts_expression' in configuration:
self.allowed_hosts_expression = re.compile(
configuration['allowed_hosts_expression'])

self.allowed_hosts = set(
configuration.get('allowed_hosts', []))

def set_application_id(self, application_id):
""" Sets the application id before __call__ is called. That is, before
Expand Down Expand Up @@ -58,3 +80,23 @@ def set_application_base_path(self, base_path):
"""
self.application_base_path = base_path

def is_allowed_hostname(self, hostname):
""" Called at least once per request with the given hostname.
If True is returned, the request with the given hostname is allowed.
If False is returned, the request is denied.
You usually won't need to override this method, as
:attr:`allowed_hosts_expression` and :attr:`allowed_hosts` already
gives you a way to influence its behavior.
If you do override, it's all on you though (except for localhost
requests).
"""

if self.allowed_hosts_expression:
if self.allowed_hosts_expression.match(hostname):
return True

return hostname in self.allowed_hosts
9 changes: 9 additions & 0 deletions onegov/server/config.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import inspect
import pydoc
import re
import yaml


Expand Down Expand Up @@ -84,6 +85,14 @@ def application_class(self):
def configuration(self):
return self._cfg.get('configuration', {})

@property
def valid_hosts_expression(self):
valid_hosts = self._cfg.get('valid_hosts')
if valid_hosts:
return re.compile(valid_hosts)
else:
return None

@property
def root(self):
""" The path without the wildcard such that '/app' and '/app/*' produce
Expand Down
39 changes: 37 additions & 2 deletions onegov/server/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,35 @@

from onegov.server.collection import ApplicationCollection
from webob import BaseRequest
from webob.exc import HTTPNotFound
from webob.exc import HTTPNotFound, HTTPForbidden

try:
from urllib.parse import urlparse
except ImportError:
from urlparse import urlparse


local_hostnames = {
'127.0.0.1',
'::1',
'localhost'
}


class Request(BaseRequest):

hostname_keys = ('HTTP_HOST', 'HTTP_X_VHM_HOST')

@property
def hostnames(self):
for key in self.hostname_keys:
if key in self.environ:
hostname = urlparse(self.environ[key]).hostname

if hostname is None:
yield self.environ[key].split(':')[0]
else:
yield hostname


class Server(object):
Expand Down Expand Up @@ -60,15 +88,22 @@ def configure_morepath(self):
config.commit()

def __call__(self, environ, start_response):
request = BaseRequest(environ)
request = Request(environ)
path_fragments = request.path.split('/')

# try to find the application that handles this path
application_root = '/'.join(path_fragments[:2])
application = self.applications.get(application_root)

if application is None:
return HTTPNotFound()(environ, start_response)

# make sure the application accepts the given hostname
for host in request.hostnames:
if host not in local_hostnames:
if not application.is_allowed_hostname(host):
return HTTPForbidden()(environ, start_response)

if application_root in self.wildcard_applications:
base_path = '/'.join(path_fragments[:3])
application_id = ''.join(path_fragments[2:3])
Expand Down
41 changes: 41 additions & 0 deletions onegov/server/tests/test_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -115,3 +115,44 @@ def __call__(self, environ, start_response):
assert c.get('/wildcard/wildcard').body == b'/wildcard/wildcard, '
assert c.get('/wildcard/wildcard/wildcard').body\
== b'/wildcard/wildcard, /wildcard'


def test_invalid_host_request():

class HelloApplication(Application):

def __call__(self, environ, start_response):
response = Response()
response.text = u'hello'

return response(environ, start_response)

server = Server(Config({
'applications': [
{
'path': '/static',
'application': HelloApplication,
}
]
}))

c = Client(server)

response = c.get('/static')
assert response.body == b'hello'

response = c.get(
'/static', headers={'X_VHM_HOST': 'https://example.org'}, status=403)
assert response.status_code == 403

response = c.get(
'/static', headers={'HOST': 'example.org:8080'}, status=403)
assert response.status_code == 403

server.applications.get('/static').allowed_hosts.add('example.org')

response = c.get('/static', headers={'X_VHM_HOST': 'https://example.org'})
assert response.body == b'hello'

response = c.get('/static', headers={'HOST': 'example.org:8080'})
assert response.body == b'hello'
1 change: 1 addition & 0 deletions setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ def get_long_description():
zip_safe=False,
platforms='any',
install_requires=[
'cached_property',
'click',
'morepath',
'PyYAML',
Expand Down

0 comments on commit a6392dc

Please sign in to comment.