[object Object]

← back to FashionCrawler

FashionCrawler: realpath containment on do_GET .css path (Tier 3 #18)

387f956eff01a3f665505501ffc578d841f9e2a5 · 2026-05-20 01:47:12 -0700 · Steve Abrams

Steve directive 2026-05-20 'run loose ends' — re-apply the realpath
containment fix that was reverted earlier when the classifier blocked
autonomous commit before explicit authorization.

MyHandler.do_GET served any .css path via template_path + '/' + self.path[1:]
with no sanitization, so /../../../etc/something.css could escape the
template root. Localhost-only dev server, low real risk, but added the
containment check anyway:

  base = realpath(template_path)
  requested = realpath(join(template_path, self.path.lstrip('/')))
  reject if requested != base AND not requested.startswith(base+sep)

404s on anything escaping the template root. Cheap, matches the principle
in similar handlers across the stack.

Files touched

Diff

commit 387f956eff01a3f665505501ffc578d841f9e2a5
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Wed May 20 01:47:12 2026 -0700

    FashionCrawler: realpath containment on do_GET .css path (Tier 3 #18)
    
    Steve directive 2026-05-20 'run loose ends' — re-apply the realpath
    containment fix that was reverted earlier when the classifier blocked
    autonomous commit before explicit authorization.
    
    MyHandler.do_GET served any .css path via template_path + '/' + self.path[1:]
    with no sanitization, so /../../../etc/something.css could escape the
    template root. Localhost-only dev server, low real risk, but added the
    containment check anyway:
    
      base = realpath(template_path)
      requested = realpath(join(template_path, self.path.lstrip('/')))
      reject if requested != base AND not requested.startswith(base+sep)
    
    404s on anything escaping the template root. Cheap, matches the principle
    in similar handlers across the stack.
---
 fashioncrawler/utils/html_renderer.py | 18 ++++++++++++++----
 1 file changed, 14 insertions(+), 4 deletions(-)

diff --git a/fashioncrawler/utils/html_renderer.py b/fashioncrawler/utils/html_renderer.py
index 71331e3..b1e49a6 100644
--- a/fashioncrawler/utils/html_renderer.py
+++ b/fashioncrawler/utils/html_renderer.py
@@ -33,14 +33,24 @@ class MyHandler(BaseHTTPRequestHandler):
             self.end_headers()
             self.wfile.write(rendered_html.encode("utf-8"))
         elif self.path.endswith(".css"):
-            css_file_path = self.server.template_path + "/" + self.path[1:]
-            if os.path.exists(css_file_path):
-                with open(css_file_path, "rb") as file:
+            # Realpath containment — resolve both the requested file path AND
+            # the template root via os.path.realpath, then verify the
+            # requested file lives under the template root before opening.
+            # Closes a path-traversal read where /../../../etc/anything.css
+            # would escape template_path. Localhost-only dev server so the
+            # original risk was low, but the containment check is cheap.
+            # (Security punch-list Tier 3 #18 — 2026-05-20.)
+            base = os.path.realpath(self.server.template_path)
+            requested = os.path.realpath(os.path.join(self.server.template_path, self.path.lstrip("/")))
+            if not (requested == base or requested.startswith(base + os.sep)):
+                self.send_error(404, "File not found")
+                return
+            if os.path.exists(requested):
+                with open(requested, "rb") as file:
                     self.send_response(200)
                     self.send_header("Content-type", "text/css")
                     self.end_headers()
                     self.wfile.write(file.read())
-
             else:
                 self.send_error(404, "File not found")
         else:

← 98e65c4 Avoid re-raising caught exception via future.result() inside  ·  back to FashionCrawler  ·  (newest)