Skip to content

[SURF-1836] feat(tag): support custom-domain script attribute - #72

Merged
0xgautam merged 2 commits into
mainfrom
agent/custom-domain-script-attribute
Aug 20, 2026
Merged

[SURF-1836] feat(tag): support custom-domain script attribute#72
0xgautam merged 2 commits into
mainfrom
agent/custom-domain-script-attribute

Conversation

@0xgautam

@0xgautam 0xgautam commented Aug 19, 2026

Copy link
Copy Markdown
Collaborator

Problem

The Surface tag hard-codes forms.withsurface.com for lead identification, journey tracking,
external-form events, and open-trigger lookups. Environments whose forms are served from a
verified custom domain therefore still send tag traffic to the default Surface hostname, and
custom-domain iframes are not part of the tag's postMessage allowlist.

Changes

  • Adds a documented data-custom-domain="demo.example.com" script attribute.
  • Normalizes hostname or HTTPS-origin values into a shared runtime configuration and safely
    falls back to the current defaults for absent or invalid values.
  • Routes lead identification, journey tracking, external-form traffic, and open-trigger lookups
    through https://<custom-domain>/api/v1.
  • Adds the exact custom origin to inbound and outbound iframe messaging while preserving the
    existing Surface origins.
  • Replaces substring iframe checks with exact URL-origin matching.
  • Preserves the explicit SurfaceExternalForm({ serverBaseUrl }) override.
  • Keys the open-trigger session cache by API base as well as environment.
  • Rebuilds surface_tag.js and surface_embed_v1.js.

Client usage

<script
  src="https://cdn.jsdelivr.net/.../surface_tag.min.js"
  site-id="your-environment-id"
  data-custom-domain="demo.example.com">
</script>

Without the attribute, existing integrations continue to use https://forms.withsurface.com.

Validation

  • pnpm typecheck
  • pnpm test — 12/12 existing tests
  • pnpm test:unit — 5/5 existing unit tests
  • pnpm build
  • Generated bundle copies verified identical
  • git diff --check

@0xgautam 0xgautam changed the title feat(tag): support custom-domain script attribute [SURF-1836] feat(tag): support custom-domain script attribute Aug 19, 2026
@notion-workspace

Copy link
Copy Markdown

@0xgautam
0xgautam marked this pull request as ready for review August 19, 2026 07:17
@greptile-apps

greptile-apps Bot commented Aug 19, 2026

Copy link
Copy Markdown

Greptile Summary

The PR adds a normalized custom-domain runtime configuration and routes tag APIs and iframe messaging through it while retaining default Surface endpoints.

  • Adds and documents the data-custom-domain script attribute.
  • Propagates the resolved configuration through lead identification, journey tracking, external forms, open triggers, and iframe messaging.
  • Uses exact iframe origins and scopes open-trigger caches by API base.
  • Rebuilds both distributed JavaScript bundles.

Confidence Score: 5/5

The PR appears safe to merge.

No blocking failure remains.

Reviews (2): Last reviewed commit: "chore: remove generated tests" | Re-trigger Greptile

@arjunsahai

Copy link
Copy Markdown
Collaborator

Can you remove the AI generated tests.

I want to have a thoughtful approach to testing and this is a great place to develop good practices given the small size of this repo

@0xgautam

Copy link
Copy Markdown
Collaborator Author

Addressed in c9c08b4: removed every test change introduced by this PR. The unchanged existing suites pass (12/12 Vitest and 5/5 unit tests), along with typecheck and build.

Comment thread src/runtime-config.ts
apiBaseUrl,
leadIdentifyApi: `${apiBaseUrl}/lead/identify`,
userJourneyTrackingApi: `${apiBaseUrl}/lead/track`,
surfaceDomains: Array.from(new Set([...SURFACE_DOMAINS, customOrigin])),

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

@greptile Any HTTPS data-custom-domain gets added to the postMessage allowlist here and has lead PII routed to it is the domain verified against the env's real custom domain somewhere, or do we just trust whatever the page passes?

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

There's no server-side or environment-level verification — it's purely trusted from the page. normalizeCustomOrigin only validates that the attribute value is a well-formed HTTPS origin (HTTPS scheme, no credentials, no path/query/fragment). It does not cross-check the value against the site-id's actual configured custom domain.

This is a real attack surface to consider:

  1. Exfiltration via a compromised/malicious page — Any page that embeds the Surface tag can redirect lead PII (/lead/identify, /lead/track) to an arbitrary HTTPS server by setting data-custom-domain="attacker.example.com". The tag will happily POST fingerprint + lead data there.

  2. postMessage spoofing — The attacker-controlled origin is added to surfaceDomains and trusted for inbound postMessage. A malicious iframe from that origin could trigger STORE_UPDATE flows, which then call identifyLead against the same attacker origin.

The practical risk depends on your threat model. If only your own team adds the <script> tag to customer pages (i.e., the page author is already trusted), the blast radius is limited to self-harm. But if customers self-serve by copying a snippet and the data-custom-domain value could be tampered (e.g., via a CMS injection, script injection into their page, or a misconfigured CDN), then an attacker can redirect PII collection to a domain they control.

A few mitigations to consider:

  • Have the backend /api/v1/lead/identify endpoint verify that the Origin or Referer header matches a known allowed domain for that site-id, so even if traffic is routed to Surface's infra the data is rejected for invalid environments.
  • Alternatively, return the allowed custom domain(s) for a given site-id from a Surface-controlled endpoint at init time, then validate the data-custom-domain attribute against that list before trusting it.
  • At minimum, document clearly that data-custom-domain must only be set to a domain verified and controlled by the Surface customer.

Comment thread surface_tag.js

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

@greptile is there a CI check that these built bundles match a fresh build from src/?

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Yes — the CI workflow has an explicit step for this:

- name: Bundle is up to date
  run: |
    pnpm run build
    git diff --exit-code surface_tag.js surface_embed_v1.js

It rebuilds from source and fails the job if surface_tag.js or surface_embed_v1.js differ from what's committed. So stale bundles will block the PR from merging.

Comment thread src/store/store.ts
const hostname = window.location?.hostname ?? "";
return SURFACE_DOMAINS.some((url) => new URL(url).hostname === hostname);
const origin = window.location?.origin ?? "";
return this.surfaceDomains.includes(origin);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

this switched from hostname matching to window.location.origin matching, is that equivalent in all cases @greptile

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Not quite equivalent — there are two edge cases where the behavior differs.

SURFACE_DOMAINS contains full origins ("https://forms.withsurface.com"), so surfaceDomains does too. The old code stripped both sides down to just the hostname before comparing; the new code compares the full origin (scheme + host + port).

Where they diverge:

  1. HTTP — if window.location is http://forms.withsurface.com, old code matches (hostname matches), new code doesn't (http://https://). Surface domains are HTTPS-only in production so this is unlikely to matter.

  2. Non-standard port — if window.location is https://forms.withsurface.com:3000 (local dev server), window.location.origin is "https://forms.withsurface.com:3000", which won't match any entry in surfaceDomains. The old hostname comparison would still match. This is the more likely real-world gap.

The stricter check is correct for production — origin comparison is semantically right for security purposes. But it's worth confirming whether isCurrentOriginSurfaceDomain is ever exercised in a local-dev setup running on a non-standard port, since that scenario silently regresses.

if (w.__SURFACE_OPEN_TRIGGERS_MAP) return w.__SURFACE_OPEN_TRIGGERS_MAP;

const sessionKey = SESSION_PREFIX + environmentId;
const sessionKey = `${SESSION_PREFIX}${config.apiBaseUrl}:${environmentId}`;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

@greptile the session-cache key now includes the api base, so any key cached under the old format is orphaned after deploy, just a one-time refetch, or does anything rely on the old key??

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

One-time refetch only. Nothing in the codebase reads or writes to the old key format after this deploy — sessionStorage under the old key (surface_open_triggers:<environmentId>) just becomes dead weight that expires when the tab closes. The new key (surface_open_triggers:<apiBaseUrl>:<environmentId>) will miss on first load, trigger a fresh fetch, and get written correctly.

No correctness issue: the cache is purely a perf optimization (CACHE_TTL_MS = 5 min), and the comments in the code explicitly treat it as best-effort. The worst case is one extra network call per tab on the first page load after deploy.

Tip: You can customize Greptile's behavior for this repo with .greptile/rules.md and .greptile/config.json.

@0xgautam
0xgautam merged commit 1ce8e5e into main Aug 20, 2026
2 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.

4 participants