Skip to content

Commit 822bd24

Browse files
Merge commit '8cfa2ee048145635ded8d55dc157d51fb45dc02c' into design-audit-phase1
2 parents 754bc24 + 8cfa2ee commit 822bd24

3 files changed

Lines changed: 133 additions & 14 deletions

File tree

core/middleware.py

Lines changed: 23 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,17 @@
6969
}
7070
)
7171
_BODY_METHODS = frozenset({"POST", "PUT", "PATCH", "DELETE"})
72+
_CREDENTIAL_HEADER_NAMES = frozenset(
73+
{
74+
"Authorization",
75+
"X-CSRFToken",
76+
"X-Preview-Token",
77+
"X-Management-Token",
78+
}
79+
)
80+
# These cookies are documented, non-credential preferences. Every other
81+
# viewer cookie is treated as unknown credential-like state and is private.
82+
_ANONYMOUS_COOKIE_NAMES = frozenset({"browser_timezone", "dtc_analytics_consent"})
7283

7384

7485
def apply_security_headers(response: HttpResponse) -> None:
@@ -264,16 +275,23 @@ def __call__(self, request: HttpRequest) -> HttpResponse:
264275
reset_context(tokens)
265276

266277

267-
def _is_authenticated_request(request: HttpRequest) -> bool:
278+
def _is_credential_bearing_request(request: HttpRequest) -> bool:
268279
user = getattr(request, "user", None)
269280
if bool(user is not None and user.is_authenticated):
270281
return True
271282
# Fail closed when an earlier middleware short-circuits before Django can
272-
# resolve the principal (notably SecurityMiddleware and WhiteNoise).
273-
if request.headers.get("Authorization"):
283+
# resolve the principal (notably SecurityMiddleware and WhiteNoise), and
284+
# for viewer-supplied credentials that do not establish a Django user.
285+
if any(request.headers.get(name) for name in _CREDENTIAL_HEADER_NAMES):
274286
return True
275287
session_cookie_name = getattr(settings, "SESSION_COOKIE_NAME", "sessionid")
276-
return session_cookie_name in request.COOKIES
288+
csrf_cookie_name = getattr(settings, "CSRF_COOKIE_NAME", "csrftoken")
289+
if session_cookie_name in request.COOKIES or csrf_cookie_name in request.COOKIES:
290+
return True
291+
# Only documented non-credential preferences are anonymous-safe. Any
292+
# other cookie is unknown credential-like state and must not share a
293+
# response.
294+
return any(name not in _ANONYMOUS_COOKIE_NAMES for name in request.COOKIES)
277295

278296

279297
def _is_private_surface(request: HttpRequest) -> bool:
@@ -340,6 +358,6 @@ def __call__(self, request: HttpRequest) -> HttpResponse:
340358
if settings.NOINDEX or private_surface:
341359
# Assignment replaces a downstream value instead of appending a second field.
342360
response["X-Robots-Tag"] = ROBOTS_HEADER_VALUE
343-
if _is_authenticated_request(request) or private_surface:
361+
if _is_credential_bearing_request(request) or private_surface:
344362
apply_private_no_store(response)
345363
return response

core/tests/test_development_seo.py

Lines changed: 88 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313
from core.middleware import apply_private_no_store
1414
from core.preview import SENSITIVE_PREVIEW_QUERY_KEYS, staff_preview_required
1515
from core.seo import validated_canonical_url
16-
from core.views import DEVELOPMENT_ROBOTS_BODY
16+
from core.views import DEVELOPMENT_ROBOTS_BODY, PRODUCTION_ROBOTS_BODY
1717
from courses.models import Course
1818

1919
FIXTURE_URLCONF = "core.tests.seo_fixture_urls"
@@ -122,6 +122,11 @@ def test_credential_bearing_early_response_fails_closed(self) -> None:
122122
for headers in (
123123
{"authorization": "Bearer opaque-input"},
124124
{"cookie": "sessionid=opaque-session"},
125+
{"cookie": "csrftoken=opaque-csrf"},
126+
{"cookie": "opaque_credential=opaque-value"},
127+
{"x-csrftoken": "opaque-csrf-header"},
128+
{"x-preview-token": "opaque-preview"},
129+
{"x-management-token": "opaque-management"},
125130
):
126131
with self.subTest(headers=headers):
127132
response = self.client.get("/missing", headers=headers)
@@ -164,10 +169,90 @@ def test_sitemap_get_and_head_expose_the_checked_section_index(self) -> None:
164169
self.assertEqual(head.content, b"")
165170

166171
@override_settings(NOINDEX=False)
167-
def test_production_exposes_only_the_public_sitemap(self) -> None:
172+
def test_production_robots_contract_and_public_sitemap(self) -> None:
168173
robots = self.client.get("/robots.txt")
169-
self.assertEqual(robots.status_code, 404)
174+
self.assertEqual(robots.status_code, 200)
175+
self.assertEqual(robots.content, PRODUCTION_ROBOTS_BODY.encode())
176+
self.assertEqual(robots.headers["Content-Type"], "text/plain; charset=utf-8")
177+
self.assertEqual(robots.headers["Cache-Control"], "max-age=0, must-revalidate")
170178
self.assertNotIn("X-Robots-Tag", robots.headers)
179+
self.assertNotIn("/podwiki/", robots.content.decode())
180+
self.assertNotIn("web.dtcdev.click", robots.content.decode())
181+
182+
head = self.client.head("/robots.txt")
183+
self.assertEqual(head.status_code, 200)
184+
self.assertEqual(head.content, b"")
185+
self.assertEqual(head.headers["Content-Type"], robots.headers["Content-Type"])
186+
self.assertEqual(head.headers["Cache-Control"], robots.headers["Cache-Control"])
187+
self.assertNotIn("X-Robots-Tag", head.headers)
188+
189+
for method in ("POST", "PUT", "PATCH", "DELETE", "OPTIONS"):
190+
with self.subTest(method=method):
191+
response = self.client.generic(method, "/robots.txt", data=b"opaque-input")
192+
self.assertEqual(response.status_code, 405)
193+
self.assertEqual(response.headers["Allow"], "GET, HEAD")
194+
self.assertEqual(response.headers["Cache-Control"], "no-store, max-age=0")
195+
self.assertNotIn("public", cache_directives(response))
196+
self.assertNotIn("s-maxage=3600", cache_directives(response))
197+
198+
credential_responses = (
199+
(
200+
"authorization",
201+
self.client.get("/robots.txt", HTTP_AUTHORIZATION="Bearer opaque-input"),
202+
self.client.head("/robots.txt", HTTP_AUTHORIZATION="Bearer opaque-input"),
203+
),
204+
(
205+
"session-cookie",
206+
self.client.get("/robots.txt", HTTP_COOKIE="sessionid=opaque-session"),
207+
self.client.head("/robots.txt", HTTP_COOKIE="sessionid=opaque-session"),
208+
),
209+
(
210+
"csrf-cookie",
211+
self.client.get("/robots.txt", HTTP_COOKIE="csrftoken=opaque-csrf"),
212+
self.client.head("/robots.txt", HTTP_COOKIE="csrftoken=opaque-csrf"),
213+
),
214+
(
215+
"csrf-token-header",
216+
self.client.get("/robots.txt", HTTP_X_CSRFTOKEN="opaque-csrf-header"),
217+
self.client.head("/robots.txt", HTTP_X_CSRFTOKEN="opaque-csrf-header"),
218+
),
219+
(
220+
"unknown-cookie",
221+
self.client.get("/robots.txt", HTTP_COOKIE="opaque_credential=opaque-value"),
222+
self.client.head("/robots.txt", HTTP_COOKIE="opaque_credential=opaque-value"),
223+
),
224+
(
225+
"preview-token-header",
226+
self.client.get("/robots.txt", HTTP_X_PREVIEW_TOKEN="opaque-preview"),
227+
self.client.head("/robots.txt", HTTP_X_PREVIEW_TOKEN="opaque-preview"),
228+
),
229+
(
230+
"management-token-header",
231+
self.client.get("/robots.txt", HTTP_X_MANAGEMENT_TOKEN="opaque-management"),
232+
self.client.head("/robots.txt", HTTP_X_MANAGEMENT_TOKEN="opaque-management"),
233+
),
234+
)
235+
for credential_kind, get_response, head_response in credential_responses:
236+
for method, response in (("GET", get_response), ("HEAD", head_response)):
237+
with self.subTest(credential_kind=credential_kind, method=method):
238+
directives = cache_directives(response)
239+
self.assertTrue({"private", "no-store", "max-age=0"}.issubset(directives))
240+
self.assertNotIn("public", directives)
241+
self.assertFalse(
242+
any(
243+
item.startswith("s-maxage=") and item != "s-maxage=0"
244+
for item in directives
245+
)
246+
)
247+
248+
for cookie in (
249+
"dtc_analytics_consent=v1.allow",
250+
"browser_timezone=Europe%2FBerlin",
251+
):
252+
with self.subTest(cookie=cookie):
253+
preference = self.client.get("/robots.txt", HTTP_COOKIE=cookie)
254+
self.assertEqual(cache_directives(preference), {"max-age=0", "must-revalidate"})
255+
self.assertNotIn("private", preference.headers["Cache-Control"])
171256

172257
sitemap = self.client.get("/sitemap.xml")
173258
self.assertEqual(sitemap.status_code, 200)

core/views.py

Lines changed: 22 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,19 @@
1818
from content.review_projection import review_projection
1919

2020
DEVELOPMENT_ROBOTS_BODY = "User-agent: *\nDisallow: /\n"
21+
PRODUCTION_ROBOTS_BODY = (
22+
"User-agent: *\n"
23+
"Disallow: /admin/\n"
24+
"Disallow: /_site/\n"
25+
"Disallow: /drafts/\n"
26+
"Disallow: /config/\n"
27+
"Disallow: /scripts/\n"
28+
"Disallow: /styles/\n"
29+
"Sitemap: https://datatalks.club/sitemap.xml\n"
30+
"Sitemap: https://datatalks.club/sitemaps/wiki.xml\n"
31+
)
32+
ROBOTS_CONTENT_TYPE = "text/plain; charset=utf-8"
33+
ROBOTS_ALLOWED_METHODS = ("GET", "HEAD")
2134

2235

2336
def management_slash_redirect(request: HttpRequest) -> HttpResponse:
@@ -59,13 +72,16 @@ def _development_seo_response(body: str, content_type: str) -> HttpResponse:
5972
return HttpResponse(body, content_type=content_type)
6073

6174

62-
@require_safe
6375
def robots(request: HttpRequest) -> HttpResponse:
64-
del request
65-
return _development_seo_response(
66-
DEVELOPMENT_ROBOTS_BODY,
67-
"text/plain; charset=utf-8",
68-
)
76+
if request.method not in ROBOTS_ALLOWED_METHODS:
77+
not_allowed_response = HttpResponseNotAllowed(ROBOTS_ALLOWED_METHODS)
78+
not_allowed_response["Cache-Control"] = "no-store, max-age=0"
79+
return not_allowed_response
80+
if settings.NOINDEX:
81+
return _development_seo_response(DEVELOPMENT_ROBOTS_BODY, ROBOTS_CONTENT_TYPE)
82+
response = HttpResponse(PRODUCTION_ROBOTS_BODY, content_type=ROBOTS_CONTENT_TYPE)
83+
response["Cache-Control"] = "max-age=0, must-revalidate"
84+
return response
6985

7086

7187
@require_safe

0 commit comments

Comments
 (0)