Problem/Motivation
# Disable content sniffing, since it's an attack vector.
Header always set X-Content-Type-Options nosniff
To the .htaccess file. We also have
// Prevent browsers from sniffing a response and picking a MIME type
// different from the declared content-type, since that can lead to
// XSS and other vulnerabilities.
// https://www.owasp.org/index.php/List_of_useful_HTTP_headers
$response->headers->set('X-Content-Type-Options', 'nosniff', FALSE);
in \Drupal\Core\EventSubscriber\FinishResponseSubscriber::onRespond().
It needs to be in both places because we want the nosniff header on files not served by Drupal's index.php, for example, images.
However, if I get the home page of my local dev build I see the following headers:
Cache-Control:must-revalidate, no-cache, private
Connection:Keep-Alive
Content-Encoding:gzip
Content-language:en
Content-Length:1619
Content-Type:text/html; charset=UTF-8
Date:Wed, 22 Feb 2017 09:16:54 GMT
Expires:Sun, 19 Nov 1978 05:00:00 GMT
Keep-Alive:timeout=5, max=100
Server:Apache/2.4.23 (Unix) PHP/7.1.0 LibreSSL/2.2.7
Vary:Accept-Encoding
X-Content-Type-Options:nosniff
X-Content-Type-Options:nosniff
X-Drupal-Cache:MISS
X-Drupal-Cache-Contexts:languages:language_interface route theme url.path url.query_args user.permissions user.roles:authenticated
X-Drupal-Cache-Tags:block_view config:block.block.stark_admin config:block.block.stark_branding config:block.block.stark_local_actions config:block.block.stark_local_tasks config:block.block.stark_login config:block.block.stark_messages config:block.block.stark_page_title config:block.block.stark_tools config:block_list config:system.menu.admin config:system.menu.tools config:system.site config:user.role.anonymous config:user.settings http_response rendered
X-Drupal-Dynamic-Cache:HIT
X-Frame-Options:SAMEORIGIN
X-Generator:Drupal 8 (https://www.drupal.org)
X-Powered-By:PHP/7.1.0
X-UA-Compatible:IE=edge
As you can see the X-Content-Type-Options header is duplicated.
This problem also causes \Drupal\system\Tests\Routing\RouterTest to fail for me locally with:
Fail Other RouterTest.php 40 Drupal\system\Tests\Routing\RouterT
Value 'nosniff,nosniff' is equal to value 'nosniff'.
Which is how I found this problem.
Proposed resolution
I think the we need to fix the .htaccess rule so this does not occur. Because the line in \Drupal\Core\EventSubscriber\FinishResponseSubscriber::onRespond() makes this work for other webservers for every delivered via index.php.
Remaining tasks
User interface changes
None
API changes
None
Data model changes
None
Release notes snippet
The root .htaccess file now unsets the X-Content-Type-Options header before setting it again, to prevent duplicate headers in some configurations of Apache. Site owners should update their .htaccess files with this change to avoid duplicated headers.
| Comment | File | Size | Author |
|---|
Issue fork drupal-2854817
Show commands
Start within a Git clone of the project using the version control instructions.
Or, if you do not have SSH keys set up on git.drupalcode.org:
- 2854817-duplicate-x-content-type-options-headers
changes, plain diff MR !323
- 9.3.x
changes, plain diff MR !1074
Comments
Comment #2
alexpottLet's see if Apache has mod_headers installed on DrupalCI. The additional test coverage passes locally for me.
Comment #4
alexpottSo as suspected mod_headers is not running on DrupalCI. If it was \Drupal\system\Tests\Routing\RouterTest would fail.
Comment #5
alexpottIf I make the following change:
RouterTest passes locally for me BUT the 403 case in the new test added by this patch (
\Drupal\system\Tests\System\HtaccessTest::testXContentTypeOptions) does not. :(I guess the question is do Chrome and IE do mime type sniffing on 400s. To me that sounds very very odd.
Comment #6
mile23I'm seeing this in 8.3.x because Drupal\system\Tests\Routing\RouterTest never passes locally. It gives this fail message:
Comment #7
mile23This patch fixes the test, but not the problem.
Here's what it says in the test result:
It applies to both 8.3.x and 8.4.x.
Comment #9
wim leers#2915417: 403 due to invalid security token has terrible DX (was: "CKEditor / X-Content-Type-Options") led me here.
Comment #10
wim leersBTW, I've had this same problem that Alex Pott describes in the IS, for years. It's bad for core DX.
Comment #11
wim leersMarked #2633204: (followup) Mitigate the security risks that come from IE, Chrome and other browsers trying to sniff the mime type as a duplicate.
Comment #12
wim leersQuoting https://httpd.apache.org/docs/current/mod/mod_headers.html#Header:
(emphasis mine)
This means this is simply a bug in Apache. We're not the only one to notice/experience this: https://serverfault.com/questions/837782/apache2-header-always-set-merge...
I tried changing it to
But that sadly doesn't seem to work either. It seems that in case of 4xx responses,
mod_headersseems to somehow fail to inspect response headers, and hence ends up simply appending?!The only thing I could think of that could still work, is something like:
i.e. only do this if the script name (i.e. after executing
RewriteRule ^ index.php [L]) is NOTindex.php, so that we only do this for static files, since in all other cases,\Drupal\Core\EventSubscriber\FinishResponseSubscriberwill already set this header for us anyway!Sadly, after more than an hour of debugging, I still can't get it to work. Debugging Apache is a painful mess.
Hopefully this helps the next person who looks at this, and hopefully they can bring it to completion…
In case it helps, here are my many sorry attempts to either make that idea of mine work, or to at least debug it:
FWIW, https://httpd.apache.org/docs/current/expr.html is mostly useless. :(
Comment #13
gwagroves commentedAlso had no luck with mod_headers, but as a workaround, I left the .htaccess as is and implemented an event subscriber to strip out the header added in PHP:
Comment #18
mr.baileysNote sure if this still is an issue. I tried to reproduce it with Apache 2.4.25, but when fetching the homepage of a 8.9.x instance, the
X-Content-Type-Options: nosniffonly appears once:Note: the empty
Vary-header is be covered in #3089143: FinishResponseSubscriber adds empty Vary-headers on non-cacheable responses.Comment #19
alex-b commentedApache 2.4.7 added a
setifemptyaction in the headers module. The following eliminates the problem for me on D7:Not sure how to turn this into a general solution, but may be a workaround for some.
Comment #20
alex-b commentedAccording to the Apache mod_headers documentation, the
onsuccess(default if ommitted) andalwaysconditions process separate internal tables of HTTP headers. And indeed usingalso fixes the problem for me. Note the absence of
alwaysin the first line compared to comment #12 above. Patch against 7.x-dev enclosed.Edit: Also, having
alwaysin both of these lines still does trigger the header duplication.Comment #21
liam morlandThe solution in #19 is working for me in D8.8.1.
Is there a reason #20 is preferable to #19?
Comment #22
alex-b commentedYes, the reason is that it should work with Apache versions older than 2.4.7 (where
setifemptywas introduced), as early as 2.0.Comment #23
liam morlandPatch for Drupal 8.9.x. It probably applies to 9.0.x. as well.
Comment #25
liam morland#23 with scaffold file also updated.
Comment #26
mr.baileysI was able to reproduce the issue after manually compiling Apache 2.4.6+PHP 7.2, both visually (duplicated headers) as well as confirming the failing
I have also confirmed that the solution in #19 (usingRouterTest::testFinishResponseSubscriber:setifempty) will not work on Apache <2.4.7 (causes a 500 internal server error). Since Drupal does not specify an minimum Apache version other than 2.x, we unfortunately cannot use that solution (as explained in #22).After applying the patch from #25, the "X-Content-Type-Options" header is present and not duplicated for A. static error pages (404, /does-not-exist.jpg), B. Dynamic error pages (404, /foo-bar), C. Regular pages (<front>) and D. static assets (/INSTALL.txt):
The suggested approach removes theX-Content-Type-Optionsheader set by Drupal and replaces it with a new one, but since "nosniff" is the only allowed value, and since we always want this to be set, there is no risk of accidentally overwriting custom data by removing/resetting.I think this comment needs to be clearer, since it does not answer the question "why unset+set to avoid duplicate headers instead of not setting it at all". We should refer to the fact that
X-Content-Type-Optionsis set byFinishResponseSubscriber, as without that context, this comment and the following directives are confusing.Other than that, I think this is RTBC.
Comment #27
mr.baileysThinking some more about this, I think we should just up the minimum required Apache version for D8 to Apache >=2.4.7 (right now it is 2.x) and use the more elegant solution from #19. I doubt many people are still stuck on <= 2.4.6 (which is over 6 years old), and if they are, then they have a large number of vulnerabilities to worry about...
Comment #28
gisle+1 on upping the minimum required Apache version. I also think this should be done for Drupal 7 as well (I already use the suggestion in #19 for Drupal 7).
Comment #29
mr.baileysOpened #3114079: Update minimum supported Apache version to >= 2.4.7 to see if upping the minimum version for Apache is acceptable.
Comment #30
liam morlandPatch in #25 with updated comments attached.
I agree with upping the Apache version. Unless this happens quickly, I believe we should move ahead with the attached patch. We can always return and use
setifemptylater.Comment #31
longwaveCan we use
mergeinstead ofset, i.e.As
nosniffis the only valid value, this should add it if it's not already present, and ignore it if it is?Although the docs don't explicitly say, in a quick test it does seem to add it if the header doesn't exist at all.
edit: #462950-44: Mitigate the security risks that come from IE, Chrome and other browsers trying to sniff the mime type mentions the same idea
Comment #32
longwaveComment #33
liam morlandI think
alwaysshould still be there.Comment #34
mr.baileys@Liam Morland is correct in #33, and
RouterTest::onFinishResponseSubscriber()is still failing locally on Apache 2.4.6 with the patch from #32.From the Apache docs:
Adding always to the changes in #32 makes
RouterTest::onFinishResponseSubscriber()pass locally.Comment #35
mr.baileysI can confirm that this is indeed the behaviour for
mergeeven on an older Apache version like 2.4.6.Comment #36
longwaveComment #37
mr.baileysWhile using
mergemakes the RouterTest pass locally, applying the patch from #36 still returns duplicate headers when testing manually:This is not the case when I apply the patch from #30
Comment #38
liam morlandI think the problem is that
setandmergeboth don't notice that the header is already set, perhaps because it was set in PHP and not Apache. This strikes me as an Apache bug, but that shouldn't stop us from working around it here. Fortunately,unsetworks.I'm proposing we stick with #30.
Comment #39
mr.baileysSince it is unlikely we can increase the minimum Apache version to >2.4.6 (see #3114079: Update minimum supported Apache version to >= 2.4.7) which would allow us to use
Header setifempty, and since usingHeader mergedoes not (always) seem to prevent the duplicate headers, I agree with @Liam Morland in #38.The patch in #30 ensures that RouterTest passes locally and resolves the duplicate header issue, so RTBC.
Comment #40
liam morlandPatch #30 also applies to 9.0.x.
Comment #41
liam morlandD7 version.
Comment #42
longwaveDrupal 7 doesn't have a FinishResponseSubscriber. This looks like it should be
drupal_page_header()instead.Comment #43
liam morlandThanks
Comment #44
alexpottIt's quite confusing what the actual rtbc patch on this issue is. I think it is #30 is the rtbc patch. Can this be the last patch on the issue so the rtbc re-test gets it right an it's obvious what to do.
Re D7 patches - that needs a separate issue as we no longer solve them as part of the same issue.
Comment #45
liam morlandPatch from #30. Restoring status.
Child issue created for D7 port.
Comment #46
liam morlandIn #3114079: Update minimum supported Apache version to >= 2.4.7, it was decided that as of Drupal 9, Apache 2.4.7 would be required. Attached is a patch for D9 that uses
merge.Comment #47
liam morlandD7 port is in #3116482: Duplicate X-Content-Type-Options headers both with the value nosniff [D7].
Comment #48
mr.baileysI ran some manual tests with both Apache 2.4.7 and Apache 2.4.41, here are my findings (✗ = duplicate X-Content-Type-Options header, ✓ = single X-Content-Type-Options header):
Conclusion: using
mergedoes not fix the issue (see also comment #37 where similar manual testing yielded the same result.), using the unset/set technique does address the issue on all supported versions of Apache.Comment #49
mr.baileysAs per #39, we upgraded the minimum Apache version so we would be able to use
setifemptyrather thanmerge. I have not tested this variant in #48, but some very quick testing indicates that this does resolve the issue.Comment #50
liam morlandSorry about that. Patch using
setifemptyfor 9.0.x.For 8.9.x, the patch in #45 is current and had acheived RTBC.
Comment #51
thallesOn my laptop show me this (D8.9.x php7.2):

D9.x php7.3:

Comment #52
mr.baileys@thalles: just to confirm, were your screenshots taken after applying the patch in #50?
Comment #53
liam morlandIt may be that newer Apache has fixed a bug.
Header setshould not have lead to a duplicated header anyway since it is supposed to set the value of the header, but in our case it has been behaving likeadd.Are you saying that in Apache 2.4.7, the patch in #50 (
setifempty) does not fix the problem but that the patch in #45 (unset&set) does?Comment #54
thallesI didn't apply any patches
Comment #55
mr.baileysThe testing in #18 and 52 indicates that the problem does not occur with Apache 2.4.25 & 2.4.38. The headers in the original report were generated using Apache 2.4.23, so that would mean the behavior changed in Apache 2.4.25 (2.4.24 was never released). I will run some tests later today to confirm this.
Correct.
Comment #56
mr.baileysIgnore my previous comment, I *am* able to reproduce the duplicate headers, even with the most recent version of Apache. I am not sure why the header duplication was not occuring during my previous tests, or in @thalles' test.
Regardless of version, the only solution that works for me is unset/set.
Comment #57
thallesWhat is your drupal version?
Comment #58
liam morlandPerhaps we should use
unsetandsetfor all versions.Comment #59
liam morlandThis is the patch from #45 which uses
unsetandset. It had reached RTBC. Given the above, I propose that we use this for all versions of D8.Comment #60
liam morlandThe patch in #59 is the patch from #30 which had reach RTBC in #39. Various tests reported above have found no better solution. Restoring status.
Comment #61
alexpottHeader merge X-Content-Type-Options nosniffworks just fine for me on Apache/2.4.41 (Unix).When testing this you need to test against a 200 generated by drupal, a 40x generated by Drupal and against a file not served by Drupal code. With that in my .htaccess I get
If I add the
alwaysto the .htaccess directive then it breaks but the always is not necessary. 40x and 50x are generated by Drupal and will have the finish response subscriber run so it's not adding any protection as far as I can see.setifemptyalso works as long as thealwayskeyword is not there.Before committing #59 I think we need to confirm that the always is necessary. I'm not sure it is.
Comment #62
xjmThis might be minor-only since it involves changes to
.htaccess. It's also something we mention in release notes.Since 8.9.x and 9.0.x are now in beta, I'm moving this to 9.1.x. Thanks!
Comment #63
liam morlandReroll of #59.
Comment #64
liam morlandThis issue is about duplicate headers. The
alwaysis already there. Making a change to that should be a separate issue.mergedoes something different. This header could be set to a different value, which meansmergewould leave us with both values, sosetis what we want.Comment #65
liam morlandReroll.
Comment #67
longwaveUnrelated fail
Comment #70
liam morlandCreated merge request with #65.
Comment #72
liam morlandRe-rolled for 9.3.x.
Comment #73
longwaveThis patch is as simple as can be and #64 responds to @alexpott's concern in #61, there seems nothing left to do here.
Comment #74
effulgentsia commentedWould be nice to have test coverage for this fix. I guess we already do per #4, except DrupalCI doesn't include mod_headers. I opened #3226187: Enable mod_headers and mod_expires on Apache for that.
It probably makes sense to commit this without waiting on that. I'm holding off for the moment, but if I or someone else doesn't commit this by Wednesday, please add a comment here then to remind me.
Comment #75
effulgentsia commentedComment #76
effulgentsia commentedSince this MR is using the pattern that's recommended in https://httpd.apache.org/docs/current/mod/mod_headers.html (see "To circumvent this limitation..." on that page), I'm thinking it might help people who read this code later if we add an inline comment that links to that.
Comment #77
larowlanNeeds work for #76
Comment #78
effulgentsia commentedI pushed a commit to the MR that addresses #76.
Comment #81
liam morlandI don't know why it created a second merge request. Use !323.
Comment #83
liam morlandRe-rolled for 9.4.x.
Comment #84
longwave#76 was addressed, so back to RTBC.
Comment #85
alexpottAs this is changing the root .htaccess file we need a change record and a release note in the issue summary see https://www.drupal.org/node/3186526 for an example CR and https://www.drupal.org/project/drupal/issues/3186524 has the release note in the issue summary.
Once these things exist the issue can be set back to rtbc.
Comment #88
joshahubbers commentedSorry for the clutter but the diff above won't apply because the .htaccess is not present when you build the project with composer. That file will be copied from the scaffold folder. So this patch only patches the scaffold file.
Comment #89
joshahubbers commentedComment #90
liam morlandComment #91
liam morlandComment #92
longwaveThis still needs a change record and release note.
Comment #93
joshahubbers commentedComment #94
joshahubbers commentedI added the release note to the summary,
but I cannot add a change record.I will add a change record now.Comment #95
joshahubbers commentedI added a draft for the change record. I is my first, hope it is sufficient.
Comment #96
longwaveThanks! I tweaked both the note and change record a little but they look good to me.
Comment #98
idebr commented#85 Change record and Release note is available, setting back to RTBC
Comment #99
xjmAsking from complete ignorance: Is there any
web.configequivalent we should consider here for parity?Comment #100
longwave@xjm Great question. https://serverfault.com/a/904278 suggests it is both possible to do and to prevent duplicate headers, as in the .htaccess changes in this issue. However I think it would be necessary for someone running IIS to test this before we commit such a change.
Comment #101
xjmGreat, let's do that then. Thanks!
Comment #102
liam morlandThe
web.configfile is completely different. This should be done in a follow-up ticket.Comment #103
alexpottAlso web.config does not currently do anything with X-Content-Type-Options so it can't be causing the same bug. This fact might be a bug on it's own but it shouldn't be solved here as this issue is about Apache having duplicate headers.
Comment #104
alexpottAlso a decent number of IIS articles recommend setting this on the site level in the server (i.e. not using web.config - see https://dotnetcoretutorials.com/2017/01/20/set-x-content-type-options-as... for an example).
Comment #105
longwaveOpened #3336628: Set X-Content-Type-Options header in IIS web.config to discuss #99 through #104.
Comment #106
xjmIf IIS doesn't have it set at all yet, then @catch, @longwave, and I agreed with a followup.
However, please make note that in general, any issues for
.htaccessshould considerweb.configin the same issue scope. In this case, probably the previous issue should have. We've had frequent issues over the years (both public hardenings and private security issues) where theweb.confighas not been well-maintained and has not gotten the same fixes, so we always need to evaluate it @Liam Morland. It doesn't get excluded just because it's in a different file, and how issues are scoped is a release management decision.Comment #108
xjmSince we're unsetting all values for the header but only resetting
nosniff, I checked to verify that this was the only possible value for it. Per https://fetch.spec.whatwg.org/#x-content-type-options-header:So at least in the present spec, this solution is OK.
Adding credit for @alexpott for code review and the initial issue report, @Liam Morland, @alex-b, and @Mile23 for work on the patch, @longwave for review and work on the CR and release note, @JoshaHubbers for work on the CR and release note, @effulgentsia for review and work on the patch, @Wim Leers for review and issue triage, and @mr.baileys for review and manual testing. I did not credit @idebr for merging HEAD with no other changes.
I was wondering what happened to the earlier attempts at test coverage, but it seems #3226187: Enable mod_headers and mod_expires on Apache needs to be addressed for that.
Release notes should always tell the user:
The release note was missing the second two, so I added a sentence for that. I similarly expanded the content of the CR with background information on why this header is used in the first place, and put in a code snippet to show exactly what site owners should update. Finally, I linked the CR from the release note.
Committed and pushed to 10.1.x. Thanks!
Comment #109
xjmAdded the release note to the draft release notes doc.
Comment #110
gregglesI appreciate all the work on this issue. Thanks to everyone involved.
From the security team perspective, I support this paragraph from comment #106. We regularly will get reports "htaccess provides X security benefit that is missing in web.config" and then are in the awkward situation of deciding whether to do a security advisory and release for a fix that affects relatively few people.
I think we should consider it a bug if web.config and htaccess' behavior is different.
Comment #112
xjmPublished the CR.
Comment #113
idebr commentedIs this commit eligible for backporting as it is a bugfix?
Comment #114
longwaveChanges to .htaccess are usually only made in minor releases unless they are critical. This is not a critical bug - the duplicate header might cause warnings, but it has no actual impact on anything - so to me this is not eligible for backport.