[Backend][Security] GET /campaigns/{id} returns the Discord webhook URL and analytics share token to every member #671
Closed
opened 2026-09-16 03:48:20 +00:00 by claude-bot
·
2 comments
No Branch/Tag specified
main
feat/1328-fill-it-in
feat/1328-fill-frontend
feat/1328-fill-backend
feat/9-foundry-push
fix/9-foundry-live
feat/9-foundry-push-frontend
renovate/celery-5.x
feat/5-foundry-connect
feat/9-foundry-push-backend
feat/9-foundry-push-module
feat/5-foundry-frontend
feat/5-foundry-backend
feat/5-foundry-module
release/v4.8.0-rc1
docs/d341-convert-draft
feat/958-update-to-version
feat/958-convert-sheet
feat/958-pathbuilder-import-frontend
fix/1360-heading-order
feat/958-pathbuilder-import-backend
feat/11-weekly-digest
feat/1341-auto-update
docs/d332-superseded
chore/1345-names-cleanup
feat/1342-mentioned-pages
feat/1328-recipes
fix/1346-pgdg-key
perf/1247-shared-recording-stream
chore/132-gitleaks-allowlist
release/v4.7.1
spike/foundry-module
release/v4.7.1-rc1
docs/decisions-d332-d337
fix/bot-certifi
chore/release-4.7.0
chore/release-4.7.0-rc4
chore/deps-4.7.0
chore/release-4.7.0-rc3
fix/1280-quote-must-say-it
fix/1257-names-row-squeeze
chore/release-4.7.0-rc2
fix/1245-pg-upgrade-count
chore/release-4.7.0-rc1
feat/1257-names-in-prep
feat/1257-be
feat/1257-fe
feat/1257-rating-fixes
design/1328-fill-it-in
feat/1257-name-splits
fix/1326-redis-503
feat/1280-fact-suggestions
feat/1280-be
feat/1280-fe
design/1280-fact-suggestions
feat/1279-infobox
feat/1279-be
feat/1279-fe
feat/1321-shelf-copies
design/1279-infobox
feat/1243-table-find
feat/1257-who-rated
feat/1243-prep-drawer
feat/1243-sheet-pickers
design/1243-browse-in-place
feat/1315-hotwords-default
feat/1242-system-filters
feat/1257-more-name-styles
feat/506-leg3-hotwords
design/1242-system-filters
fix/1302-packs-polish
feat/1241-rules-library
design/1241-rules-library
feat/1245-postgres-18
integ/4.6.2
fix/1263-map-ui-2
fix/1263-map-server
fix/1263-map-ui-1
fix/1261-editor-link-menu
fix/1229-axe-4.14-name-mismatch
renovate/axllent-mailpit-1.x
renovate/eslint-monorepo
renovate/authlib-1.x
chore/release-4.6.0
chore/release-4.6.0-rc2
fix/460-rc2-screens
fix/460-rc2-server
fix/release-prerelease-exit
chore/release-4.6.0-rc1
feat/125-campaign-templates
feat/126-calendar
feat/1181-pictures-and-files
feat/1190-event-people
integ/4.6.0
feat/1191-line-of-the-night
feat/1200-starter-packs
chore/bot-multidict-and-playwright-group
fix/1202-this-week-answered-count
fix/122-124-leftovers
fix/1178-get-routes-that-write
fix/1203-journeys-check-names
design/125-campaign-templates
feat/126-calendar-engine
design/126-in-world-calendar
fix/1189-cited-stamp-in-range
fix/1189-v4-looser-check
fix/1189-richer-beats
fix/1113-export-bind-lists
fix/1154-release-notes-by-file
chore/release-4.5.0
chore/release-4.5.0-rc12
fix/1135-wiki-markup-as-text
fix/demo-usage-cost-in-window
chore/release-4.5.0-rc11
fix/1121-sheet-consistency
fix/1121-sheet-5e-terms-and-card-hp
copy/1122-busiest-pages
fix/1121-relevant-choices
fix/1121-wiki-create-npc-save-sheet-links
fix/1029-menu-stays-on-screen
docs/prototype-transcript-bin-wording
fix/1023-editor-save-row-back-scroll
fix/1020-search-exact-first
fix/1020-table-prep
fix/1020-shell-wordmark-footer-bell
fix/1020-wiki-article-party
fix/872-admin-kept-until-phone-tables
feat/l13-milestone-level-waiting
fix/1000-1001-bell-older-setup-context
fix/999-pronouns-in-prompts
fix/l19-other-class-feats
fix/1010-transcript-window-ordinary-confirm
docs/d254-d269-decisions
fix/702-member-search
fix/680-transcript-bin
fix/993-unidentified-prompts
fix/841-redis-timeouts
fix/990-binned-not-exported
fix/830-export-prep-shelf
fix/deps-audit-2026-10-01
fix/877-title-reads
fix/798-audio-received-at
build/lane11-17-j7
build/lane11-16-levelup
build/lane11-14-seams
build/lane11-14-seed
build/lane11-15-start
build/lane11-13-old-block-out
fix/980-review-r3
build/lane11-12-block
build/lane11-8-packs
fix/demo-seed-canonical-names
build/lane11-11-hot-half
docs/l11-decisions
build/lane11-7-convert
build/lane11-10-editing
fix/npm-audit-brace-expansion
build/lane11-9-sheet-page
fix/bot-pyjwt
build/lane11-6-content
fix/lane11-rank-names
build/lane11-2b-language
build/lane11-4-js-evaluator
build/lane11-3-server
build/lane11-5a-pf2e
build/lane11-5b-5e
feat/design-admin
build/lane11-2-evaluator
build/lane11-1-sheet-pieces
build/lane11-0-drawing
fix/943-quest-objectives-fk
fix/prepsheet-shelf-flake
fix/945-leftovers
build/demo-gm-copy
fix/901-email-landings
build/lane10-8-journeys
build/demo-seed-lanes-8-10
fix/887-gm-own-confirmation
build/lane9-5-j1-admin
fix/918-audience-click
fix/useasync-overtaken
ci/939-critical-path
test/937-backend-suite-speed
chore/claude-orchestration
fix/924-xp-gm-only
fix/session-stale-poll
docs/decisions-d239-d242
fix/930-pypdf
build/lane8-journeys
build/925-quest-editor
fix/894-private-page-pictures
build/lane8-p4-drafts
build/lane8-p7-map
build/lane8-p6-handouts-rules
fix/wiki-fixture-names
build/lane10-d227-test-usage
fix/rsvp-horizon-test-tuesday
fix/media-volume-owner
build/lane8-p3-editor
fix/912-unidentified-rename
build/lane8-p5-party-quests
build/lane8-handout-takeback
build/lane10-pickers
build/lane8-p2-article
build/lane8-6-map
build/lane10-7b-email-pages
build/lane8-5-handouts
build/lane10-7a-test-the-bot-pages
build/lane8-p1-pages
build/lane8-4-quests
build/lane8-2b-feeds
build/lane8-3-parties
build/lane8-2a-links
build/lane10-5-commands
build/lane9-6-seed
build/lane10-4b-reactions
build/lane9-4d-pages
build/lane9-4c-pages
build/lane10-4a-posts
build/lane10-3-email
fix/891-web-fonts
build/lane9-4b-pages
build/lane10-6-test-the-bot
build/lane10-2-notify-out
build/lane8-1-wiki-text
build/lane9-4a-frame
design/lane10-drawings
fix/j6-budget
fix/j5-phone-flake
fix/869-test-leaks
fix/843-record-start-followups
build/lane7-all
build/lane6-all
build/lane9-b2-server
chore/changelog-fragments
docs/decisions-d218
build/lane9-a-owner-overview
fix/rule-card-level-label
fix/834-retention-semantics
fix/o3c-record-without-announcement
docs/decisions-d179
docs/decisions-d131-d170
fix/851-commit-before-response
build/lane6-j3-seed
build/lane9-b1-d67-identity
fix/841-blocking-loop
build/lane9-c-backups-audit
fix/vote-race-and-start-commit
fix/recording-announcement
fix/proposal-read-leak
fix/last-admin-guard
build/lane6-p4c-planning
build/lane6-p4b-margin
build/lane6-p4a-sheet
build/lane5-j4-seed
build/lane6-base
build/lane7-pages-talk
build/lane5-pages
build/lane7-pages-safety
build/lane7-p3-safety
build/lane7-p2-notify
build/lane6-p3-planning
build/lane6-p2-history
build/lane6-p1b-items
build/lane7-p1-threads
build/lane5-p6-faces
build/lane6-p1a-sheet
build/lane5-p5-find
build/lane5-p4a-board
build/lane5-p4b-scenes
build/lane5-p3-live
build/lane5-base
build/lane5-p2-cards
docs/lane5-decisions
build/review-r2-wide
fix/review-r1
build/lane5-p1-scenes
build/demo-seed
build/lane4-journeys-j5
build/lane4-page-review-b
build/lane4-page-review-a
build/lane4-page-read
build/lane4-journeys
build/lane4-page-sess2
build/lane4-page-sess1
build/lane4-notes
build/lane4-discord-on-approval
build/lane4-scheduling
build/lane4-versions
build/760-session-number
build/lane4-decisions
build/797-erase-no-regenerate
build/test-race-audit
build/lane3-completions
build/794-dark-only
build/796-titles-scroll
build/791-retention-anchor
build/a11y-sweep
build/shell-followups
build/retention-preview
build/753-backup-readback
docs/d91-d93
build/lane3-j1
build/ci-harness-paths-selftest
fix/useauth-test-race
build/ci-harness-paths
build/777-ai-settings-read
build/lane3-settings-c
build/lane3-settings-b
build/lane3-j6
build/lane3-sessionform
build/lane3-settings-a
build/lane3-members
build/lane3-thisweek
build/lane3-story
chore/87-align-python-3.12
v4.8.0-rc1
v4.7.1
v4.7.1-rc1
v4.7.0
v4.7.0-rc4
v4.7.0-rc3
v4.7.0-rc2
v4.7.0-rc1
v4.6.2
v4.6.2-rc4
v4.6.2-rc3
v4.6.2-rc2
v4.6.2-rc1
v4.6.1
v4.6.1-rc1
v4.6.0
v4.6.0-rc2
v4.6.0-rc1
v4.5.3
v4.5.3-rc1
v4.5.2
v4.5.2-rc1
v4.5.1
v4.5.1-rc2
v4.5.1-rc1
v4.5.0
v4.5.0-rc12
v4.5.0-rc11
v4.5.0-rc10
v4.5.0-rc9
v4.5.0-rc8
v4.5.0-rc7
v4.5.0-rc6
v4.5.0-rc5
v4.5.0-rc4
v4.5.0-rc3
v4.5.0-rc2
v4.5.0-rc1
v4.4.1
v4.4.0
v4.3.0
v4.2.3
v4.2.2
v4.2.1
v4.2.0
v4.1.2
v4.1.1
v4.1.0
v4.0.1
v4.0.0
v3.11.4
v3.11.3
v3.11.2
v3.11.1
v3.11.0
v3.10.0
v3.9.1
v3.9.0
v3.8.0
v3.7.0
v3.6.1
v3.6.0
v3.5.0
v3.4.1
v3.4.0
v3.3.0
v3.2.0
v3.1.0
v3.0.0
v2.1.0
v2.0.0
v1.1.0
v1.0.0
v0.12.0
v0.11.0
v0.10.0
v0.9.1
v0.3.3
v0.9.0
v0.8.0
v0.3.2
v0.3.1
v0.3.0
v0.7.0
v0.6.0
v0.5.0
v0.4.0
v0.2.1
v0.2.0
v0.1.1
v0.1.0
Labels
Clear labels
backend
Issue related to the backend
bot
Issue related to the bot
bug
Something is not working
duplicate
This issue or pull request already exists
enhancement
New feature
frontend
Issue related to the frontend
help wanted
Need some help
invalid
Something is wrong
question
More information is needed
recording
Issue related to the recording system
security
Security hardening / vulnerability work
wontfix
This won't be fixed
No labels
backend
bot
bug
duplicate
enhancement
frontend
help wanted
invalid
question
recording
security
wontfix
Milestone
Clear milestone
No items
No milestone
Projects
Clear projects
No items
No project
Assignees
Clear assignees
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".
No due date set.
Dependencies
No dependencies set
Reference
rbrooks/Quest-Board#671
Loading…
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Found while checking, for the redesign (#659), whether a player needs a Settings link. The answer was no — and the reason turned out to be interesting: the settings form is GM-only, but the settings data is not.
What happens
GET /campaigns/{campaign_id}is gated onrequire_campaign_member(webapp/backend/app/routers/campaigns.py:960-972) and returns the fullCampaignResponse. That schema carries, among the ordinary fields:discord_webhook_url(webapp/backend/app/schemas/campaign.py:367)guild_id(:368)notification_channel_id(:369)analytics_share_token(:396)to_campaign_responsetakes the viewer but uses it only to computeis_owner(webapp/backend/app/services/campaign_service.py:28-30, 52); every field above is copied straight off the model with no role check (:57-59, and the share token further down).The frontend renders these only inside the GM-gated Edit Campaign form, so nothing looks wrong in the UI. But any member can read them from the API response — devtools, or any script holding their own session.
Why the webhook URL matters most
A Discord webhook URL is a credential, not a setting. Anyone holding it can post arbitrary messages to that channel, as the webhook's configured name and avatar, with no authentication and no audit trail back to them. A player who copies it can impersonate Quest Board's own notifications in the group's Discord — including fake session cancellations or fake reminders.
This is a different problem from #97, which validated the URL against a Discord host allowlist on write (SSRF). That one was about where the server may send requests; this is about who may read the value back.
analytics_share_tokenis a lesser version of the same shape: creating, rotating and revoking it are correctlyrequire_gm(campaigns.py:2588,:2616), but the existing value is readable by every member, so a player can hand the public analytics link — which carries every member's name and attendance rate — to anyone, and the GM has no way to know it was them.guild_idandnotification_channel_idare ids rather than credentials, anddiscord_delivery_statusis already deliberately member-visible with a comment explaining why (schemas/campaign.py:370-375) — that reasoning is sound and should stay.Threat model, honestly stated
Everyone in a campaign is a friend group who already trust each other, and this is self-hosted, so this is not a "drop everything" severity. But:
Suggested fix
Redact on the way out rather than adding a second endpoint.
to_campaign_responsealready receives the viewer; give it the viewer's role and blank the GM-only fields for everyone else:discord_webhook_url→Nonefor non-GManalytics_share_token→Nonefor non-GM (the presence of a share link may be worth surfacing as a plain boolean, since a player arguably deserves to know one exists)guild_id,notification_channel_id→ GM-only too, on the principle that nothing member-facing reads them; keepdiscord_delivery_statusas it isThen check the frontend does not rely on reading them as a non-GM (it should not — the form is GM-only), and add a test asserting a player's
GET /campaigns/{id}has those fields null.Worth also auditing the other response schemas for the same shape, since this one was not deliberate — it is what happens when one response model serves both the "show me the campaign" and "fill in the settings form" jobs. That conflation is the actual root cause, and splitting the two is the more thorough fix if the redaction turns out to be fiddly.
Where it lives in the redesign
No new surface.
CAMP-14/Settings stays GM-only, which is already how it is drawn in lane 1 (#659). If the "a share link exists" boolean is added, it belongs onCAMP-7(Analytics) alongside the existing GM-only share panel.Found by reading the code; not exploited or tested against any running instance.
Fix opened as #672 (branch
fix/671-redact-campaign-credentials), not merged — left open for review.to_campaign_responsenow resolves the viewer'''s role and blanksdiscord_webhook_url,analytics_share_token,guild_idandnotification_channel_idfor anyone who is not a GM of that campaign. Redacted in the builder rather than at the route because it is the only place aCampaignResponseis constructed, so all eight call sites — and the next one anybody adds — are covered by construction.Decisions taken along the way, each argued in the PR:
analytics_share_link_exists. Two lines, no frontend or schema churn, and it follows the pattern already here (llm_api_key_set,bot_token_configured). It is what makes the redaction lossless in what a player is entitled to.discord_delivery_statusuntouched, as the issue asks — but worth recording: it names the channel it could not deliver to, so thenotification_channel_idredaction is partial by design. The new test says so out loud rather than leaving it to be rediscovered.can_manage_membership'''s stated line that the owner-who-is-a-player reads nothing GM-only.Frontend checked rather than assumed — all four reads are already behind
isGm(edit form,FirstRunChecklist,RecordingPanel, the analytics share panel). No frontend change.The audit you asked for is in the PR body. One finding, low severity:
NotificationSettingsResponse.discord_webhook_url(routers/users.py:106) returns the instance fallback webhook in full while the SMTP password beside it is reduced toconfigured: bool— same shape, butrequire_admin, so a note rather than a bug. Nothing fixed there.CI run 9773 green,
3587 passed, 34 skipped.Fixed in #672, merged to
mainaseb7f286. CI run 9773 green — all 8 jobs,3587 passed, 34 skipped.to_campaign_responsenow resolves the viewer's role and blanksdiscord_webhook_url,analytics_share_token,guild_idandnotification_channel_idfor anyone who is not a GM of that campaign. Redacted in the builder rather than at the route, because it is the only place aCampaignResponseis constructed — so all eight call sites are covered, and so is the next one anyone adds.discord_delivery_statusis untouched, as this issue proposed; its comment explaining why a player is owed the delivery reason is intact.analytics_share_link_exists: boolwas added, following thellm_api_key_set/bot_token_configuredpattern already in the codebase.Two things deliberately not closed here:
discord_delivery_statusechoes the channel id in its documented shape (models/campaign.py:79-88), so redactingnotification_channel_idis partial. Called out in the test rather than left to be rediscovered. Closing it is a decision about that dict's shape, not about this fix.invite_service.can_manage_membership's stated line that the owner gets membership management and nothing that reads campaign content. A non-GM owner now gets redacted fields from the owner-gated routes; nothing on those pages reads them.The audit this issue asked for turned up one thing of the same shape, filed separately, plus one adjacent PII question. Neither was fixed here.
Closing.