Skip to content
Merged
Show file tree
Hide file tree
Changes from 21 commits
Commits
Show all changes
44 commits
Select commit Hold shift + click to select a range
13b04ca
Add the public channel platform behind flag_public_channel
barry47products Aug 26, 2026
bea119e
Treat the public channel as a widget platform
barry47products Aug 26, 2026
694c48d
Pin the public channel to the canonical host through one origin rule
barry47products Aug 26, 2026
dbbac8d
Refuse public link starts without a published version
barry47products Aug 26, 2026
34196ac
Serve public link sessions from the published version on every request
barry47products Aug 26, 2026
a0939e0
Pin the keyless fallback refusal for public sessions
barry47products Aug 26, 2026
b85c13e
Throttle public link starts per visitor IP
barry47products Aug 26, 2026
cad54a7
Regenerate a public link token and end its live sessions
barry47products Aug 26, 2026
e39ad99
Add the public link channel form with a regenerate action
barry47products Aug 26, 2026
053fb33
Cover creating and regenerating a public link through the dialog
barry47products Aug 26, 2026
19360e6
Serve the public link page with the widget in kiosk mode
barry47products Aug 26, 2026
a9dff0c
Serve the public link page without the app chrome
barry47products Aug 26, 2026
16dc0dd
Show a copy chip for the public link on the chatbot home
barry47products Aug 26, 2026
2189140
Hide the public link value inside the copy chip
barry47products Aug 26, 2026
befa340
Tolerate a used platform the dropdown no longer offers
barry47products Aug 26, 2026
a3b1ae1
Remove the chat widget prototype behind flag_chat_widget
barry47products Aug 26, 2026
e2cbb32
Keep the public page off localStorage until widget release A ships
barry47products Aug 26, 2026
7dea35b
Let team members chat on an unpublished public link
barry47products Aug 26, 2026
529a9e4
Update the hosting docs for the public link
barry47products Aug 26, 2026
861614c
Tidy the public link helpers
barry47products Aug 26, 2026
9d2b1a9
Harden the public link after adversarial review
barry47products Aug 26, 2026
67929e9
Merge remote-tracking branch 'origin/main' into bt/public-channel-bac…
barry47products Aug 26, 2026
e870967
Flatten the send-version and dropdown flag branches
barry47products Aug 26, 2026
aa95a6d
Import the session models at module level in channels.models
barry47products Aug 26, 2026
83b8763
Merge branch 'main' into bt/public-channel-backend-3682
barry47products Aug 26, 2026
c6d1b13
Describe the published version on public starts and parse hosts as au…
barry47products Aug 26, 2026
f128029
Start logged-in non-members on the public channel as visitors
barry47products Aug 27, 2026
451b5c0
Merge branch 'main' into bt/public-channel-backend-3682
barry47products Aug 27, 2026
8a729a8
Merge branch 'main' into bt/public-channel-backend-3682
barry47products Aug 27, 2026
92a0554
Merge branch 'main' into bt/public-channel-backend-3682
barry47products Aug 27, 2026
98ea43e
Keep the public link widget disabled for team members on a disabled c…
barry47products Aug 27, 2026
12e6b94
Merge the bot_channels migration leaves after syncing with main
barry47products Aug 27, 2026
d92236a
Merge branch 'main' into bt/public-channel-backend-3682
barry47products Aug 28, 2026
4f99fc0
Keep a public link conversation to the tab that started it
barry47products Aug 28, 2026
494d290
Leave public link channels out of the outdated widget report
barry47products Aug 28, 2026
d0509ce
Keep the widget version surfaces to the embedded widget
barry47products Aug 28, 2026
45d2747
Tell a signed-in viewer why a public link 404s on the wrong host
barry47products Aug 28, 2026
5a34638
Merge branch 'main' into bt/public-channel-backend-3682
barry47products Aug 31, 2026
971b841
Name the published chatbot on a switched-off public link
barry47products Aug 31, 2026
a4d86fb
Assert the rendered host sentence rather than a bare substring
barry47products Aug 31, 2026
19e4d30
Stop gating the public link on a consent form
barry47products Aug 31, 2026
aaac6fe
Share the public channel flag fixture across the channels tests
barry47products Aug 31, 2026
6b0c6ae
Drop an unverified claim from the consent test docstring
barry47products Aug 31, 2026
ceccdb7
Withhold an unpublished draft's name from public link visitors
barry47products Aug 31, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions api-schemas/export.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5027,6 +5027,7 @@ components:
- evaluations
- embedded_widget
- email
- public
type: string
description: |-
* `telegram` - Telegram
Expand All @@ -5040,6 +5041,7 @@ components:
* `evaluations` - Evaluations
* `embedded_widget` - Chat Widget & API
* `email` - Email
* `public` - Public link
PricingRuleDetail:
type: object
properties:
Expand Down
19 changes: 19 additions & 0 deletions api-schemas/v1.yml
Original file line number Diff line number Diff line change
Expand Up @@ -234,6 +234,12 @@ paths:
schema:
$ref: '#/components/schemas/ChatAccessDenied'
description: ''
'409':
content:
application/json:
schema:
$ref: '#/components/schemas/ChatStartSessionRefused'
description: ''
/api/experiments/:
get:
operationId: experiment_list
Expand Down Expand Up @@ -1174,6 +1180,17 @@ components:
* `processing` - Processing
* `completed` - Completed
* `error` - Error
ChatStartSessionRefused:
type: object
properties:
error:
type: string
code:
type: string
description: '`no_published_version` or `consent_unavailable`.'
required:
- code
- error
ChatStartSessionRequest:
type: object
properties:
Expand Down Expand Up @@ -1812,6 +1829,7 @@ components:
- evaluations
- embedded_widget
- email
- public
type: string
description: |-
* `telegram` - Telegram
Expand All @@ -1825,6 +1843,7 @@ components:
* `evaluations` - Evaluations
* `embedded_widget` - Chat Widget & API
* `email` - Email
* `public` - Public link
SessionModelUsage:
type: object
properties:
Expand Down
4 changes: 4 additions & 0 deletions api-schemas/v2.yml
Original file line number Diff line number Diff line change
Expand Up @@ -675,6 +675,7 @@ paths:
- commcare_connect
- embedded_widget
- email
- public
type: string
minLength: 1
description: |-
Expand All @@ -690,6 +691,7 @@ paths:
* `commcare_connect` - commcare_connect
* `embedded_widget` - embedded_widget
* `email` - email
* `public` - public
- in: query
name: start
schema:
Expand Down Expand Up @@ -2406,6 +2408,7 @@ components:
- evaluations
- embedded_widget
- email
- public
type: string
description: |-
* `telegram` - Telegram
Expand All @@ -2419,6 +2422,7 @@ components:
* `evaluations` - Evaluations
* `embedded_widget` - Chat Widget & API
* `email` - Email
* `public` - Public link
PromptVariable:
type: object
description: A template variable rather than a resource id -- there is no `value`
Expand Down
27 changes: 20 additions & 7 deletions apps/api/authentication.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
from apps.experiments.models import Experiment
from apps.oauth.permissions import validated_machine_token
from apps.teams.utils import set_current_team
from apps.web.meta import canonical_hostname


def chatbot_id_from_body(request) -> str | None:
Expand Down Expand Up @@ -66,7 +67,7 @@ def authenticate(self, request):
try:
experiment_channel = ExperimentChannel.objects.select_related("experiment", "team").get(
experiment__public_id=experiment_id,
platform=ChannelPlatform.EMBEDDED_WIDGET,
platform__in=ChannelPlatform.widget_platforms(),
extra_data__widget_token=embed_key,
deleted=False,
)
Expand Down Expand Up @@ -101,6 +102,21 @@ def authenticate_header(self, request):
return "X-Embed-Key"


def channel_origin_allowed(request, channel: ExperimentChannel) -> bool:
"""Whether the request's Origin (or Referer) may use `channel`.

An embedded widget lists the domains it may be embedded on. A public link runs on the OCS
host only, so its rule is the canonical Site hostname; hostname to hostname, so ports do
not matter. Same-origin GETs carry no Origin header, which is why Referer is the fallback.
"""
origin_domain = extract_domain_from_headers(request)
if not origin_domain:
return False
if channel.platform == ChannelPlatform.PUBLIC:
return origin_domain.lower() == canonical_hostname()
return validate_domain(origin_domain, channel.extra_data.get("allowed_domains", []))


def embed_key_authorizes_channel(request, channel: ExperimentChannel | None) -> bool:
"""Whether this request's X-Embed-Key proves access to `channel`.

Expand All @@ -117,7 +133,7 @@ def embed_key_authorizes_channel(request, channel: ExperimentChannel | None) ->
embed_key = request.headers.get("X-Embed-Key")
if not embed_key or channel is None:
return False
if channel.platform != ChannelPlatform.EMBEDDED_WIDGET:
if channel.platform not in ChannelPlatform.widget_platforms():
return False
# Callers that reach a channel by FK traversal (`session.experiment_channel`) bypass the
# default manager's `deleted=False`, so deleting a widget would otherwise not revoke its key.
Expand All @@ -126,10 +142,7 @@ def embed_key_authorizes_channel(request, channel: ExperimentChannel | None) ->
if embed_key != channel.extra_data.get("widget_token"):
return False

origin_domain = extract_domain_from_headers(request)
if not origin_domain:
return False
return validate_domain(origin_domain, channel.extra_data.get("allowed_domains", []))
return channel_origin_allowed(request, channel)


def get_embed_key_channel(request, experiment) -> ExperimentChannel | None:
Expand All @@ -145,7 +158,7 @@ def get_embed_key_channel(request, experiment) -> ExperimentChannel | None:
ExperimentChannel.objects.select_related("experiment", "team")
.filter(
experiment=experiment,
platform=ChannelPlatform.EMBEDDED_WIDGET,
platform__in=ChannelPlatform.widget_platforms(),
extra_data__widget_token=embed_key,
)
.first()
Expand Down
16 changes: 5 additions & 11 deletions apps/api/permissions.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,10 @@
from rest_framework.permissions import SAFE_METHODS, BasePermission, DjangoModelPermissions, IsAuthenticated
from rest_framework_api_key.permissions import KeyParser

from apps.api.authentication import embed_key_authorizes_channel, oauth_resolved_channel
from apps.api.authentication import channel_origin_allowed, embed_key_authorizes_channel, oauth_resolved_channel
from apps.api.session_tokens import session_token_expired, validate_session_token
from apps.channels.models import ExperimentChannel, WidgetAuthLevel
from apps.channels.utils import extract_domain_from_headers, get_experiment_session_cached, validate_domain
from apps.channels.utils import get_experiment_session_cached
from apps.oauth.permissions import is_client_credentials_request
from apps.teams.helpers import get_team_membership_for_request, set_request_attrs
from apps.teams.utils import set_current_team
Expand Down Expand Up @@ -94,17 +94,11 @@ def has_permission(self, request, view):
# Each credential validates its own origin, and ChatOAuthAuthentication has already
# applied the rule for this one — including the case this check cannot express, where a
# blank domain list declares the channel server-only and an originless request is the
# correct shape. The `if not origin_domain` line below would reject it before the view
# ever runs.
# correct shape. The origin rule now lives in `channel_origin_allowed`, which would
# reject an originless server-only request before the view runs.
return True

origin_domain = extract_domain_from_headers(request)
if not origin_domain:
return False

experiment_channel = request.auth
allowed_domains = experiment_channel.extra_data.get("allowed_domains", [])
return validate_domain(origin_domain, allowed_domains)
return channel_origin_allowed(request, request.auth)


class SessionAccessPermission(BasePermission):
Expand Down
133 changes: 133 additions & 0 deletions apps/api/tests/test_public_channel_origin.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
"""The public channel is pinned to the OCS canonical host (spec D3).

Both origin call sites go through `channel_origin_allowed`: the permission class when the embed
key authenticated the request, and `embed_key_authorizes_channel` when a Django session cookie
authenticated first and the key merely rode along.
"""

import pytest
from django.contrib.sites.models import Site
from django.test import RequestFactory
from django.urls import reverse
from rest_framework.test import APIClient

from apps.api.authentication import channel_origin_allowed, embed_key_authorizes_channel
from apps.channels.models import ChannelPlatform
from apps.experiments.models import ExperimentSession
from apps.utils.factories.channels import ExperimentChannelFactory
from apps.utils.factories.experiment import ExperimentFactory
from apps.utils.factories.user import UserFactory

TOKEN = "public_token_1234567890123456789012"
CANONICAL = "ocs.example.com"


@pytest.fixture(autouse=True)
def _canonical_site(db):
Site.objects.filter(id=1).update(domain=f"{CANONICAL}:8443", name="OCS")
Site.objects.clear_cache()
yield
Site.objects.clear_cache()


@pytest.fixture()
def public_channel(team_with_users):
experiment = ExperimentFactory.create(team=team_with_users, consent_form=None)
experiment.create_new_version(make_default=True)
return ExperimentChannelFactory.create(
team=team_with_users, experiment=experiment, platform=ChannelPlatform.PUBLIC, extra_data={"widget_token": TOKEN}
)


def _request(origin=None, referer=None):
headers = {}
if origin:
headers["HTTP_ORIGIN"] = origin
if referer:
headers["HTTP_REFERER"] = referer
return RequestFactory().post("/api/chat/start/", **headers)


@pytest.mark.django_db()
@pytest.mark.parametrize(
("origin", "referer", "allowed"),
[
pytest.param(f"https://{CANONICAL}", None, True, id="canonical-origin"),
pytest.param(f"https://{CANONICAL}:8443", None, True, id="port-ignored"),
pytest.param(f"https://{CANONICAL.upper()}", None, True, id="case-insensitive"),
pytest.param(None, f"https://{CANONICAL}/c/{TOKEN}/", True, id="referer-fallback"),
pytest.param("https://evil.example.org", None, False, id="foreign-origin"),
pytest.param(f"https://sub.{CANONICAL}", None, False, id="subdomain-refused"),
pytest.param(None, None, False, id="no-origin"),
],
)
def test_public_channel_origin_rule(public_channel, origin, referer, allowed):
assert channel_origin_allowed(_request(origin, referer), public_channel) is allowed


@pytest.mark.django_db()
def test_embedded_channel_still_uses_its_domain_list(experiment):
channel = ExperimentChannelFactory.create(
experiment=experiment,
platform=ChannelPlatform.EMBEDDED_WIDGET,
extra_data={"widget_token": TOKEN, "allowed_domains": ["partner.example.com"]},
)
assert channel_origin_allowed(_request("https://partner.example.com"), channel) is True
assert channel_origin_allowed(_request(f"https://{CANONICAL}"), channel) is False


@pytest.mark.django_db()
def test_embed_key_authorizes_a_public_channel_from_the_canonical_origin(public_channel):
request = _request(f"https://{CANONICAL}")
request.META["HTTP_X_EMBED_KEY"] = TOKEN
assert embed_key_authorizes_channel(request, public_channel) is True
foreign = _request("https://evil.example.org")
foreign.META["HTTP_X_EMBED_KEY"] = TOKEN
assert embed_key_authorizes_channel(foreign, public_channel) is False


def _start(client, experiment, body=None, **extra):
return client.post(
reverse("api:chat:start-session"),
data={"chatbot_id": experiment.public_id, "session_data": {"source": "widget"}, **(body or {})},
format="json",
**extra,
)


@pytest.mark.django_db()
def test_anonymous_start_from_the_canonical_origin_lands_on_the_public_channel(public_channel):
response = _start(
APIClient(), public_channel.experiment, HTTP_X_EMBED_KEY=TOKEN, HTTP_ORIGIN=f"https://{CANONICAL}"
)
assert response.status_code == 201, response.content
session = ExperimentSession.objects.get(external_id=response.json()["session_id"])
assert session.experiment_channel == public_channel
assert session.participant.platform == "public"


@pytest.mark.django_db()
def test_anonymous_start_from_a_foreign_origin_is_refused(public_channel):
response = _start(
APIClient(), public_channel.experiment, HTTP_X_EMBED_KEY=TOKEN, HTTP_ORIGIN="https://evil.example.org"
)
assert response.status_code == 403


@pytest.mark.django_db()
@pytest.mark.parametrize("member", [pytest.param(True, id="team-member"), pytest.param(False, id="non-member")])
def test_logged_in_user_on_the_page_lands_on_the_public_channel(public_channel, member):
team = public_channel.team
user = team.members.first() if member else UserFactory.create()
client = APIClient()
client.force_login(user)
response = _start(
client,
public_channel.experiment,
{"participant_remote_id": user.email},
HTTP_X_EMBED_KEY=TOKEN,
HTTP_ORIGIN=f"https://{CANONICAL}",
)
assert response.status_code == 201, response.content
session = ExperimentSession.objects.get(external_id=response.json()["session_id"])
assert session.experiment_channel == public_channel
Loading
Loading