Skip to content

fix(extension): region-aware URLs to prevent cross-region data leak#93

Draft
dhairyashiil wants to merge 7 commits into
mainfrom
devin/1779220944-extension-region-aware-urls
Draft

fix(extension): region-aware URLs to prevent cross-region data leak#93
dhairyashiil wants to merge 7 commits into
mainfrom
devin/1779220944-extension-region-aware-urls

Conversation

@dhairyashiil

Copy link
Copy Markdown
Member

Summary

The Chrome extension's content scripts and background worker emitted client-side Cal.com URLs (Gmail booking links, LinkedIn integration links, "Edit event type" deep links, the "open Cal.com" tab, and the API base) that were hardcoded to cal.com / app.cal.com regardless of the user's data region. An EU host (whose data lives on cal.eu) composing a Gmail email would attach a https://cal.com/<their-username>/<slot> link to attendees — best case the recipient sees a 404; worst case the same username exists on cal.com (independent identity realm — a different person) and the recipient books with the wrong person, exfiltrating the EU host's calendar metadata.

The server-side fix in calcom/cal#2808 does NOT cover this — these URLs are emitted client-side from extension code, never validated against a region-bound JWT. The CI grep check:no-cal-hostnames.sh scanned only apps/mobile, so the extension drifted without enforcement.

Changes

  1. New apps/extension/lib/region.ts — mirrors apps/mobile/utils/region.ts. Reads chrome.storage.local["cal_region"] (the same key the background worker writes), caches synchronously, and refreshes via chrome.storage.onChanged. Exports getCalAppUrl, getCalApiUrl, getCalWebUrl, getCalMarketingAppUrl, CAL_WEB_HOSTNAMES, plus preloadRegion/getRegion.
  2. 17 hardcoded callsites migrated to the helpers:
    • entrypoints/content.ts — 13 sites (Gmail menu/embed/slot-picker links, edit/create event-type deep-links, fallback booking handles)
    • lib/linkedin.ts — 2 sites (edit event-type deep-link, booking URL fallback)
    • lib/google-calendar.ts — 3 sites: regex broadened to match cal.{com|eu}/booking/<uid>; two text.includes("cal.com") || text.includes("Cal.com") host checks replaced with a lowercased CAL_WEB_HOSTNAMES.some(...) scan so cal.com, Cal.com, cal.eu, Cal.eu, app.cal.com, app.cal.eu all trigger no-show button injection
    • entrypoints/background/index.tsopenAppPage() routes through getCalMarketingAppUrl() (Framer marketing is intentionally cross-region, same precedent as mobile's getCalSupportUrl()); apiBaseUrlForRegion() deleted, both call sites now use ${getCalApiUrl(region)}/v2 for one source of truth
  3. CI grep extended to scan both apps/mobile and apps/extension. File-path-anchored allowlist (was content-anchored) so future code lines can't bypass by mentioning a hostname. Pattern broadened to also flag app.cal.{com|eu}. companion.cal.com (OAuth/iframe origin — different surface) stays content-allowlisted.
  4. .husky/pre-commit — fixed a pre-existing broken hook (cd companion referencing a non-existent subdir, leftover from when this code lived inside calcom/cal). Replaced with bunx lint-staged run from the repo root. This was blocking all commits in fresh clones.

Verified locally

  • bun run typecheck — all 4 workspaces (extension, mobile, mcp-server, chat) clean
  • bun run lint:all — biome + react-compiler eslint + the extended check:no-cal-hostnames.sh — exit 0
  • Planted-regression test on the CI script: adding https://cal.com/foo or https://app.cal.eu/foo to extension code makes the script exit 1; reverting brings it back to exit 0

Review & Testing Checklist for Human

  • Smoke-test in Chrome with both regions. Build the extension (cd apps/extension && bun dev), load unpacked, then in the service worker DevTools console run await chrome.storage.local.set({ cal_region: "eu" }). Verify Gmail slot-picker links point to cal.eu, LinkedIn "Edit event type" points to app.cal.eu, and Google Calendar events whose description contains https://cal.eu/booking/abc123 still get the no-show button. Switch back to us and confirm cal.com URLs return without an extension reload (the chrome.storage.onChanged listener should refresh the cache).
  • First-render sync caveat. The very first call to getCalWebUrl() after extension load can return the US default if it lands before the fire-and-forget preloadRegion() resolves. In practice content scripts only call these helpers in user-event handlers, but worth eyeballing on a slow-starting Gmail tab.
  • Background getApiBaseUrl() still refreshes per call — kept await getStoredRegion() in the path rather than reading the cached region from lib/region.ts, since the service worker can be suspended/woken and we want a fresh storage read per request. Confirm by toggling region and immediately triggering a background API call.
  • apiBaseUrlForRegion removal. Two call sites updated (getApiBaseUrl and validateTokens); double-check no other callers I missed.
  • CI grep allowlist. Confirm the allowlisted files (wxt.config.ts, public/manifest.json, apps/extension/.env.example) are genuinely intentional — they declare manifest host_permissions / privacy URL that must be static at build time.

Notes

  • The plan deliberately skipped adding vitest to apps/extension — the parallel mobile utils/region.ts module has no unit tests either, so adding test infra only on the extension side would be inconsistent. Validation is via the smoke test + the CI grep regression test above.
  • Independent of calcom/cal#2808 (which fixes the server-side JWT keyring). Complementary, not overlapping.
  • Independent of Becky's <all_urls> content-script scope reduction (separate bug, separate PR).
  • The .husky/pre-commit fix is bundled here because it was blocking my own commits. Happy to split it into a separate PR if preferred.

Link to Devin session: https://app.devin.ai/sessions/ac67e96833434118859e913e2ed01489
Requested by: @dhairyashiil

The hook ran 'cd companion' from a non-existent subdirectory — a leftover
from when this code lived inside calcom/cal as a 'companion/' subdir.
Replace with 'bunx lint-staged' run from the repo root so commits aren't
blocked in fresh clones.
Replace 13 hardcoded https://cal.com / https://app.cal.com URLs in
content scripts (Gmail integration, embed slot picker, fallback handles)
with the getCalAppUrl() / getCalWebUrl() helpers from lib/region. EU
users will now get cal.eu / app.cal.eu URLs in composed emails, copied
booking links, and 'Edit event type' deep links.
LinkedIn's 'Edit event type' and the booking-URL fallback both pointed
at https://app.cal.com / https://cal.com. Route them through the new
getCalAppUrl() / getCalWebUrl() helpers so EU hosts get app.cal.eu /
cal.eu links.
extractBookingUid()'s regex matched only https://(app.)?cal.com/booking/{uid},
so descriptions referencing cal.eu booking URLs never triggered the
no-show button injection. Broaden the regex to (com|eu) and switch the
two text.includes() host checks to a lowercased CAL_WEB_HOSTNAMES scan
so cal.com, Cal.com, cal.eu, Cal.eu, app.cal.com, app.cal.eu all match.
openAppPage() now routes through getCalMarketingAppUrl() so the policy
(Framer marketing is intentionally cross-region) is explicit at the
helper level rather than inline.

apiBaseUrlForRegion() is deleted; both call sites (getApiBaseUrl and
validateTokens) now build the URL via getCalApiUrl(region) + '/v2', so
there is a single source of truth for Cal.com origins shared with the
content-script callsites.
The CI grep previously scanned only apps/mobile, so the extension drifted
without enforcement. After this change:
- cd walks up to repo root (was: apps/mobile only)
- rg scans both apps/mobile AND apps/extension
- PATTERN broadens to \b(app\.)?cal\.(com|eu)\b so app.cal.eu also fails
- FILE_ALLOWLIST is path-anchored (^path:) so future code lines can't
  bypass the check by mentioning a hostname; adds extension paths that
  must stay literal (lib/region.ts, wxt.config.ts, public/manifest.json)
- CONTENT_ALLOWLIST exempts the companion.cal.com OAuth/iframe origin,
  which is not a Cal app URL

Verified manually: planting https://cal.com/foo or https://app.cal.eu/foo
in extension code makes the script exit 1; reverting brings it back to
exit 0.
@devin-ai-integration

Copy link
Copy Markdown
Contributor

🤖 Devin AI Engineer

I'll be helping with this pull request! Here's what you should know:

✅ I will automatically:

  • Address comments on this PR that start with 'DevinAI' or '@devin'.
  • Look at CI failures and help fix them

Note: I can only respond to comments from users who have write access to this repository.

⚙️ Control Options:

  • Disable automatic comment and CI monitoring

@vercel

vercel Bot commented May 19, 2026

Copy link
Copy Markdown

Deployment failed with the following error:

You don't have permission to create a Preview Deployment for this Vercel project: cal-companion-mcp.

View Documentation: https://vercel.com/docs/accounts/team-members-and-roles

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ Devin Review: No Issues Found

Devin Review analyzed this PR and found no potential bugs to report.

View in Devin Review to see 5 additional findings.

Open in Devin Review

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