CVE-2023-38501: Improper Neutralization of Input During Web Page Generation ('Cross-site Scripting') (CWE-79)
value: copyparty is file server software. Prior to version 1.8.7, the application contains a reflected cross-site scripting via URL-parameter `?k304=...` and `?setck=...`. The worst-case outcome of this is being able to move or delete existing files on the server, or upload new files, using the account of the person who clicks the malicious link. It is recommended to change the passwords of one's copyparty accounts, unless one have inspected one's logs and found no trace of attacks. Version 1.8.7 contains a patch for the issue.
CWE description: The product does not neutralize or incorrectly neutralizes user-controllable input before it is placed in output that is used as a web page that is served to other users.
CVSS v3 base score: 6.1
Published: 2023-07-25T22:15Z
Repository: https://github.com/9001/copyparty
Language: Python
Files changed: copyparty/httpcli.py, copyparty/httpsrv.py, copyparty/util.py
Commit date: 2023-07-23 10:55:08 +0000
Commit message: fix GHSA-f54q-j679-p9hh: reflected-XSS in cookie-setters;
it was possible to set cookie values which contained newlines,
thus terminating the http header and bleeding into the body.

We now disallow control-characters in queries,
but still allow them in paths, as copyparty supports
filenames containing newlines and other mojibake.

The changes in `set_k304` are not necessary in fixing the vulnerability,
but makes the behavior more correct.

Diff: diff --git a/copyparty/httpcli.py b/copyparty/httpcli.py
index 97c04bbd..476c5ffb 100644
--- a/copyparty/httpcli.py
+++ b/copyparty/httpcli.py
@@ -335,10 +335,19 @@ class HttpCli(object):
             vpath = undot(self.req)
         else:
             vpath, arglist = self.req.split("?", 1)
             self.trailing_slash = vpath.endswith("/")
             vpath = undot(vpath)
+
+            zs = unquotep(arglist)
+            m = self.conn.hsrv.ptn_cc.search(zs)
+            if m:
+                hit = zs[m.span()[0] :]
+                t = "malicious user; Cc in query [{}] => [{!r}]"
+                self.log(t.format(self.req, hit), 1)
+                return False
+
             for k in arglist.split("&"):
                 if "=" in k:
                     k, zs = k.split("=", 1)
                     uparam[k.lower()] = unquotep(zs.strip().replace("+", " "))
                 else:
@@ -486,10 +495,13 @@ class HttpCli(object):
                 pex = Pebkac(500)
             else:
                 pex: Pebkac = ex  # type: ignore
 
             try:
+                if pex.code == 999:
+                    return False
+
                 post = self.mode in ["POST", "PUT"] or "content-length" in self.headers
                 if not self._check_nonfatal(pex, post):
                     self.keepalive = False
 
                 em = str(ex)
@@ -584,10 +596,18 @@ class HttpCli(object):
         self.out_headers["Content-Type"] = mime
 
         for k, zs in list(self.out_headers.items()) + self.out_headerlist:
             response.append("%s: %s" % (k, zs))
 
+        for zs in response:
+            m = self.conn.hsrv.ptn_cc.search(zs)
+            if m:
+                hit = zs[m.span()[0] :]
+                t = "malicious user; Cc in out-hdr {!r} => [{!r}]"
+                self.log(t.format(zs, hit), 1)
+                raise Pebkac(999)
+
         try:
             # best practice to separate headers and body into different packets
             self.s.sendall("\r\n".join(response).encode("utf-8") + b"\r\n\r\n")
         except:
             raise Pebkac(400, "client d/c while replying headers")
@@ -783,11 +803,11 @@ class HttpCli(object):
                 return True
 
             path_base = os.path.join(self.E.mod, "web")
             static_path = absreal(os.path.join(path_base, self.vpath[5:]))
             if not static_path.startswith(path_base):
-                t = "attempted path traversal [{}] => [{}]"
+                t = "malicious user; attempted path traversal [{}] => [{}]"
                 self.log(t.format(self.vpath, static_path), 1)
                 self.tx_404()
                 return False
 
             return self.tx_file(static_path)
@@ -3075,11 +3095,18 @@ class HttpCli(object):
         )
         self.reply(html.encode("utf-8"))
         return True
 
     def set_k304(self) -> bool:
-        ck = gencookie("k304", self.uparam["k304"], self.args.R, False, 86400 * 299)
+        v = self.uparam["k304"].lower()
+        if v == "y":
+            dur = 86400 * 299
+        else:
+            dur = None
+            v = "x"
+
+        ck = gencookie("k304", v, self.args.R, False, dur)
         self.out_headerlist.append(("Set-Cookie", ck))
         self.redirect("", "?h#cc")
         return True
 
     def setck(self) -> bool:
diff --git a/copyparty/httpsrv.py b/copyparty/httpsrv.py
index f980a690..a9f55bc1 100644
--- a/copyparty/httpsrv.py
+++ b/copyparty/httpsrv.py
@@ -2,10 +2,11 @@
 from __future__ import print_function, unicode_literals
 
 import base64
 import math
 import os
+import re
 import socket
 import sys
 import threading
 import time
 
@@ -136,10 +137,12 @@ class HttpSrv(object):
         jn = ["splash", "svcs", "browser", "browser2", "msg", "md", "mde", "cf"]
         self.j2 = {x: env.get_template(x + ".html") for x in jn}
         zs = os.path.join(self.E.mod, "web", "deps", "prism.js.gz")
         self.prism = os.path.exists(zs)
 
+        self.ptn_cc = re.compile(r"[\x00-\x1f]")
+
         self.mallow = "GET HEAD POST PUT DELETE OPTIONS".split()
         if not self.args.no_dav:
             zs = "PROPFIND PROPPATCH LOCK UNLOCK MKCOL COPY MOVE"
             self.mallow += zs.split()
 
diff --git a/copyparty/util.py b/copyparty/util.py
index d3eb06d1..fab74b79 100644
--- a/copyparty/util.py
+++ b/copyparty/util.py
@@ -169,10 +169,11 @@ HTTPCODE = {
     423: "Locked",
     429: "Too Many Requests",
     500: "Internal Server Error",
     501: "Not Implemented",
     503: "Service Unavailable",
+    999: "MissingNo",
 }
 
 
 IMPLICATIONS = [
     ["e2dsa", "e2ds"],

Vulnerable code:                 t = "attempted path traversal [{}] => [{}]"
        ck = gencookie("k304", self.uparam["k304"], self.args.R, False, 86400 * 299)
Fixed code: 
            zs = unquotep(arglist)
            m = self.conn.hsrv.ptn_cc.search(zs)
            if m:
                hit = zs[m.span()[0] :]
                t = "malicious user; Cc in query [{}] => [{!r}]"
                self.log(t.format(self.req, hit), 1)
                return False

                if pex.code == 999:
                    return False

        for zs in response:
            m = self.conn.hsrv.ptn_cc.search(zs)
            if m:
                hit = zs[m.span()[0] :]
                t = "malicious user; Cc in out-hdr {!r} => [{!r}]"
                self.log(t.format(zs, hit), 1)
                raise Pebkac(999)

                t = "malicious user; attempted path traversal [{}] => [{}]"
        v = self.uparam["k304"].lower()
        if v == "y":
            dur = 86400 * 299
        else:
            dur = None
            v = "x"

        ck = gencookie("k304", v, self.args.R, False, dur)
import re
        self.ptn_cc = re.compile(r"[\x00-\x1f]")

    999: "MissingNo",