CVE-2009-2659
Description
The Admin media handler in core/servers/basehttp.py in Django 1.0 and 0.96 does not properly map URL requests to expected "static media files," which allows remote attackers to conduct directory traversal attacks and read arbitrary files via a crafted URL.
Affected packages
Versions sourced from the GitHub Security Advisory.
| Package | Affected versions | Patched versions |
|---|---|---|
DjangoPyPI | >= 0.96.0, < 0.96.4 | 0.96.4 |
DjangoPyPI | >= 1.0, < 1.0.3 | 1.0.3 |
Affected products
2cpe:2.3:a:django_project:django:0.96:*:*:*:*:*:*:*+ 1 more
- cpe:2.3:a:django_project:django:0.96:*:*:*:*:*:*:*
- cpe:2.3:a:django_project:django:1.0:*:*:*:*:*:*:*
Patches
2da85d76fd6ca[0.96.X] SECURITY ALERT: Corrected a problem with the Admin media handler that could lead to the exposure of system files. Thanks to Gary Wilson for the patch.
5 files changed · +95 −8
django/core/management.py+1 −3 modified@@ -1192,9 +1192,7 @@ def inner_run(): print "Development server is running at http://%s:%s/" % (addr, port) print "Quit the server with %s." % quit_command try: - import django - path = admin_media_dir or django.__path__[0] + '/contrib/admin/media' - handler = AdminMediaHandler(WSGIHandler(), path) + handler = AdminMediaHandler(WSGIHandler(), admin_media_path) run(addr, int(port), handler) except WSGIServerException, e: # Use helpful error messages instead of ugly tracebacks.
django/core/servers/basehttp.py+27 −5 modified@@ -11,6 +11,8 @@ from types import ListType, StringType import os, re, sys, time, urllib +from django.utils._os import safe_join + __version__ = "0.1" __all__ = ['WSGIServer','WSGIRequestHandler','demo_app'] @@ -599,11 +601,25 @@ def __init__(self, application, media_dir=None): self.application = application if not media_dir: import django - self.media_dir = django.__path__[0] + '/contrib/admin/media' + self.media_dir = \ + os.path.join(django.__path__[0], 'contrib', 'admin', 'media') else: self.media_dir = media_dir self.media_url = settings.ADMIN_MEDIA_PREFIX + def file_path(self, url): + """ + Returns the path to the media file on disk for the given URL. + + The passed URL is assumed to begin with ADMIN_MEDIA_PREFIX. If the + resultant file path is outside the media directory, then a ValueError + is raised. + """ + # Remove ADMIN_MEDIA_PREFIX. + relative_url = url[len(self.media_url):] + relative_path = urllib.url2pathname(relative_url) + return safe_join(self.media_dir, relative_path) + def __call__(self, environ, start_response): import os.path @@ -614,19 +630,25 @@ def __call__(self, environ, start_response): return self.application(environ, start_response) # Find the admin file and serve it up, if it exists and is readable. - relative_url = environ['PATH_INFO'][len(self.media_url):] - file_path = os.path.join(self.media_dir, relative_url) + try: + file_path = self.file_path(environ['PATH_INFO']) + except ValueError: # Resulting file path was not valid. + status = '404 NOT FOUND' + headers = {'Content-type': 'text/plain'} + output = ['Page not found: %s' % environ['PATH_INFO']] + start_response(status, headers.items()) + return output if not os.path.exists(file_path): status = '404 NOT FOUND' headers = {'Content-type': 'text/plain'} - output = ['Page not found: %s' % file_path] + output = ['Page not found: %s' % environ['PATH_INFO']] else: try: fp = open(file_path, 'rb') except IOError: status = '401 UNAUTHORIZED' headers = {'Content-type': 'text/plain'} - output = ['Permission denied: %s' % file_path] + output = ['Permission denied: %s' % environ['PATH_INFO']] else: status = '200 OK' headers = {}
tests/regressiontests/servers/__init__.py+0 −0 addedtests/regressiontests/servers/models.py+0 −0 addedtests/regressiontests/servers/tests.py+67 −0 added@@ -0,0 +1,67 @@ +""" +Tests for django.core.servers. +""" + +import os + +import django +from django.test import TestCase +from django.core.handlers.wsgi import WSGIHandler +from django.core.servers.basehttp import AdminMediaHandler + + +class AdminMediaHandlerTests(TestCase): + + def setUp(self): + self.admin_media_file_path = \ + os.path.join(django.__path__[0], 'contrib', 'admin', 'media') + self.handler = AdminMediaHandler(WSGIHandler()) + + def test_media_urls(self): + """ + Tests that URLs that look like absolute file paths after the + settings.ADMIN_MEDIA_PREFIX don't turn into absolute file paths. + """ + # Cases that should work on all platforms. + data = ( + ('/media/css/base.css', ('css', 'base.css')), + ) + # Cases that should raise an exception. + bad_data = () + + # Add platform-specific cases. + if os.sep == '/': + data += ( + # URL, tuple of relative path parts. + ('/media/\\css/base.css', ('\\css', 'base.css')), + ) + bad_data += ( + '/media//css/base.css', + '/media////css/base.css', + '/media/../css/base.css', + ) + elif os.sep == '\\': + bad_data += ( + '/media/C:\css/base.css', + '/media//\\css/base.css', + '/media/\\css/base.css', + '/media/\\\\css/base.css' + ) + for url, path_tuple in data: + try: + output = self.handler.file_path(url) + except ValueError: + self.fail("Got a ValueError exception, but wasn't expecting" + " one. URL was: %s" % url) + rel_path = os.path.join(*path_tuple) + desired = os.path.normcase( + os.path.join(self.admin_media_file_path, rel_path)) + self.assertEqual(output, desired, + "Got: %s, Expected: %s, URL was: %s" % (output, desired, url)) + for url in bad_data: + try: + output = self.handler.file_path(url) + except ValueError: + continue + self.fail('URL: %s should have caused a ValueError exception.' + % url)
df7f917b7f51[1.0.X] SECURITY ALERT: Corrected a problem with the Admin media handler that could lead to the exposure of system files. Thanks to Gary Wilson for the patch.
5 files changed · +94 −7
django/core/management/commands/runserver.py+1 −2 modified@@ -56,8 +56,7 @@ def inner_run(): translation.activate(settings.LANGUAGE_CODE) try: - path = admin_media_path or django.__path__[0] + '/contrib/admin/media' - handler = AdminMediaHandler(WSGIHandler(), path) + handler = AdminMediaHandler(WSGIHandler(), admin_media_path) run(addr, int(port), handler) except WSGIServerException, e: # Use helpful error messages instead of ugly tracebacks.
django/core/servers/basehttp.py+26 −5 modified@@ -16,6 +16,7 @@ import urllib from django.utils.http import http_date +from django.utils._os import safe_join __version__ = "0.1" __all__ = ['WSGIServer','WSGIRequestHandler'] @@ -621,11 +622,25 @@ def __init__(self, application, media_dir=None): self.application = application if not media_dir: import django - self.media_dir = django.__path__[0] + '/contrib/admin/media' + self.media_dir = \ + os.path.join(django.__path__[0], 'contrib', 'admin', 'media') else: self.media_dir = media_dir self.media_url = settings.ADMIN_MEDIA_PREFIX + def file_path(self, url): + """ + Returns the path to the media file on disk for the given URL. + + The passed URL is assumed to begin with ADMIN_MEDIA_PREFIX. If the + resultant file path is outside the media directory, then a ValueError + is raised. + """ + # Remove ADMIN_MEDIA_PREFIX. + relative_url = url[len(self.media_url):] + relative_path = urllib.url2pathname(relative_url) + return safe_join(self.media_dir, relative_path) + def __call__(self, environ, start_response): import os.path @@ -636,19 +651,25 @@ def __call__(self, environ, start_response): return self.application(environ, start_response) # Find the admin file and serve it up, if it exists and is readable. - relative_url = environ['PATH_INFO'][len(self.media_url):] - file_path = os.path.join(self.media_dir, relative_url) + try: + file_path = self.file_path(environ['PATH_INFO']) + except ValueError: # Resulting file path was not valid. + status = '404 NOT FOUND' + headers = {'Content-type': 'text/plain'} + output = ['Page not found: %s' % environ['PATH_INFO']] + start_response(status, headers.items()) + return output if not os.path.exists(file_path): status = '404 NOT FOUND' headers = {'Content-type': 'text/plain'} - output = ['Page not found: %s' % file_path] + output = ['Page not found: %s' % environ['PATH_INFO']] else: try: fp = open(file_path, 'rb') except IOError: status = '401 UNAUTHORIZED' headers = {'Content-type': 'text/plain'} - output = ['Permission denied: %s' % file_path] + output = ['Permission denied: %s' % environ['PATH_INFO']] else: # This is a very simple implementation of conditional GET with # the Last-Modified header. It makes media files a bit speedier
tests/regressiontests/servers/__init__.py+0 −0 addedtests/regressiontests/servers/models.py+0 −0 addedtests/regressiontests/servers/tests.py+67 −0 added@@ -0,0 +1,67 @@ +""" +Tests for django.core.servers. +""" + +import os + +import django +from django.test import TestCase +from django.core.handlers.wsgi import WSGIHandler +from django.core.servers.basehttp import AdminMediaHandler + + +class AdminMediaHandlerTests(TestCase): + + def setUp(self): + self.admin_media_file_path = \ + os.path.join(django.__path__[0], 'contrib', 'admin', 'media') + self.handler = AdminMediaHandler(WSGIHandler()) + + def test_media_urls(self): + """ + Tests that URLs that look like absolute file paths after the + settings.ADMIN_MEDIA_PREFIX don't turn into absolute file paths. + """ + # Cases that should work on all platforms. + data = ( + ('/media/css/base.css', ('css', 'base.css')), + ) + # Cases that should raise an exception. + bad_data = () + + # Add platform-specific cases. + if os.sep == '/': + data += ( + # URL, tuple of relative path parts. + ('/media/\\css/base.css', ('\\css', 'base.css')), + ) + bad_data += ( + '/media//css/base.css', + '/media////css/base.css', + '/media/../css/base.css', + ) + elif os.sep == '\\': + bad_data += ( + '/media/C:\css/base.css', + '/media//\\css/base.css', + '/media/\\css/base.css', + '/media/\\\\css/base.css' + ) + for url, path_tuple in data: + try: + output = self.handler.file_path(url) + except ValueError: + self.fail("Got a ValueError exception, but wasn't expecting" + " one. URL was: %s" % url) + rel_path = os.path.join(*path_tuple) + desired = os.path.normcase( + os.path.join(self.admin_media_file_path, rel_path)) + self.assertEqual(output, desired, + "Got: %s, Expected: %s, URL was: %s" % (output, desired, url)) + for url in bad_data: + try: + output = self.handler.file_path(url) + except ValueError: + continue + self.fail('URL: %s should have caused a ValueError exception.' + % url)
Vulnerability mechanics
Generated by null/stub on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
16- www.djangoproject.com/weblog/2009/jul/28/security/nvdPatchVendor Advisory
- github.com/advisories/GHSA-9xg7-gg9m-rmq9ghsaADVISORY
- nvd.nist.gov/vuln/detail/CVE-2009-2659ghsaADVISORY
- bugs.debian.org/cgi-bin/bugreport.cginvdWEB
- code.djangoproject.com/changeset/11353nvdWEB
- www.djangoproject.com/weblog/2009/jul/28/securityghsaWEB
- www.openwall.com/lists/oss-security/2009/07/29/2nvdWEB
- github.com/django/django/commit/da85d76fd6ca846f3b0ff414e042ddb5e62e2e69ghsaWEB
- github.com/django/django/commit/df7f917b7f51ba969faa49d000ffc79572c5dcb4ghsaWEB
- github.com/pypa/advisory-database/tree/main/vulns/django/PYSEC-2009-3.yamlghsaWEB
- web.archive.org/web/20111211001428/http://www.securityfocus.com/bid/35859ghsaWEB
- www.redhat.com/archives/fedora-package-announce/2009-August/msg00055.htmlnvdWEB
- www.redhat.com/archives/fedora-package-announce/2009-August/msg00069.htmlnvdWEB
- secunia.com/advisories/36137nvd
- secunia.com/advisories/36153nvd
- www.securityfocus.com/bid/35859nvd
News mentions
0No linked articles in our index yet.