Skip to content

feat(core): redirect GET and HEAD for legacy host names - #213

Merged
bryangingechen merged 2 commits into
masterfrom
feat/legacy-host-redirect
Oct 11, 2026
Merged

bryangingechen merged 2 commits into
masterfrom
feat/legacy-host-redirect

Conversation

@bryangingechen

@bryangingechen bryangingechen commented Oct 11, 2026 •

Copy link
Copy Markdown
Contributor

Motivation

When the site moves to a new host name while an old one still reaches the same deployment, links and bookmarks that name the old host should land on the new one, without breaking anything that can't follow a redirect.

What changes

  • QUEUEBOARD_LEGACY_HOSTS (comma-separated; empty means off). A GET or HEAD request whose Host is one of them gets a redirect to the same path and query on QUEUEBOARD_BASE_URL's scheme and host. Matching ignores case, a port and a trailing dot.
  • QUEUEBOARD_LEGACY_HOST_REDIRECT_PERMANENT: the redirect is a 302 by default; set this to make it a 301 once it has worked for a while.
  • Every other method is served as before. Webhook senders don't follow redirects, and a redirected POST (an analytics beacon) loses its body.
  • Hosts not in the setting are unaffected: the canonical name, localhost for a container health check, and so on.
  • No open redirect. The target comes only from QUEUEBOARD_BASE_URL, never from the request: not from the path, and not from X-Forwarded-Host. The middleware removes itself (MiddlewareNotUsed) when no legacy host is set. When the base URL is unset, not http(s), or names a legacy host (which would redirect to itself), it also removes itself and logs a warning at startup. An entry that isn't a bare host name (a URL, *, .example.org) is ignored with a warning of its own.
  • It is first in MIDDLEWARE, ahead of SecurityMiddleware, so a redirected request reaches nothing that could redirect it again, set a cookie, or touch the database. production.py used to insert WhiteNoise at a fixed index. It now places WhiteNoise directly after SecurityMiddleware by looking that up, so the order stays the one WhiteNoise's docs ask for.
  • The host is read from the raw Host header rather than request.get_host(). The host only decides whether to redirect, and get_host() would reject a host missing from ALLOWED_HOSTS before that. Requests that aren't redirected still go through ALLOWED_HOSTS as before. The docs say to keep the old names in DJANGO_ALLOWED_HOSTS while they still reach the site, so that the methods that aren't redirected keep being served.
  • Settings are wired through base.py and .env.example. docs/production_image.md has a paragraph on moving to a new name, and qb_site/AGENTS.md notes the middleware's position.

User-visible effects

None until QUEUEBOARD_LEGACY_HOSTS is set. No migrations.

Rollout

  1. Deploy. Nothing changes.
  2. Before setting the variable, move API clients that send credentials to the new name. A client may drop an Authorization header when it is redirected to another host.
  3. Set QUEUEBOARD_LEGACY_HOSTS to the old name. Check that curl -sI https://<old>/api/ gives 302 with Location: https://<new>/api/, that a POST to the old name is still served, and that https://<new>/api/ still gives 200. A proxy that rewrote the new name's Host to an old one would make the new name redirect to itself.
    Webhook senders, monitors and anything else that posts are never moved by the redirect: move them to the new name before the old one goes away.
  4. Later, optionally, set QUEUEBOARD_LEGACY_HOST_REDIRECT_PERMANENT=1.

Testing

  • core.tests.test_legacy_host_redirect (20 tests, SimpleTestCase, so any database query fails them):
    • GET and HEAD redirect, with path and query kept; POST, PUT, PATCH, DELETE and OPTIONS pass through.
    • Case, port and trailing-dot matching; other hosts, a missing Host and an invalid Host pass through.
    • A //evil… path and a forged X-Forwarded-Host change only the path, never the target host.
    • 301 with the permanent setting; only the base URL's scheme and host are used.
    • Disabled when unset, and when the base URL is unset, invalid or names a legacy host (with the warning). Entries that aren't host names are warned about and skipped, and valid ones beside them still redirect. The CI settings pin the setting empty.
    • Through the whole stack: no cookie is set on a redirect, and POST and other hosts are served.
    • Order: first in MIDDLEWARE, and in production settings first, then SecurityMiddleware, then WhiteNoise.
  • Mutation check: dropping the method filter or the self-redirect guard fails the tests.
  • Under the real production settings, with the environment set as a deployment would set it: a GET for a static file and a HEAD for a page on the old host both 302 to the new one, a POST to the old host gets 200, localhost gets 200, and with the base URL unset the warning is logged and nothing redirects.
  • uv run ruff check ., uv run ruff format --check .
  • Review (a separate agent): the tests also pass on Django 6.1.2. Under gunicorn with production settings, it checked header injection (%0d%0a is encoded), // paths, http → https in one hop, and that CORS is unaffected. The review fixes are in c6e7c350.
  • bash scripts/repo_check_compose.sh: all 13 steps passed locally (core 133, syncer 543, analyzer 606, api 28, zulip_bot 350, console 96, site_analytics 81 tests).

🤖 Generated with Claude Code

https://claude.ai/code/session_01QoXLf4KSfqzMFrdZGQG9Up

bryangingechen and others added 2 commits October 11, 2026 17:17
When the site moves to a new host name while an old one still reaches
it, links and bookmarks that name the old host should land on the new
one. QUEUEBOARD_LEGACY_HOSTS lists the old names; a GET or HEAD request
whose Host is one of them is redirected to the same path and query on
QUEUEBOARD_BASE_URL's scheme and host. A 302 by default;
QUEUEBOARD_LEGACY_HOST_REDIRECT_PERMANENT makes it a 301 once the
redirect has proven itself.

Every other method is served as before: webhook senders don't follow
redirects, and a redirected POST (an analytics beacon) loses its body.

The target comes only from QUEUEBOARD_BASE_URL, never from the request,
so it can't be an open redirect. The middleware removes itself when no
legacy host is set, and, with a warning at startup, when the base URL is
unset, not http(s), or names a legacy host.

It is first in MIDDLEWARE, so a redirected request reaches nothing that
could redirect it again, set a cookie, or touch the database. Production
settings now place WhiteNoise directly after SecurityMiddleware by
lookup rather than by index, so it stays where WhiteNoise's docs ask.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QoXLf4KSfqzMFrdZGQG9Up
- Warn at startup about each QUEUEBOARD_LEGACY_HOSTS entry that isn't a
  bare host name (a URL, a wildcard, a leading-dot domain) instead of
  dropping it silently, as .env.example promised. Valid entries beside
  it still redirect.
- Docs: other methods are never moved by the redirect, so webhook
  senders, monitors and anything that posts must be moved to the new
  name themselves; and the proxy must pass each request's own Host
  through, or the new name redirects to itself.
- The CI settings pin QUEUEBOARD_LEGACY_HOSTS empty, so a developer's
  .env can't turn the redirect on for the suite.
- The stack tests' docstring no longer implies they pin the order;
  MiddlewareOrderTests does.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QoXLf4KSfqzMFrdZGQG9Up
@bryangingechen
bryangingechen marked this pull request as ready for review October 11, 2026 22:09
@bryangingechen
bryangingechen merged commit bfc05cc into master Oct 11, 2026
6 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant