new file mode 100644
@@ -0,0 +1,255 @@
+From 6bd9c4e0fc10ae743006cd6a97543d15050f9594 Mon Sep 17 00:00:00 2001
+From: Claude <noreply@anthropic.com>
+Date: Tue, 15 Sep 2026 12:54:25 +0000
+Subject: [PATCH] web: Fix subclasses that do not set allowed_symlink_directory
+
+allowed_symlink_directory was added in 6.5.9 as an attribute assigned in
+initialize(). Projects that subclass StaticFileHandler with their own
+initialize() that does not call ours never set it, so every static file
+request failed with
+
+ AttributeError: 'FileFindHandler' object has no attribute
+ 'allowed_symlink_directory'
+
+Jupyter is the most prominent example, and it also shows why the
+attribute cannot simply default to self.root: its FileFindHandler
+searches a list of directories, so self.root is a tuple of paths, and
+its validate_absolute_path passes the one that matched down to us.
+
+Give allowed_symlink_directory a class-level default of None, which
+subclasses inherit whatever their initialize() does, and resolve that
+default in validate_absolute_path against the root it was given.
+_resolve_symlink_target becomes a static method taking only the values
+its caller settled on, so that it cannot read a directory from self that
+differs from the one the other path checks used.
+
+Fixes #3724
+
+CVE: CVE-2026-103263
+Upstream-Status: Backport [https://github.com/tornadoweb/tornado/commit/6bd9c4e0fc10ae743006cd6a97543d15050f9594]
+
+Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
+Claude-Session: https://claude.ai/code/session_01Brgh48sZusx4pzHwygor7q
+Signed-off-by: Yogita Urade <yurade@cisco.com>
+---
+ tornado/test/web_test.py | 97 ++++++++++++++++++++++++++++++++++++++++
+ tornado/web.py | 40 +++++++++++++----
+ 2 files changed, 128 insertions(+), 9 deletions(-)
+
+diff --git a/tornado/test/web_test.py b/tornado/test/web_test.py
+index 4ac6fb98..fba23a7c 100644
+--- a/tornado/test/web_test.py
++++ b/tornado/test/web_test.py
+@@ -1676,6 +1676,103 @@ class StaticFileSymlinkTest(WebTestCase):
+ self.assertEqual(response.body, b"secret")
+
+
++class MultiRootStaticFileHandler(StaticFileHandler):
++ """A subclass that searches a list of directories.
++
++ It replaces ``initialize`` without calling ``super().initialize()``, so
++ ``allowed_symlink_directory`` is never set, and it puts something other
++ than a string in ``self.root``, passing the directory that matched to
++ ``validate_absolute_path``. The symlink check must run against that
++ directory. Jupyter's ``FileFindHandler`` is built this way.
++ """
++
++ root: tuple # type: ignore[assignment]
++
++ def initialize(self, path): # type: ignore[override]
++ self.root = tuple(os.path.abspath(p) + os.path.sep for p in path)
++ self.default_filename = None
++
++ @classmethod
++ def get_absolute_path(cls, roots, path): # type: ignore[override]
++ for root in roots:
++ candidate = os.path.abspath(os.path.join(root, path))
++ if os.path.exists(candidate):
++ return candidate
++ return os.path.abspath(os.path.join(roots[0], path))
++
++ def validate_absolute_path(self, root, absolute_path): # type: ignore[override]
++ for candidate in self.root:
++ if (absolute_path + os.path.sep).startswith(candidate):
++ root = candidate
++ break
++ return super().validate_absolute_path(root, absolute_path)
++
++
++@unittest.skipIf(os.name != "posix", "non-posix OS")
++class StaticFileMultiRootTest(WebTestCase):
++ """Subclasses that search several directories and never set
++ ``allowed_symlink_directory`` must keep working (issue #3724).
++ """
++
++ def setUp(self):
++ self.tmpdir = tempfile.mkdtemp()
++ self.addCleanup(shutil.rmtree, self.tmpdir)
++ self.root1 = os.path.join(self.tmpdir, "static1")
++ self.root2 = os.path.join(self.tmpdir, "static2")
++ self.outside = os.path.join(self.tmpdir, "outside")
++ for d in (self.root1, self.root2, self.outside):
++ os.mkdir(d)
++ with open(os.path.join(self.root1, "one.txt"), "w", encoding="utf-8") as f:
++ f.write("one")
++ with open(os.path.join(self.root2, "two.txt"), "w", encoding="utf-8") as f:
++ f.write("two")
++ with open(os.path.join(self.outside, "secret.txt"), "w", encoding="utf-8") as f:
++ f.write("secret")
++ os.symlink(
++ os.path.join(self.root1, "one.txt"),
++ os.path.join(self.root2, "internal.txt"),
++ )
++ os.symlink(
++ os.path.join(self.outside, "secret.txt"),
++ os.path.join(self.root1, "link.txt"),
++ )
++ super().setUp()
++
++ def get_handlers(self):
++ return [
++ (
++ "/multi/(.*)",
++ MultiRootStaticFileHandler,
++ dict(path=[self.root1, self.root2]),
++ )
++ ]
++
++ def get_app_kwargs(self):
++ return dict()
++
++ def test_first_root(self):
++ response = self.fetch("/multi/one.txt")
++ self.assertEqual(response.code, 200)
++ self.assertEqual(response.body, b"one")
++
++ def test_second_root(self):
++ response = self.fetch("/multi/two.txt")
++ self.assertEqual(response.code, 200)
++ self.assertEqual(response.body, b"two")
++
++ def test_symlink_to_other_root_rejected(self):
++ # Each root is validated on its own: a link from one search directory
++ # into another is still an escape from the directory it lives in.
++ with ExpectLog(gen_log, ".*is not in root static directory"):
++ response = self.fetch("/multi/internal.txt")
++ self.assertEqual(response.code, 403)
++
++ def test_symlink_escaping_roots_rejected(self):
++ with ExpectLog(gen_log, ".*is not in root static directory"):
++ response = self.fetch("/multi/link.txt")
++ self.assertEqual(response.code, 403)
++
++
+ class StaticFileSymlinkedRootTest(WebTestCase):
+ """The root itself may be reached through a symlink; that must not make
+ every request look like an escape.
+diff --git a/tornado/web.py b/tornado/web.py
+index 2c8a1e1d..34315220 100644
+--- a/tornado/web.py
++++ b/tornado/web.py
+@@ -2794,6 +2794,13 @@ class StaticFileHandler(RequestHandler):
+ _static_hashes = {} # type: Dict[str, Optional[str]]
+ _lock = threading.Lock() # protects _static_hashes
+
++ # None means "not set", in which case the symlink check falls back to the
++ # directory being served. The default lives on the class, not in
++ # initialize(), because this attribute was introduced in a security patch
++ # and some projects (notably Jupyter) subclass StaticFileHandler with their
++ # own initialize() that does not call ours.
++ allowed_symlink_directory = None # type: Optional[str]
++
+ def initialize(
+ self,
+ path: str,
+@@ -2802,8 +2809,6 @@ class StaticFileHandler(RequestHandler):
+ ) -> None:
+ self.root = path
+ self.default_filename = default_filename
+- if allowed_symlink_directory is None:
+- allowed_symlink_directory = path
+ self.allowed_symlink_directory = allowed_symlink_directory
+
+ @classmethod
+@@ -3016,6 +3021,11 @@ class StaticFileHandler(RequestHandler):
+ # the requested path so a request to root/ will match.
+ if not (absolute_path + os.path.sep).startswith(root):
+ raise HTTPError(403, "%s is not in root static directory", self.path)
++ # Symlinks may point anywhere inside allowed_symlink_directory, which
++ # defaults to the directory being served. Resolve that default here,
++ # against the same root the checks above used, rather than in
++ # _resolve_symlink_target.
++ allowed_symlink_directory = self.allowed_symlink_directory or root
+ # The check above is on the path as written, which is cheap and
+ # rejects traversal without touching the filesystem. Resolve
+ # symlinks only once it has passed, so that the remaining checks -
+@@ -3024,7 +3034,9 @@ class StaticFileHandler(RequestHandler):
+ # cannot be merged: this one must stay on the unresolved path,
+ # because allowed_symlink_directory may be wider than the root and
+ # would then let a ../ traversal through.
+- absolute_path = self._resolve_symlink_target(absolute_path)
++ absolute_path = self._resolve_symlink_target(
++ absolute_path, allowed_symlink_directory, self.path
++ )
+ if os.path.isdir(absolute_path) and self.default_filename is not None:
+ # need to look at the request.path here for when path is empty
+ # but there is some prefix to the path that was already
+@@ -3049,7 +3061,9 @@ class StaticFileHandler(RequestHandler):
+ # on a path that has already escaped, or the difference between
+ # a redirect and a 403 would reveal whether an out-of-bounds
+ # directory exists.
+- absolute_path = self._resolve_symlink_target(absolute_path)
++ absolute_path = self._resolve_symlink_target(
++ absolute_path, allowed_symlink_directory, self.path
++ )
+ # Stat the resolved path once and keep the result, instead of
+ # letting os.path.exists, os.path.isfile and the later header
+ # generation each resolve the path again. Every one of those is a
+@@ -3066,15 +3080,23 @@ class StaticFileHandler(RequestHandler):
+ self._stat_result = stat_result
+ return absolute_path
+
+- def _resolve_symlink_target(self, absolute_path: str) -> str:
++ @staticmethod
++ def _resolve_symlink_target(
++ absolute_path: str, allowed_symlink_directory: str, path_message: str
++ ) -> str:
+ """Resolves symlinks in ``absolute_path`` and validates the result.
+
+ The path checks in `validate_absolute_path` operate on the path as
+ written, which says nothing about where a symlink points: a symlink
+ inside the static directory can name any file on the system. The
+ resolved path must therefore stay inside
+- ``allowed_symlink_directory``, which defaults to the directory being
+- served.
++ ``allowed_symlink_directory``.
++
++ This is a static method so that it can only see the values its
++ caller checked against: ``allowed_symlink_directory`` is the
++ directory `validate_absolute_path` settled on, which is not
++ necessarily ``self.allowed_symlink_directory`` or ``self.root``.
++ ``path_message`` is used only to build the error message.
+
+ Raises `HTTPError` (403) if the resolved path escapes that directory.
+
+@@ -3089,7 +3111,7 @@ class StaticFileHandler(RequestHandler):
+ # may itself be reached through a symlink (a static directory of
+ # /var/www that links to /srv/www, say). Comparing a resolved path
+ # against an unresolved directory would reject every request.
+- allowed_directory = os.path.realpath(self.allowed_symlink_directory)
++ allowed_directory = os.path.realpath(allowed_symlink_directory)
+ if not allowed_directory.endswith(os.path.sep):
+ # As in validate_absolute_path, the separator must not be
+ # doubled when the directory is the filesystem root.
+@@ -3105,7 +3127,7 @@ class StaticFileHandler(RequestHandler):
+ # Deliberately the same error as the check on the path as
+ # written, so that a caller cannot tell which way a path left
+ # the served directory.
+- raise HTTPError(403, "%s is not in root static directory", self.path)
++ raise HTTPError(403, "%s is not in root static directory", path_message)
+ return resolved_path
+
+ @classmethod
+--
+2.35.6
+
new file mode 100644
@@ -0,0 +1,422 @@
+From 437ab5f76565403b3403438d2e555d11f8128d32 Mon Sep 17 00:00:00 2001
+From: Claude <noreply@anthropic.com>
+Date: Sun, 13 Sep 2026 03:42:20 +0000
+Subject: [PATCH] web: Do not follow symlinks out of the static directory
+
+StaticFileHandler.validate_absolute_path checked the requested path
+against the configured root using os.path.abspath, which does not
+resolve symlinks. A symlink inside the static directory could therefore
+point anywhere on the filesystem and its contents would be served: both
+a symlinked file and a symlinked directory were confirmed to expose
+files outside the root.
+
+Resolve the path with os.path.realpath and require the result to remain
+inside a new allowed_symlink_directory, which defaults to the served
+path. Deployments that use such symlinks deliberately can widen it to a
+directory containing all of the intended targets. Both this check and
+the existing one on the path as written raise the same error, so a
+caller cannot tell which way a path left the served directory.
+
+The two checks are not merged, and the resolution stays after the check
+on the unresolved path. That check must operate on the path as written:
+when allowed_symlink_directory is wider than the root, comparing the
+resolved path instead would let a ../ traversal out of the root while
+rejecting the deliberate symlinks the setting exists to permit. Doing
+the cheap string comparison first also avoids walking the filesystem
+for a path that is already out of bounds.
+
+Because default_filename is appended after the path has been resolved,
+the joined path is resolved again: the directory containing an index
+file may be legitimate while the index file itself is a symlink
+pointing elsewhere. Deferring to a single resolution after that block
+would be cheaper, but the isdir() check and the redirect it can trigger
+would then run on a path that had already escaped, and the difference
+between a 301 and a 403 would reveal whether an out-of-bounds directory
+exists. A test pins this. The second resolution only happens for
+directory-index requests; a request for a file resolves once.
+
+realpath() is applied to the allowed directory as well as to the
+requested path, so that serving a directory that is itself reached
+through a symlink continues to work instead of rejecting every request.
+strict=True is not used: it is unavailable on Python 3.9, and a path
+that does not exist resolves to itself and is then reported as a 404.
+
+This check reflects the filesystem at one instant, and a path swapped
+for a symlink afterwards would not be caught; closing that race fully
+would mean holding descriptors across the whole operation. Short of
+that, the validated path is now stat'ed once and the result reused for
+the Content-Length and Last-Modified headers, replacing the separate
+os.path.exists and os.path.isfile calls. That cuts the number of times
+a single request resolves the path from ten to six, and ensures the
+headers describe the file that was validated. The residual race is
+documented rather than papered over.
+
+CVE: CVE-2026-103263
+Upstream-Status: Backport [https://github.com/tornadoweb/tornado/commit/437ab5f76565403b3403438d2e555d11f8128d32]
+
+Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
+Claude-Session: https://claude.ai/code/session_01JD7LUuFMdyPJB1Dwdq6fPg
+Signed-off-by: Yogita Urade <yurade@cisco.com>
+---
+ tornado/test/web_test.py | 170 +++++++++++++++++++++++++++++++++++++++
+ tornado/web.py | 100 ++++++++++++++++++++++-
+ 2 files changed, 267 insertions(+), 3 deletions(-)
+
+diff --git a/tornado/test/web_test.py b/tornado/test/web_test.py
+index 27e7fcfe..8a014d22 100644
+--- a/tornado/test/web_test.py
++++ b/tornado/test/web_test.py
+@@ -54,7 +54,9 @@ import itertools
+ import logging
+ import os
+ import re
++import shutil
+ import socket
++import tempfile
+ import typing # noqa: F401
+ import unittest
+ import urllib.parse
+@@ -1510,6 +1512,14 @@ class StaticFileTest(WebTestCase):
+ response = self.get_and_head("/static/blarg")
+ self.assertEqual(response.code, 404)
+
++ def test_static_directory_without_default_filename(self):
++ # Without a default_filename, a directory is not servable. This
++ # shares the stat() that validate_absolute_path performs, so it is
++ # worth pinning the status it produces.
++ with ExpectLog(gen_log, ".*is not a file"):
++ response = self.get_and_head("/static/dir/")
++ self.assertEqual(response.code, 403)
++
+ def test_path_traversal_protection(self):
+ # curl_httpclient processes ".." on the client side, so we
+ # must test this with simple_httpclient.
+@@ -1536,6 +1546,166 @@ class StaticFileTest(WebTestCase):
+ self.assertEqual(response.code, 200)
+
+
++@unittest.skipIf(os.name != "posix", "non-posix OS")
++class StaticFileSymlinkTest(WebTestCase):
++ """Symlinks must not be followed out of the static directory unless the
++ application opts in with ``allowed_symlink_directory``.
++ """
++
++ def setUp(self):
++ self.tmpdir = tempfile.mkdtemp()
++ self.addCleanup(shutil.rmtree, self.tmpdir)
++ self.root = os.path.join(self.tmpdir, "static")
++ self.outside = os.path.join(self.tmpdir, "outside")
++ os.mkdir(self.root)
++ os.mkdir(self.outside)
++ with open(os.path.join(self.root, "inside.txt"), "w") as f:
++ f.write("inside")
++ with open(os.path.join(self.outside, "secret.txt"), "w") as f:
++ f.write("secret")
++ with open(os.path.join(self.root, "index.html"), "w") as f:
++ f.write("root index")
++ # A symlink to a file outside the root.
++ os.symlink(
++ os.path.join(self.outside, "secret.txt"),
++ os.path.join(self.root, "link.txt"),
++ )
++ # A symlink to a directory outside the root.
++ os.symlink(self.outside, os.path.join(self.root, "linkdir"))
++ # Out-of-bounds symlink targets, one existing and one not, for the
++ # existence-oracle test below.
++ os.mkdir(os.path.join(self.outside, "realdir"))
++ os.symlink(
++ os.path.join(self.outside, "realdir"),
++ os.path.join(self.root, "link_exists"),
++ )
++ os.symlink(
++ os.path.join(self.outside, "nope"),
++ os.path.join(self.root, "link_missing"),
++ )
++ # A symlink that stays inside the root.
++ os.symlink(
++ os.path.join(self.root, "inside.txt"),
++ os.path.join(self.root, "internal.txt"),
++ )
++ super().setUp()
++
++ def get_handlers(self):
++ return [
++ ("/default/(.*)", StaticFileHandler, dict(path=self.root)),
++ (
++ "/permissive/(.*)",
++ StaticFileHandler,
++ dict(path=self.root, allowed_symlink_directory=self.tmpdir),
++ ),
++ (
++ "/default_filename/(.*)",
++ StaticFileHandler,
++ dict(path=self.root, default_filename="index.html"),
++ ),
++ ]
++
++ def get_app_kwargs(self):
++ return dict()
++
++ def test_regular_file(self):
++ response = self.fetch("/default/inside.txt")
++ self.assertEqual(response.code, 200)
++ self.assertEqual(response.body, b"inside")
++
++ def test_symlink_within_root(self):
++ # A symlink is fine as long as it resolves inside the root.
++ response = self.fetch("/default/internal.txt")
++ self.assertEqual(response.code, 200)
++ self.assertEqual(response.body, b"inside")
++
++ def test_symlinked_file_escaping_root(self):
++ with ExpectLog(gen_log, ".*is not in root static directory"):
++ response = self.fetch("/default/link.txt")
++ self.assertEqual(response.code, 403)
++
++ def test_symlinked_directory_escaping_root(self):
++ with ExpectLog(gen_log, ".*is not in root static directory"):
++ response = self.fetch("/default/linkdir/secret.txt")
++ self.assertEqual(response.code, 403)
++
++ def test_served_directory_itself(self):
++ # A request for the served directory itself resolves to the allowed
++ # directory exactly, rather than to something beneath it. That must
++ # still be allowed.
++ response = self.fetch("/default_filename/")
++ self.assertEqual(response.code, 200)
++ self.assertEqual(response.body, b"root index")
++
++ def test_symlinked_default_filename(self):
++ # The default filename is appended after the directory has been
++ # resolved, so it must be checked in its own right.
++ os.mkdir(os.path.join(self.root, "dir"))
++ os.symlink(
++ os.path.join(self.outside, "secret.txt"),
++ os.path.join(self.root, "dir", "index.html"),
++ )
++ with ExpectLog(gen_log, ".*is not in root static directory"):
++ response = self.fetch("/default_filename/dir/")
++ self.assertEqual(response.code, 403)
++
++ def test_no_existence_oracle_for_symlinked_directory(self):
++ # A symlink pointing out of bounds must be rejected identically
++ # whether or not its target exists, so that it cannot be used to
++ # probe the server's filesystem. This pins the ordering in
++ # validate_absolute_path: resolving only after the isdir() check
++ # would let an existing directory trigger a redirect (301) while a
++ # missing one still gave 403.
++ codes = []
++ for name in ("link_exists", "link_missing"):
++ with ExpectLog(gen_log, ".*is not in root static directory"):
++ codes.append(
++ self.fetch("/default_filename/" + name, follow_redirects=False).code
++ )
++ self.assertEqual(codes, [403, 403])
++
++ def test_allowed_symlink_directory(self):
++ # With an explicit allowed_symlink_directory that contains the
++ # target, the same symlinks are served.
++ response = self.fetch("/permissive/link.txt")
++ self.assertEqual(response.code, 200)
++ self.assertEqual(response.body, b"secret")
++
++ response = self.fetch("/permissive/linkdir/secret.txt")
++ self.assertEqual(response.code, 200)
++ self.assertEqual(response.body, b"secret")
++
++
++class StaticFileSymlinkedRootTest(WebTestCase):
++ """The root itself may be reached through a symlink; that must not make
++ every request look like an escape.
++ """
++
++ def setUp(self):
++ self.tmpdir = tempfile.mkdtemp()
++ self.addCleanup(shutil.rmtree, self.tmpdir)
++ real_root = os.path.join(self.tmpdir, "real")
++ os.mkdir(real_root)
++ with open(os.path.join(real_root, "inside.txt"), "w") as f:
++ f.write("inside")
++ # Serve through a symlink to the real directory.
++ self.root = os.path.join(self.tmpdir, "link")
++ os.symlink(real_root, self.root)
++ super().setUp()
++
++ def get_handlers(self):
++ return [("/static/(.*)", StaticFileHandler, dict(path=self.root))]
++
++ def get_app_kwargs(self):
++ return dict()
++
++ @unittest.skipIf(os.name != "posix", "non-posix OS")
++ def test_symlinked_root(self):
++ response = self.fetch("/static/inside.txt")
++ self.assertEqual(response.code, 200)
++ self.assertEqual(response.body, b"inside")
++
++
+ class StaticDefaultFilenameTest(WebTestCase):
+ def get_app_kwargs(self):
+ return dict(
+diff --git a/tornado/web.py b/tornado/web.py
+index 6f1ae304..2c8a1e1d 100644
+--- a/tornado/web.py
++++ b/tornado/web.py
+@@ -72,6 +72,7 @@ import numbers
+ import os.path
+ import re
+ import socket
++import stat
+ import sys
+ import threading
+ import time
+@@ -2736,6 +2737,13 @@ class StaticFileHandler(RequestHandler):
+ in your application settings, or add ``default_filename`` as an initializer
+ argument for your ``StaticFileHandler``.
+
++ Symlinks are not followed out of the directory being served: if a path
++ resolves to a location outside it, the request is rejected with a 403
++ error. Deployments that deliberately serve content through symlinks
++ pointing elsewhere can set the ``allowed_symlink_directory`` initializer
++ argument to a directory containing all of the intended targets. It
++ defaults to the ``path`` argument.
++
+ To maximize the effectiveness of browser caching, this class supports
+ versioned urls (by default using the argument ``?v=``). If a version
+ is given, we instruct the browser to cache this file indefinitely.
+@@ -2774,6 +2782,11 @@ class StaticFileHandler(RequestHandler):
+
+ .. versionchanged:: 3.1
+ Many of the methods for subclasses were added in Tornado 3.1.
++
++ .. versionchanged:: 6.5.9
++ Symlinks pointing outside the served directory are no longer
++ followed, and the ``allowed_symlink_directory`` argument was added
++ to configure this.
+ """
+
+ CACHE_MAX_AGE = 86400 * 365 * 10 # 10 years
+@@ -2781,9 +2794,17 @@ class StaticFileHandler(RequestHandler):
+ _static_hashes = {} # type: Dict[str, Optional[str]]
+ _lock = threading.Lock() # protects _static_hashes
+
+- def initialize(self, path: str, default_filename: Optional[str] = None) -> None:
++ def initialize(
++ self,
++ path: str,
++ default_filename: Optional[str] = None,
++ allowed_symlink_directory: Optional[str] = None,
++ ) -> None:
+ self.root = path
+ self.default_filename = default_filename
++ if allowed_symlink_directory is None:
++ allowed_symlink_directory = path
++ self.allowed_symlink_directory = allowed_symlink_directory
+
+ @classmethod
+ def reset(cls) -> None:
+@@ -2995,6 +3016,15 @@ class StaticFileHandler(RequestHandler):
+ # the requested path so a request to root/ will match.
+ if not (absolute_path + os.path.sep).startswith(root):
+ raise HTTPError(403, "%s is not in root static directory", self.path)
++ # The check above is on the path as written, which is cheap and
++ # rejects traversal without touching the filesystem. Resolve
++ # symlinks only once it has passed, so that the remaining checks -
++ # and the path this method returns - refer to the file that will
++ # actually be opened rather than to the link. The two checks
++ # cannot be merged: this one must stay on the unresolved path,
++ # because allowed_symlink_directory may be wider than the root and
++ # would then let a ../ traversal through.
++ absolute_path = self._resolve_symlink_target(absolute_path)
+ if os.path.isdir(absolute_path) and self.default_filename is not None:
+ # need to look at the request.path here for when path is empty
+ # but there is some prefix to the path that was already
+@@ -3012,12 +3042,72 @@ class StaticFileHandler(RequestHandler):
+ self.redirect(self.request.path + "/", permanent=True)
+ return None
+ absolute_path = os.path.join(absolute_path, self.default_filename)
+- if not os.path.exists(absolute_path):
++ # The default filename may be a symlink even when the directory
++ # containing it is not. Resolving here rather than deferring a
++ # single resolution until after this block is deliberate: the
++ # isdir() above, and the redirect it can trigger, must not run
++ # on a path that has already escaped, or the difference between
++ # a redirect and a 403 would reveal whether an out-of-bounds
++ # directory exists.
++ absolute_path = self._resolve_symlink_target(absolute_path)
++ # Stat the resolved path once and keep the result, instead of
++ # letting os.path.exists, os.path.isfile and the later header
++ # generation each resolve the path again. Every one of those is a
++ # separate trip through the filesystem that could observe a
++ # different file from the one validated here.
++ try:
++ stat_result = os.stat(absolute_path)
++ except OSError:
++ # Matches the previous os.path.exists() check, which is also
++ # false when the path cannot be stat'ed at all.
+ raise HTTPError(404)
+- if not os.path.isfile(absolute_path):
++ if not stat.S_ISREG(stat_result.st_mode):
+ raise HTTPError(403, "%s is not a file", self.path)
++ self._stat_result = stat_result
+ return absolute_path
+
++ def _resolve_symlink_target(self, absolute_path: str) -> str:
++ """Resolves symlinks in ``absolute_path`` and validates the result.
++
++ The path checks in `validate_absolute_path` operate on the path as
++ written, which says nothing about where a symlink points: a symlink
++ inside the static directory can name any file on the system. The
++ resolved path must therefore stay inside
++ ``allowed_symlink_directory``, which defaults to the directory being
++ served.
++
++ Raises `HTTPError` (403) if the resolved path escapes that directory.
++
++ Note that this is a check against the state of the filesystem at
++ one moment: a path that is replaced by a symlink after this returns
++ but before the file is opened would not be caught. Closing that
++ race entirely would require holding file descriptors across the
++ whole operation. This check is not a substitute for filesystem
++ permissions on a directory that untrusted users can write to.
++ """
++ # realpath() is applied to the allowed directory as well, because it
++ # may itself be reached through a symlink (a static directory of
++ # /var/www that links to /srv/www, say). Comparing a resolved path
++ # against an unresolved directory would reject every request.
++ allowed_directory = os.path.realpath(self.allowed_symlink_directory)
++ if not allowed_directory.endswith(os.path.sep):
++ # As in validate_absolute_path, the separator must not be
++ # doubled when the directory is the filesystem root.
++ allowed_directory += os.path.sep
++ # strict=True is deliberately not used here: it is unavailable on
++ # Python 3.9, and a path that does not exist resolves to itself,
++ # which the caller then reports as a 404.
++ resolved_path = os.path.realpath(absolute_path)
++ # The trailing separator lets the allowed directory itself match, as
++ # well as anything beneath it. This is reachable because the path may
++ # still be a directory at this point (when default_filename is set).
++ if not (resolved_path + os.path.sep).startswith(allowed_directory):
++ # Deliberately the same error as the check on the path as
++ # written, so that a caller cannot tell which way a path left
++ # the served directory.
++ raise HTTPError(403, "%s is not in root static directory", self.path)
++ return resolved_path
++
+ @classmethod
+ def get_content(
+ cls, abspath: str, start: Optional[int] = None, end: Optional[int] = None
+@@ -3077,6 +3167,10 @@ class StaticFileHandler(RequestHandler):
+
+ def _stat(self) -> os.stat_result:
+ assert self.absolute_path is not None
++ # validate_absolute_path normally populates this, so that the size
++ # and modification time reported here describe the same file that
++ # was validated. Subclasses that override validate_absolute_path
++ # without calling super() fall back to stat'ing here.
+ if not hasattr(self, "_stat_result"):
+ self._stat_result = os.stat(self.absolute_path)
+ return self._stat_result
+--
+2.35.6
+
@@ -13,6 +13,8 @@ SRC_URI += "file://CVE-2026-91990.patch \
file://CVE-2026-91991.patch \
file://CVE-2026-103261.patch \
file://CVE-2026-103262.patch \
+ file://CVE-2026-103263.patch \
+ file://CVE-2026-103263-dependent.patch \
"
inherit pypi python_setuptools_build_meta
This patch applies the upstream fix as referenced in [3], using the commit shown in [1] and dependent commit from [2]. Required companion fix for CVE-2026-103263: The symlink-hardening backport adds allowed_symlink_directory, but subclasses that override initialize() without calling the base method do not set it and fail with AttributeError. Include upstream commit 6bd9c4e0 and its regression tests with the CVE fix. [1] https://github.com/tornadoweb/tornado/commit/437ab5f76565403b3403438d2e555d11f8128d32 [2] https://github.com/tornadoweb/tornado/commit/6bd9c4e0fc10ae743006cd6a97543d15050f9594 [3] https://nvd.nist.gov/vuln/detail/CVE-2026-103263 [4] https://github.com/tornadoweb/tornado/issues/3724 Signed-off-by: Yogita Urade <yurade@cisco.com> --- .../CVE-2026-103263-dependent.patch | 255 +++++++++++ .../python3-tornado/CVE-2026-103263.patch | 422 ++++++++++++++++++ .../python/python3-tornado_6.5.7.bb | 2 + 3 files changed, 679 insertions(+) create mode 100644 meta-python/recipes-devtools/python/python3-tornado/CVE-2026-103263-dependent.patch create mode 100644 meta-python/recipes-devtools/python/python3-tornado/CVE-2026-103263.patch