Skip to content

Release 0.81.4 - #4021

Merged
odlbot merged 8 commits into
releasefrom
release-candidate
Oct 1, 2026
Merged

odlbot merged 8 commits into
releasefrom
release-candidate

Conversation

@odlbot

@odlbot odlbot commented Sep 30, 2026 •

Copy link
Copy Markdown
Contributor

Danielle Frappier

Carey P Gumaer

Ahtesham Quraish

Sar

daniellefrappier18 and others added 8 commits September 30, 2026 08:48
…paces (#4002)

* fix(auth): disable ApisixUserMiddleware by default in local dev/codespaces

ApisixUserMiddleware trusts the X-Userinfo header (base64-encoded JSON
identity claims) unconditionally, with no signature or secret tying it
to a real Keycloak login through APISIX -- it just assumes the header
could only be there because APISIX put it there.

The default local dev profile (COMPOSE_PROFILES=backend,frontend)
never runs APISIX/Keycloak, and Codespaces doesn't either while also
exposing forwardable, network-reachable ports -- so in both cases
nothing verifies that header before Django trusts it. A request
straight to Django with a forged X-Userinfo header logs in (and
creates, if the identity doesn't exist) as whoever the header claims.

Verified live, end to end: reproduced the exact repro from the linked
issue against a clean checkout (forged header -> is_authenticated:
true, brand-new account) with docker compose up (no APISIX/Keycloak
profile), then confirmed the same request is fully rejected
(is_authenticated: false, no account created) once
DISABLE_APISIX_USER_MIDDLEWARE=True is set.

DISABLE_APISIX_USER_MIDDLEWARE was already the purpose-built lever for
this -- traced through Django's RemoteUserMiddleware base class and
confirmed AUTHENTICATION_BACKENDS has no remote_user-capable backend,
so this is a safe no-op for real session-based Django logins. QA/
Production are unaffected (they don't set this anywhere; APISIX is the
only path in there, confirmed separately in the issue). Existing
middleware/admin tests set this explicitly per-test rather than
relying on the env default, and aren't affected either (42 passed).

Set the default in both env/backend.env and env/codespaces.env,
since codespaces.env doesn't inherit from backend.env (extends
replaces its env_file list rather than merging). Updated
README-keycloak.md's opt-in-to-Keycloak instructions to add the now-
necessary DISABLE_APISIX_USER_MIDDLEWARE=False override, and corrected
its now-inaccurate "Keycloak is enabled by default" claim.

Fixes mitodl/hq#13471

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* fix(tests): force middleware enabled for apisix_user_test.py, add disabled-path case

Copilot review feedback on this PR: env/backend.env's new
DISABLE_APISIX_USER_MIDDLEWARE=True default is injected into the web
container, so the documented `docker compose run --rm web ... pytest
main/middleware/apisix_user_test.py` path now runs those tests with
the middleware disabled. Confirmed live: on a fresh setup (no personal
backend.local.env override), that command now fails 12 tests and
errors 6 more with unrelated-looking TypeErrors -- process_request()
takes the disabled-path branch (Django's base RemoteUserMiddleware),
which touches real request attributes the test mocks don't provide.

The existing userinfo_flag_defaults autouse fixture already exists
specifically to pin settings regardless of backend.local.env drift
(its own docstring says so, for the userinfo create/update flags) --
extended it to also force DISABLE_APISIX_USER_MIDDLEWARE = False, so
every test in this file keeps exercising the enabled/trusting code
path regardless of the new env default.

Added test_disabled_middleware_ignores_forged_header as the requested
regression case for the actual disabled path: sets the flag to True
and asserts a forged X-Userinfo header is fully ignored through a real
request (client.get, not a bare Mock, since the disabled path needs
real request attributes) -- the same property already verified live
against a real docker compose setup in this PR's earlier commit,
now covered at the test level too.

Verified via the exact cited repro: `docker compose run --rm web
pytest main/middleware/apisix_user_test.py` now passes 19/19 (was
12 failed, 6 errored). Also re-ran via the normal host pytest path
(43 passed, unaffected either way).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
…#3988)

* fix(webhooks): stop leaking the real OCW_WEBHOOK_KEY into logs/Sentry

WebhookOCWView.handle_exception embedded the raw request body verbatim
into every exception it raises, unconditionally -- including the case
where the shared secret check already passed and something else in
the request goes wrong afterward, at which point the body genuinely
contains the real webhook_key the caller just authenticated with.
That message reaches the django.request logger (ERROR, full
traceback) and Sentry, both broader audiences than "whoever legitimately
calls this webhook."

A concrete, no-malice-required way this fires: prefixes arrives as
something other than a list/string (e.g. an int, from a buggy client)
-- .split(',') raises AttributeError *after* the key check succeeded,
routing straight into handle_exception with the real secret still in
the body.

Redact the webhook_key value out of the body before it's ever embedded
in a log/exception message, regardless of whether the key was right or
wrong -- simpler than trying to special-case "was this the real secret"
and just as effective, since redacting an already-known-wrong attempt
costs nothing.

Fixes mitodl/hq#13470

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* fix(webhooks): redact based on parsed JSON, not raw-text pattern match

Copilot review feedback: JSON allows an object member name to be
spelled with \uXXXX escapes -- "webhook_key" decodes to
"webhook_key" -- so a request using that spelling still authenticates
(rapidjson.loads decodes it before the dict lookup), but the previous
raw-text regex only matched the literal "webhook_key" bytes and would
leave that spelling's value completely unredacted. Verified this is a
real bypass: with the old regex, the real secret survived "redaction"
verbatim when spelled this way.

_redact_webhook_key now parses the body with the same rapidjson parser
the view itself uses, and redacts based on the decoded key -- so any
encoding of the same member name is caught, not just the literal bytes.
A body that fails to parse as a JSON object (malformed JSON, or valid
JSON that isn't an object) is redacted in full rather than assumed
safe to log, since we can't confirm it doesn't contain a secret either.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* fix(sentry): scrub OCW_WEBHOOK_KEY from captured Sentry events

Human review feedback on this PR (mbertrand): redacting the secret
from WebhookOCWView's own exception message doesn't stop it reaching
Sentry another way -- verified locally against a fake Sentry endpoint
that with the correct webhook_key and prefixes sent as an int (the
same post-auth error this PR already fixes at the message level), the
key still shows up in the captured event via the "content" local
variable in post()'s frame (captured because include_local_variables
defaults to True) and via request.data (captured unconditionally by
DjangoIntegration for JSON requests).

This codebase already has a general mechanism for exactly this class
of problem: main/sentry.py's before_send -> scrub_pg_details ->
_scrub_node walks every string in the serialized event, originally to
truncate a Postgres DETAIL line wherever the SDK could put it
(exception value, breadcrumbs, logentry.params, frame locals -- same
places this secret can end up). Added scrub_ocw_webhook_key, called
from the same recursive walk, so it's applied everywhere the walk
already reaches rather than needing to be threaded through every
capture path individually.

Scoped to OCW_WEBHOOK_KEY specifically, not WEBHOOK_SECRET (the other
webhook's secret): WEBHOOK_SECRET only ever exists as an HMAC signing
key derived from the request body (webhooks/utils.py), never
transmitted itself, so it doesn't have this same "the secret is also
valid caller-supplied input" leak path.

Verified the fix is real, not vacuous, the same way as the rest of
this PR: a throwaway test that monkeypatched the new scrub to a no-op
confirmed the secret genuinely leaks into a real captured Sentry event
without it.

New tests: a direct unit test on scrub_ocw_webhook_key, a
scrub_pg_details-level test with the key planted in request.data and
frame locals (matching mbertrand's exact finding), and an end-to-end
test through the real Django view + real Sentry SDK with
DjangoIntegration active (needed for request.data to actually attach)
-- reproducing the exact reported scenario and confirming no captured
event contains the real secret. 156 tests pass; pre-commit clean.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
Typing into a new article or news item and pausing would appear to
reload the page. The first autosave is what creates the item, and the
page answered that by pushing the new item's URL -- which unmounts the
editor and mounts the edit route in its place. The caret goes, the new
route shows a spinner while it fetches the item the editor is already
holding, and all of it happens a couple of seconds after the author
stops typing, mid-sentence.

The URL is now corrected in place with `history.replaceState`, Next's
supported way to change it without re-running the route, as
`useCanonicalizeResourceParam` already does for the resource drawer.
Nothing unmounts. That is safe because the URL is not what the editor
saves against: it remembers the row it created and PATCHes that, so
only a reload, a bookmark or a shared link needed the address bar to
catch up. `replace` rather than `push` also keeps Back out of a trail of
one entry per autosave.

The edit page's draft branch gets the same treatment. It is unreachable
today -- `onSave` only fires there on a publish, since the item always
exists -- but it was carrying a `usePathname` guard that existed purely
to suppress this same navigation, and the guard goes with it.

Co-authored-by: Ahtesham Quraish <ahtesham.quraish@arbisoft.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
* chore(api): use the mitxonline-api-axios branch build with contract consent

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* feat(ol-components): let Dialog hide its close button

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* feat(api): add useDataConsentMutation and learner contract factories

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* feat(dashboard): add a disabled state to course cards

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* feat(dashboard): add the B2B data consent dialog behind a feature flag

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* feat(dashboard): require data consent on the B2B contract dashboard

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* test(dashboard): use learner contract and organization types in test setup

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix(dashboard): show the consent spinner on the button that was clicked

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* chore(api): rebuild the mitxonline-api-axios branch client with openapi-generator v7.25.0

The previous branch build used v7.2.0, which names some types differently
from published releases.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* switch back to published api package

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@odlbot
odlbot requested a review from a team as a code owner September 30, 2026 17:49
@github-actions

Copy link
Copy Markdown

OpenAPI Changes

No changes detected

View full changelog

Unexpected changes? Ensure your branch is up-to-date with main (consider rebasing).

This branch was successfully deployed

1 active deployment
github-pages — 62bea9f1 Deployed Oct 1, 2026 by odlbot via deploy #2250
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants