diff --git a/libpathod/app.py b/libpathod/app.py index 858c1d865..c3ce9991a 100644 --- a/libpathod/app.py +++ b/libpathod/app.py @@ -1,6 +1,7 @@ import logging import pprint import cStringIO +import copy from flask import Flask, jsonify, render_template, request, abort, make_response import version, language, utils from netlib import http_uastrings @@ -10,6 +11,7 @@ logging.basicConfig(level="DEBUG") def make_app(noapi): app = Flask(__name__) + # app.debug = True if not noapi: @app.route('/api/info') @@ -144,20 +146,17 @@ def make_app(noapi): c = app.config["pathod"].check_policy( safe, - app.config["pathod"].request_settings + app.config["pathod"].settings ) if c: args["error"] = c return render(template, False, **args) if is_request: - language.serve( - safe, - s, - app.config["pathod"].request_settings, - request_host = "example.com" - ) + set = copy.copy(app.config["pathod"].settings) + set.request_host = "example.com" + language.serve(safe, s, set) else: - language.serve(safe, s, app.config["pathod"].request_settings) + language.serve(safe, s, app.config["pathod"].settings) args["output"] = utils.escape_unprintables(s.getvalue()) return render(template, False, **args) diff --git a/libpathod/language.py b/libpathod/language.py index 29d2ade89..28976a294 100644 --- a/libpathod/language.py +++ b/libpathod/language.py @@ -15,6 +15,20 @@ BLOCKSIZE = 1024 TRUNCATE = 1024 +class Settings: + def __init__( + self, + staticdir = None, + unconstrained_file_access = False, + request_host = None, + websocket_key = None + ): + self.staticdir = staticdir + self.unconstrained_file_access = unconstrained_file_access + self.request_host = request_host + self.websocket_key = websocket_key + + def quote(s): quotechar = s[0] s = s[1:-1] @@ -22,7 +36,11 @@ def quote(s): return quotechar + s + quotechar -class FileAccessDenied(Exception): +class RenderError(Exception): + pass + + +class FileAccessDenied(RenderError): pass @@ -97,7 +115,7 @@ def write_values(fp, vals, actions, sofar=0, blocksize=BLOCKSIZE): return True -def serve(msg, fp, settings, **kwargs): +def serve(msg, fp, settings): """ fp: The file pointer to write to. @@ -107,7 +125,7 @@ def serve(msg, fp, settings, **kwargs): Calling this function may modify the object. """ - msg = msg.resolve(settings, **kwargs) + msg = msg.resolve(settings) started = time.time() vals = msg.values(settings) @@ -351,15 +369,16 @@ class ValueFile(_Token): return self def get_generator(self, settings): - uf = settings.get("unconstrained_file_access") - sd = settings.get("staticdir") - if not sd: + if not settings.staticdir: raise FileAccessDenied("File access disabled.") - sd = os.path.normpath(os.path.abspath(sd)) + sd = os.path.normpath(os.path.abspath(settings.staticdir)) s = os.path.expanduser(self.path) - s = os.path.normpath(os.path.abspath(os.path.join(sd, s))) - if not uf and not s.startswith(sd): + s = os.path.normpath( + os.path.abspath(os.path.join(settings.staticdir, s)) + ) + uf = settings.unconstrained_file_access + if not uf and not s.startswith(settings.staticdir): raise FileAccessDenied( "File access outside of configured directory" ) @@ -594,9 +613,28 @@ class Path(_Component): return Path(self.value.freeze(settings)) +class WS(_Component): + def __init__(self, value): + self.value = value + + @classmethod + def expr(klass): + spec = pp.Literal("ws") + spec = spec.setParseAction(lambda x: klass(*x)) + return spec + + def values(self, settings): + return "ws" + + def spec(self): + return "ws" + + def freeze(self, settings): + return self + + class Method(_Component): methods = [ - "ws", "get", "head", "post", @@ -797,29 +835,35 @@ class _Message(object): def __init__(self, tokens): self.tokens = tokens - def _get_tokens(self, klass): + def toks(self, klass): + """ + Fetch all tokens that are instances of klass + """ return [i for i in self.tokens if isinstance(i, klass)] - def _get_token(self, klass): - l = self._get_tokens(klass) + def tok(self, klass): + """ + Fetch first token that is an instance of klass + """ + l = self.toks(klass) if l: return l[0] @property def raw(self): - return bool(self._get_token(Raw)) + return bool(self.tok(Raw)) @property def actions(self): - return self._get_tokens(_Action) + return self.toks(_Action) @property def body(self): - return self._get_token(Body) + return self.tok(Body) @property def headers(self): - return self._get_tokens(_Header) + return self.toks(_Header) def length(self, settings): """ @@ -883,8 +927,8 @@ class _Message(object): vals.append(self.body.value.get_generator(settings)) return vals - def freeze(self, settings, **kwargs): - r = self.resolve(settings, **kwargs) + def freeze(self, settings): + r = self.resolve(settings) return self.__class__([i.freeze(settings) for i in r.tokens]) def __repr__(self): @@ -908,17 +952,26 @@ class Response(_Message): ) logattrs = ["code", "reason", "version", "body"] + @property + def ws(self): + return self.tok(WS) + @property def code(self): - return self._get_token(Code) + return self.tok(Code) @property def reason(self): - return self._get_token(Reason) + return self.tok(Reason) def preamble(self, settings): l = [self.version, " "] - l.extend(self.code.values(settings)) + if self.code: + l.extend(self.code.values(settings)) + code = int(self.code.code) + elif self.ws: + l.extend(Code(101).values(settings)) + code = 101 l.append(" ") if self.reason: l.extend(self.reason.values(settings)) @@ -926,7 +979,7 @@ class Response(_Message): l.append( LiteralGenerator( http_status.RESPONSES.get( - int(self.code.code), + code, "Unknown code" ) ) @@ -935,6 +988,22 @@ class Response(_Message): def resolve(self, settings): tokens = self.tokens[:] + if self.ws: + if not settings.websocket_key: + raise RenderError( + "No websocket key - have we seen a client handshake?" + ) + if not self.code: + tokens.insert( + 1, + Code(101) + ) + hdrs = websockets.server_handshake_headers(settings.websocket_key) + for i in hdrs.lst: + if not utils.get_header(i[0], self.headers): + tokens.append( + Header(ValueLiteral(i[0]), ValueLiteral(i[1])) + ) if not self.raw: if not utils.get_header("Content-Length", self.headers): if not self.body: @@ -958,7 +1027,12 @@ class Response(_Message): atom = pp.MatchFirst(parts) resp = pp.And( [ - Code.expr(), + pp.MatchFirst( + [ + WS.expr() + pp.Optional(Sep + Code.expr()), + Code.expr(), + ] + ), pp.ZeroOrMore(Sep + atom) ] ) @@ -982,17 +1056,21 @@ class Request(_Message): ) logattrs = ["method", "path", "body"] + @property + def ws(self): + return self.tok(WS) + @property def method(self): - return self._get_token(Method) + return self.tok(Method) @property def path(self): - return self._get_token(Path) + return self.tok(Path) @property def pathodspec(self): - return self._get_token(PathodSpec) + return self.tok(PathodSpec) def preamble(self, settings): v = self.method.values(settings) @@ -1004,10 +1082,14 @@ class Request(_Message): v.append(self.version) return v - def resolve(self, settings, **kwargs): + def resolve(self, settings): tokens = self.tokens[:] - if self.method.string().lower() == "ws": - tokens[0] = Method("get") + if self.ws: + if not self.method: + tokens.insert( + 1, + Method("get") + ) for i in websockets.client_handshake_headers().lst: if not utils.get_header(i[0], self.headers): tokens.append( @@ -1023,13 +1105,12 @@ class Request(_Message): ValueLiteral(str(length)), ) ) - request_host = kwargs.get("request_host") - if request_host: + if settings.request_host: if not utils.get_header("Host", self.headers): tokens.append( Header( ValueLiteral("Host"), - ValueLiteral(request_host) + ValueLiteral(settings.request_host) ) ) intermediate = self.__class__(tokens) @@ -1043,7 +1124,12 @@ class Request(_Message): atom = pp.MatchFirst(parts) resp = pp.And( [ - Method.expr(), + pp.MatchFirst( + [ + WS.expr() + pp.Optional(Sep + Method.expr()), + Method.expr(), + ] + ), Sep, Path.expr(), pp.ZeroOrMore(Sep + atom) diff --git a/libpathod/pathoc.py b/libpathod/pathoc.py index 0d8ec8f94..cf9be5b9d 100644 --- a/libpathod/pathoc.py +++ b/libpathod/pathoc.py @@ -108,9 +108,10 @@ class Pathoc(tcp.TCPClient): ignorecodes: Sequence of return codes to ignore """ tcp.TCPClient.__init__(self, address) - self.settings = dict( + self.settings = language.Settings( staticdir = os.getcwd(), unconstrained_file_access = True, + request_host = self.address.host ) self.ssl, self.sni = ssl, sni self.clientcert = clientcert @@ -201,15 +202,14 @@ class Pathoc(tcp.TCPClient): if self.showresp: self.rfile.start_log() try: - req = language.serve( - r, - self.wfile, - self.settings, - request_host = self.address.host - ) + req = language.serve(r, self.wfile, self.settings) self.wfile.flush() resp = list( - http.read_response(self.rfile, r.method.string(), None) + http.read_response( + self.rfile, + req["method"], + None + ) ) resp.append(self.sslinfo) resp = Response(*resp) @@ -290,7 +290,7 @@ def main(args): # pragma: nocover ) if args.explain or args.memo: playlist = [ - i.freeze(p.settings, request_host=p.address.host) for i in playlist + i.freeze(p.settings) for i in playlist ] if args.memo: newlist = [] diff --git a/libpathod/pathod.py b/libpathod/pathod.py index 1c23baaef..0c6267772 100644 --- a/libpathod/pathod.py +++ b/libpathod/pathod.py @@ -66,10 +66,10 @@ class PathodHandler(tcp.BaseHandler): self.sni = connection.get_servername() def serve_crafted(self, crafted): - c = self.server.check_policy(crafted, self.server.request_settings) + c = self.server.check_policy(crafted, self.server.settings) if c: err = language.make_error_response(c) - language.serve(err, self.wfile, self.server.request_settings) + language.serve(err, self.wfile, self.server.settings) log = dict( type="error", msg=c @@ -77,12 +77,12 @@ class PathodHandler(tcp.BaseHandler): return False, log if self.server.explain and not isinstance(crafted, language.PathodErrorResponse): - crafted = crafted.freeze(self.server.request_settings) + crafted = crafted.freeze(self.server.settings) self.info(">> Spec: %s" % crafted.spec()) response_log = language.serve( crafted, self.wfile, - self.server.request_settings + self.server.settings ) if response_log["disconnect"]: return False, response_log @@ -199,7 +199,7 @@ class PathodHandler(tcp.BaseHandler): return again, retlog elif self.server.noweb: crafted = language.make_error_response("Access Denied") - language.serve(crafted, self.wfile, self.server.request_settings) + language.serve(crafted, self.wfile, self.server.settings) return False, dict( type="error", msg="Access denied: web interface disabled" @@ -323,6 +323,10 @@ class Pathod(tcp.TCPServer): self.logid = 0 self.anchors = anchors + self.settings = language.Settings( + staticdir = self.staticdir + ) + def check_policy(self, req, settings): """ A policy check that verifies the request size is withing limits. @@ -337,12 +341,6 @@ class Pathod(tcp.TCPServer): return "Pauses have been disabled." return False - @property - def request_settings(self): - return dict( - staticdir=self.staticdir - ) - def handle_client_connection(self, request, client_address): h = PathodHandler(request, client_address, self) try: diff --git a/libpathod/templates/docs_lang.html b/libpathod/templates/docs_lang.html index 4ed7f151d..e67b13c56 100644 --- a/libpathod/templates/docs_lang.html +++ b/libpathod/templates/docs_lang.html @@ -11,6 +11,7 @@
Requests and responses can be decorated with the ws prefix to + create a websockets client or server handshake. Since the websocket + specifier implies a request method (GET) and a response code (102), + these can optionally be omitted. All other request and response + features can be applied, and websocket-specific headers can be + over-ridden explicitly.
+ +ws:[method:]path:[colon-separated list of features]+ +
This will generate a wsocket client handshake with a GET method:
+ +ws:/+ +
This will do the same, but using the (invalid) PUT method:
+ +ws:put:/+ + +
ws[:code:][colon-separated list of features]+ +
This will generate a simple protocol acceptance with a 101 response + code:
+ +ws+ +
This will do the same, but using the (invalid) 202 code:
+ +ws:202+ +