Skip to content

Commit

Permalink
Merge pull request #86 from AspenWeb/breakouts
Browse files Browse the repository at this point in the history
This branch improves the detection and handling of symbolic links that point outside the `www_root` directory.
  • Loading branch information
Changaco committed Sep 16, 2019
2 parents e3e6b40 + a10243c commit 05abb82
Show file tree
Hide file tree
Showing 7 changed files with 96 additions and 32 deletions.
19 changes: 19 additions & 0 deletions aspen/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,25 @@ def __init__(self, message=''):
self.message = message or "not found"


class AttemptedBreakout(Exception):
"""Raised when a request is dispatched to a symlinked file which is outside
:attr:`~aspen.request_processor.DefaultConfiguration.www_root`.
"""

def __init__(self, sym_path, real_path):
self.sym_path = sym_path
self.real_path = real_path

def __str__(self):
return "%r is a symlink to %r" % (self.sym_path, self.real_path)


class PossibleBreakout(AttemptedBreakout, Warning):
"""A warning emitted when a symlink that points outside
:attr:`~aspen.request_processor.DefaultConfiguration.www_root` is detected.
"""


class SlugCollision(Exception):
"""Raised if two files claim the same URL path.
Expand Down
29 changes: 25 additions & 4 deletions aspen/http/resource.py
Original file line number Diff line number Diff line change
@@ -1,15 +1,35 @@
import os.path

import mimeparse
import mimetypes

from ..exceptions import NegotiationFailure, NotFound
from ..exceptions import AttemptedBreakout, NegotiationFailure, NotFound
from ..output import Output


def open_resource(www_root, resource_path):
"""Open a resource in read-only binary mode, after checking for symlinks.
:raises AttemptedBreakout:
if the :arg:`resource_path` points to a file outside the :arg:`www_root`
This function doesn't fully protect against attackers who have the ability
to create and delete symlinks inside the `www_root` whenever they want, but
it makes the attack more difficult and detectable.
"""
if not os.path.isabs(resource_path):
resource_path = os.path.join(www_root, resource_path)
real_path = os.path.realpath(resource_path)
if not real_path.startswith(www_root.rstrip(os.path.sep) + os.path.sep):
raise AttemptedBreakout(resource_path, real_path)
return open(real_path, 'rb')


class Static(object):
"""Model a static HTTP resource.
"""

__slots__ = ('fspath', 'raw', 'media_type', 'charset')
__slots__ = ('request_processor', 'fspath', 'raw', 'media_type', 'charset')

def __init__(self, request_processor, fspath):
raw = None
Expand All @@ -18,8 +38,9 @@ def __init__(self, request_processor, fspath):
request_processor.charset_static
)
if read_file:
with open(fspath, 'rb') as f:
with open_resource(request_processor.www_root, fspath) as f:
raw = f.read()
self.request_processor = request_processor
self.fspath = fspath
self.raw = raw if request_processor.store_static_files_in_ram else None
self.media_type = request_processor.guess_media_type(fspath)
Expand All @@ -40,7 +61,7 @@ def render(self, *ignored):
"""
output = Output(media_type=self.media_type, charset=self.charset)
if self.raw is None:
with open(self.fspath, 'rb') as f:
with open_resource(self.request_processor.www_root, self.fspath) as f:
output.body = f.read()
else:
output.body = self.raw
Expand Down
39 changes: 23 additions & 16 deletions aspen/request_processor/dispatcher.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,13 +11,14 @@
from operator import attrgetter
import os
import posixpath
import warnings

try:
from os import scandir
except ImportError:
from scandir import scandir

from ..exceptions import SlugCollision, WildcardCollision
from ..exceptions import PossibleBreakout, SlugCollision, WildcardCollision

from ..utils import auto_repr, Constant

Expand Down Expand Up @@ -370,12 +371,6 @@ def dispatch(self, path, path_segments):
self.www_root, path_segments
)
debug(lambda: "dispatch_abstract returned: " + repr(result))

# Protect against escaping the www_root.
if result.match and not result.match.startswith(self.www_root):
# Attempted breakout, e.g. a request for `/../secrets`
return MISSING

return result


Expand Down Expand Up @@ -429,18 +424,29 @@ def get_wildleaf_fallback():
# check all the possibilities:
# node.html, node.html.spt, node.spt, node.html/, %node.html/ %*.html.spt, %*.spt

# don't serve hidden files
parent_subnodes = subnodes
subnodes = set([ n for n in listnodes(curnode) if not file_skipper(n, curnode) ])
parent_subnodes, subnodes = subnodes, set()
wild_leaf_ns, wild_nonleaf_ns = [], []
for entry in scandir(curnode):
if file_skipper(entry.name, curnode):
# don't serve hidden files
continue
if entry.is_symlink():
real_path = os.path.realpath(entry.path)
if not real_path.startswith(startnode):
# don't serve files outside `www_root`
warnings.warn(PossibleBreakout(entry.path, real_path))
continue
subnodes.add(entry.name)
if entry.name.startswith("%"):
if entry.is_dir():
wild_nonleaf_ns.append(entry.name)
elif is_dynamic(entry.path):
wild_leaf_ns.append(entry.name)
wild_leaf_ns.sort()
wild_nonleaf_ns.sort()

node_noext, node_ext = splitext(node)

# only maybe because non-spt files aren't wild
maybe_wild_nodes = [ n for n in sorted(subnodes) if n.startswith("%") ]

wild_leaf_ns = [ n for n in maybe_wild_nodes if is_leaf_node(n) and is_dynamic_node(n) ]
wild_nonleaf_ns = [ n for n in maybe_wild_nodes if not is_leaf_node(n) ]

# store all the fallback possibilities
remaining = reduce(posixpath.join, nodepath[depth:])
for n in wild_leaf_ns:
Expand Down Expand Up @@ -598,6 +604,7 @@ def _build_subtree(self, dirpath, varnames):
fspath = os.path.realpath(fspath)
if not fspath.startswith(self.www_root):
# Prevent escaping the www_root
warnings.warn(PossibleBreakout(entry.path, fspath))
continue
is_dir = entry.is_dir()
if is_dir:
Expand Down
4 changes: 2 additions & 2 deletions aspen/simplates/simplate.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@

from ..output import Output
from .pagination import split_and_escape, parse_specline, Page
from aspen.http.resource import Dynamic
from aspen.http.resource import Dynamic, open_resource


DEFAULT_ENCODING = 'ascii' if PY2 else 'utf8'
Expand Down Expand Up @@ -107,7 +107,7 @@ def __init__(self, request_processor, fspath):

self.renderers = {} # mapping of media type to Renderer objects
self.available_types = [] # ordered sequence of media types
with open(fspath, 'rb') as fh:
with open_resource(request_processor.www_root, fspath) as fh:
raw = fh.read()
pages = self.parse_into_pages(_decode(raw))
self.compile_pages(pages)
Expand Down
13 changes: 13 additions & 0 deletions aspen/testing.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
from __future__ import print_function
from __future__ import unicode_literals

from contextlib import contextmanager
import os
import sys

Expand Down Expand Up @@ -124,3 +125,15 @@ def hit(self, path, querystring='', accept_header=None, want=None, **context):
if want is None:
return output
return resolve_want(locals(), want)


@contextmanager
def chdir(path):
"""A context manager that temporarily changes the working directory.
"""
back_to = os.getcwd()
os.chdir(path)
try:
yield back_to
finally:
os.chdir(back_to)
12 changes: 7 additions & 5 deletions tests/test_configuration.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
from pytest import raises, mark

from aspen.request_processor import ConfigurationError, RequestProcessor
from aspen.testing import chdir


def test_defaults_to_defaults(harness):
Expand Down Expand Up @@ -42,11 +43,12 @@ def test_ConfigurationError_raised_if_no_cwd(harness):
@mark.skipif(sys.platform == 'win32',
reason="Windows file locking makes this fail")
def test_ConfigurationError_NOT_raised_if_no_cwd_but_do_have__www_root(harness):
foo = os.getcwd()
os.chdir(harness.fs.project.resolve(''))
os.rmdir(os.getcwd())
rp = RequestProcessor(www_root=foo)
assert rp.www_root == foo
project_root = harness.fs.project.root
www_root = harness.fs.www.root
with chdir(project_root):
os.rmdir(project_root)
rp = RequestProcessor(www_root=www_root)
assert rp.www_root == www_root

def test_processor_sees_root_option(harness):
rp = RequestProcessor(www_root=harness.fs.project.resolve(''))
Expand Down
12 changes: 7 additions & 5 deletions tests/test_request_processor.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,15 @@
import os

from aspen.request_processor import RequestProcessor
from aspen.testing import chdir


def test_basic():
rp = RequestProcessor()
expected = os.getcwd()
actual = rp.www_root
assert actual == expected
def test_basic(harness):
with chdir(harness.fs.www.root):
rp = RequestProcessor()
expected = os.getcwd()
actual = rp.www_root
assert actual == expected

def test_processor_can_process(harness):
output = harness.simple('[---]\n[---]\nGreetings, program!', 'index.html.spt')
Expand Down

0 comments on commit 05abb82

Please sign in to comment.