Opened 4 weeks ago
Last modified 3 weeks ago
#36653 new Bug
FORCE_SCRIPT_NAME is not respected for static URLs
| Reported by: | Brian Helba | Owned by: | |
|---|---|---|---|
| Component: | contrib.staticfiles | Version: | 5.2 |
| Severity: | Normal | Keywords: | |
| Cc: | Brian Helba, Florian Apolloner, Klaas van Schelven, David Sanders, Dan LaManna | Triage Stage: | Accepted |
| Has patch: | no | Needs documentation: | no |
| Needs tests: | no | Patch needs improvement: | no |
| Easy pickings: | no | UI/UX: | no |
Description (last modified by )
The documentation for the STATIC_URL setting states:
If
STATIC_URLis a relative path, then it will be prefixed by the server-provided value ofSCRIPT_NAME(or / if not set). This makes it easier to serve a Django application in a subpath without adding an extra configuration to the settings.
However, when using FORCE_SCRIPT_NAME, the value of STATIC_URL when serving requests is never updated appropriately. This breaks URL construction via django.templatetags.static.static (used in templates as {% static ... %}).
For example, this causes the Django Admin pages to break when using FORCE_SCRIPT_NAME to serve Django under a subpath. Generally, using FORCE_SCRIPT_NAME causes Django to behave incorrectly: view URLs are constructed to respect it, static URLs are constructed to not respect it.
I believe that this bug likely also affects static URLs when using the SCRIPT_NAME WSGI environment variable too, but I haven't verified that yet.
There is definitely some history here, but I believe previous bug reports have struggled to articulate this problem. I'm including all of the below detail in the hope that this issue will be understood well enough to acknowledge it as a legitimate bug.
Long ago, the following reports were made, which I believe are not relevant to this problem (but I'm summarizing them because they've been claimed to be duplicates of this problem):
- #7930 only discussed view URLs (and using
reverse()); this now works as expected, but static URLs are still broken - #30634 is probably irrelevant; it claimed vague problems with
SCRIPT_NAMEand runserver; the problem here is general to all servers, including both runserver and WSGI - #31724 is probably a true duplicate of #7930
More recently, we've seen:
- #34892 is probably the same as this problem, but the reporter struggled to articulate the behavior and I believe it was mistakenly closed as a duplicate of the aforementioned old issues
- #35985 claimed that this is problem is limited to using threading within management commands, then got derailed by the niche use case and suggestions around the low-level
set_script_prefixAPI
I believe that I understand the exact cause of the bug. Note, my use of some lifecycle events and specific thread names may be limited to the runserver case, but the exact same behaviors manifest with WSGI (and I believe that equivalent things are occurring with a multiprocess lifecycle).
django.setup()is called very early by runserverdjango.setup()callsset_script_prefixset_script_prefixcorrectly setsdjango.urls.base._prefixes.value, but only for the current thread (since_prefixesis a thread-local object)- a new thread,
django-main-thread, is spawned; again, I believe (and can locate if necessary) that an equivalent event also happens in a WSGI lifecycle check_url_settingsruns and accessessettings.STATIC_URL; importantly, this is the first time in the startup lifecycle thatsettings.STATIC_URLhas ever been accesseddjango.conf.__getattr__has special logic forSTATIC_URL, so it calls the staticmethodLazySettings._add_script_prefixLazySettings._add_script_prefixcallsget_script_prefixget_script_prefixlooks atdjango.urls.base._prefixes.value, but it's running in a new thread (step 4), so it doesn't contain theFORCE_SCRIPT_NAMEvalue (step 3) and returns"/"django.conf.__getattr__(step 6) permanently caches the incorrect value ofsettings.STATIC_URLinLazySettings.__dict__(which is not thread-local); all future requests forsettings.STATIC_URLwill receive the incorrect value ("/", instead ofsettings.FORCE_SCRIPT_NAME)- The first HTTP request comes in
WSGIHandler.__call__callsget_script_nameget_script_namecorrectly returnssettings.FORCE_SCRIPT_NAMEWSGIHandler.__call__callsset_script_prefixwith the correct argument (the valuesettings.FORCE_SCRIPT_NAME)set_script_prefix(just as in step 3) finally setsdjango.urls.base._prefixes.value(which, remember, is a thread-local variable, but will persist for at least the remainder of this HTTP request) with the correct value- While rendering the HTTP response, a template or some code calls the function
django.templatetags.static; assume that the app"django.contrib.staticfiles"is installed and configured to use some subclass ofStaticFilesStorage(which is Django's typical configuration) django.contrib.staticfiles.storage.staticfiles_storage.url()is calledstaticfiles_storage(an instance ofStaticFilesStorage) is lazily constructedStaticFilesStorage.__init__is called; assume it has no arguments fromsettings.STORAGES["staticfiles"]["OPTIONS"](this is Django's default)StaticFilesStorage.__init__defaults to set itsself._base_urltosettings.STATIC_URL, butsettings.STATIC_URLreturns an incorrect value (step 9)- Continuing the call in step 16,
FilesystemStorage.urlforms the actual URL fromself.base_url self.base_url, a cached property, relies on the incorrectself._base_url- A static file URL is returned with the incorrect base URL, not respecting
settings.FORCE_SCRIPT_NAME - Subsequent HTTP requests skip steps 17-19, but otherwise reply the work from step 10 onwards (and also result in incorrect static URLs)
In summary, the problem is that although there's code to attempt to set (via set_script_prefix) the correct script name both on Django's startup and again on every individual HTTP request, settings.STATIC_URL slips between a lifecycle "crack" and ends up with the wrong value (which doesn't incorporate the script name, contrary to its documentation), which persists over the entire request-response lifecycle.
Change History (11)
comment:1 by , 4 weeks ago
| Description: | modified (diff) |
|---|
comment:2 by , 4 weeks ago
| Description: | modified (diff) |
|---|
comment:3 by , 4 weeks ago
| Description: | modified (diff) |
|---|
comment:4 by , 4 weeks ago
comment:5 by , 4 weeks ago
| Description: | modified (diff) |
|---|
comment:6 by , 4 weeks ago
Finally, I see that LazySettings also caches MEDIA_URL in a similar way. I haven't fully thought about the impact on the default (a.k.a. media) storage, but there may be similar issues there too. I'm trying to keep this issue more focused on static files, as they definitely cause breakages with the default Django Admin.
comment:7 by , 4 weeks ago
| Description: | modified (diff) |
|---|
comment:8 by , 3 weeks ago
| Cc: | added |
|---|---|
| Triage Stage: | Unreviewed → Accepted |
Thanks for the thorough report. Reproduced 👍
Regression in 7dbbe65647a54283fadf2c51f11a750a74425d7b given the side effects left by the system check in step 4 and django.contrib.staticfiles.utils.check_settings, which does something similar.
Reverting that commit fixes the FORCE_SCRIPT_NAME case; I would expect it to fix an invariant SCRIPT_NAME as well. I think triage on #34028 missed this, since we can't just document that prefixes shouldn't be set outside the request/response cycle and expect this to work, since these two system checks are *also* outside the request/response cycle.
See this comment anticipating a revert when someone posted on the closed PR, also encouraging us not to focus on varying values for SCRIPT_NAME:
There is an open ticket to that extend somewhere and I feel we might need to have to revert this. That said, settings are considered to be static, so if this changes per request Django will not be happy either way since the value was already passed to default_storage and used to instantiate the backend :/
Would you like to submit a PR?
comment:10 by , 3 weeks ago
I fear I don't have anything useful to add, personally I think we should burn it down and rebuild (the whole SCRIPT_NAME/FORCE_SCRIPT_NAME interaction with static files etc). For context I can offer https://code.djangoproject.com/ticket/35985#comment:5 which provides some "workarounds" -- "workarounds" in quotes since I don't think this is a workaround but how it should actually behave :D
comment:11 by , 3 weeks ago
| Cc: | added |
|---|
In terms of policies for a solution, although
settings.FORCE_SCRIPT_NAMEmight practically be constant over Django's lifecycle, it's completely allowed by WSGI for the value of theSCRIPT_NAMEenvironment variable to change on each request (and there's code in step 13 to support this). So, I think it follows that thatsettings.STATIC_URLshould also be allowed to change on each new HTTP request. I'd encourage this path.If we require that
settings.STATIC_URLshouldn't change, it allows for some simpler fixes that only requireSTATIC_URLto eventually respectFORCE_SCRIPT_NAMEand maybe the first-presented value of aSCRIPT_NAMEenvironment variable. I think this would lead to confusing behavior for anyone usingSCRIPT_NAME.Alternatively, we could change the documentation for
STATIC_URLto state that it doesn't respectSCRIPT_NAME, but that would be a larger policy change (not a simple bugfix) and it'd lead to a slipperly slope of just dropping all support forSCRIPT_NAME/FORCE_SCRIPT_NAME, etc. (which is out of scope for this issue). I think this change is too ambitious and would remove useful functionality for serving Django within subpaths behind reverse proxies, so I don't support it.I see two reasonable changes:
settings.STATIC_URL, so it's not permanently cached. Each access ofsettings.STATIC_URLwould recomputed the value, usingLazySettings._add_script_prefix.set_script_prefixto reach intosettingsand invalidate the cache ofSTATIC_URL, causing step 13 to have its full intended effect. This is technically more efficient than having no cache ofSTATIC_URL, but introduces additional circularity in the relationship between all these methods and further breaks the abstraction ofLazySettings(asLazySettingscaches itself internally, but has its cache invalidated externally).