diff --git a/browser_devtools/Dockerfile b/browser_devtools/Dockerfile new file mode 100644 index 0000000..2cb8647 --- /dev/null +++ b/browser_devtools/Dockerfile @@ -0,0 +1,12 @@ +FROM node:lts-alpine + +WORKDIR /usr/app + +COPY websockets.js ./ + +RUN npm init -y +RUN npm install ws + +EXPOSE 8080 + +CMD ["node", "websockets.js"] \ No newline at end of file diff --git a/browser_devtools/apps.py b/browser_devtools/apps.py index 8681842..85173a6 100644 --- a/browser_devtools/apps.py +++ b/browser_devtools/apps.py @@ -5,4 +5,6 @@ class BrowserDevtoolsConfig(AppConfig): default_auto_field = "django.db.models.BigAutoField" name = "browser_devtools" has_tasks = True + use_docker = True + container_protocol = "ws" display_name = "Browser Devtools" diff --git a/browser_devtools/static/browser_devtools/courier_intercept.js b/browser_devtools/static/browser_devtools/courier_intercept.js new file mode 100644 index 0000000..51d7e77 --- /dev/null +++ b/browser_devtools/static/browser_devtools/courier_intercept.js @@ -0,0 +1,3 @@ +const wsUri = `ws://${window.location.host}/envs/browser_devtools/`; +console.log(wsUri); +const websocket = new WebSocket(wsUri); \ No newline at end of file diff --git a/browser_devtools/templates/browser_devtools/courier_intercept.html b/browser_devtools/templates/browser_devtools/courier_intercept.html new file mode 100644 index 0000000..ffd0104 --- /dev/null +++ b/browser_devtools/templates/browser_devtools/courier_intercept.html @@ -0,0 +1,14 @@ +{% extends "ctef_web/task.html" %} + +{% block head %} +{% load static %} + +{% endblock %} + +{% block task %} + +{% load task %} + +

Test!

+ +{% endblock %} \ No newline at end of file diff --git a/browser_devtools/views.py b/browser_devtools/views.py index 15dae20..1fd0b7d 100644 --- a/browser_devtools/views.py +++ b/browser_devtools/views.py @@ -7,3 +7,7 @@ @define_task(name="Watch your head!", clues="clues/watch_your_head.md") def watch_your_head(request: HttpRequest, context): return render(request, "browser_devtools/watch_your_head.html", context) + +@define_task(name="Courier intercept") +def courier_intercept(request: HttpRequest, context): + return render(request, "browser_devtools/courier_intercept.html", context) \ No newline at end of file diff --git a/browser_devtools/websockets.js b/browser_devtools/websockets.js new file mode 100644 index 0000000..ce8f91a --- /dev/null +++ b/browser_devtools/websockets.js @@ -0,0 +1,13 @@ +import { WebSocketServer } from 'ws'; + +const wss = new WebSocketServer({ port: 8080 }); + +wss.on('connection', function connection(ws) { + ws.on('error', console.error); + + ws.on('message', function message(data) { + console.log('received: %s', data); + }); + + ws.send('something'); +}); \ No newline at end of file diff --git a/ctef/asgi.py b/ctef/asgi.py index 4a2a060..aeaf4b2 100644 --- a/ctef/asgi.py +++ b/ctef/asgi.py @@ -19,12 +19,19 @@ asgi_application = get_asgi_application() from ctef_core.routing import websocket_urlpatterns +from . import urls +# Add the websocket url patterns from the containers +websocket_urlpatterns = websocket_urlpatterns + urls.wspatterns +print(websocket_urlpatterns) + +# TODO: Adapt this to forward websocket connections to container proxy as well +# https://channels.readthedocs.io/en/latest/topics/routing.html# application = ProtocolTypeRouter( { "http": asgi_application, "websocket": AllowedHostsOriginValidator( AuthMiddlewareStack(URLRouter(websocket_urlpatterns)) - ) + ), } ) diff --git a/ctef/urls.py b/ctef/urls.py index 1ae912a..a06a108 100644 --- a/ctef/urls.py +++ b/ctef/urls.py @@ -29,6 +29,8 @@ path("admin/", admin.site.urls), ] +wspatterns = [] + # Import and register all task views in installed CTF modules. from ctef_core.common import fetch_ctf_modules from ctef_web.views import config_hints_view @@ -49,7 +51,17 @@ container = client.containers.get(container_name) IPAddress = container.attrs["NetworkSettings"]["IPAddress"] - urlpatterns.append( + try: + if ctf_module.container_protocol == "ws": + pattern_collection = wspatterns + else: + pattern_collection = urlpatterns + except: + pattern_collection = urlpatterns + + # TODO: Maybe just writing a Proxy consumer would be the easiest. + # Or maybe extend ProxyView? + pattern_collection.append( re_path( f"envs/{module_name}/(?P.*)", ProxyView.as_view(upstream=f"http://{IPAddress}:8080"), @@ -57,7 +69,7 @@ ) except (AttributeError, docker.errors.NotFound) as e: pass - + # Import MODULE_TASKS after being populated by the previous section imports. from ctef_core.decorators import MODULE_TASKS diff --git a/ctef_core/consumers.py b/ctef_core/consumers.py index a849663..b35e0b9 100644 --- a/ctef_core/consumers.py +++ b/ctef_core/consumers.py @@ -1,5 +1,7 @@ -import json -from channels.generic.websocket import WebsocketConsumer +import json, websockets, asyncio + +from channels.exceptions import DenyConnection +from channels.generic.websocket import WebsocketConsumer, AsyncWebsocketConsumer from .models import Task from .process import CTFProcess @@ -47,3 +49,44 @@ def receive(self, text_data=None, bytes_data=None): def send_bytes(self, bytes: bytes): decoded = bytes.decode() self.send(decoded) + + +class ProxyConsumer(AsyncWebsocketConsumer): + """ + Django Channels websockets consumer that forwards websocket connections to a Docker container. + Based on https://gist.github.com/brianglass/e3184341afe63ed348144753ee62dce5 + """ + + def __init__(self, endpoint: str, *args: object, **kwargs: object) -> None: + """Create a new proxy consumer for a websocket server at the specified endpoint""" + super().__init__(*args, **kwargs) + self.endpoint = endpoint + self.upstream_socket = None + self.forward_upstream_task = None + + async def connect(self) -> None: + # Attempt to connect to the endpoint to proxy + try: + self.upstream_socket = await websockets.connect(self.endpoint) + except websockets.InvalidURI: + print("The container endpoint was not reachable.") + raise DenyConnection() + + # Accept the incoming connection with the same subprotocol. + await self.accept(self.upstream_socket.subprotocol) + + self.forward_upstream_task = asyncio.create_task(self.forward_upstream()) + + async def forward_upstream(self): + try: + async for data in self.upstream_socket: + if hasattr(data, "decode"): + await self.send(bytes_data=data) + else: + await self.send(text_data=data) + except asyncio.exceptions.CancelledError: + # This is triggered by the consumer itself when the client connection is terminating. + await self.upstream_socket.close() + except websockets.ConnectionClosedError: + # The target probably closed the connection. + await self.close() diff --git a/requirements.txt b/requirements.txt index 6ac3876..95a946c 100644 --- a/requirements.txt +++ b/requirements.txt @@ -38,5 +38,6 @@ Twisted==24.7.0 txaio==23.1.1 typing_extensions==4.12.2 urllib3==2.5.0 +websockets==15.0.1 wheel==0.44.0 zope.interface==7.0.3