Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
20 changes: 20 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,26 @@ cd test && ./serve.sh

Open `http://localhost:8000/test/index.html` to test all embed types.

## Custom Domains

Add `data-custom-domain` to the Surface tag when an environment uses a verified
custom domain:

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

The tag sends lead identification, journey tracking, external-form events, and
open-trigger requests to `https://demo.example.com/api/v1`. It also trusts form
iframe messages from `https://demo.example.com`. The value must be an HTTPS
hostname or origin without a path, query string, credentials, or fragment. If
the attribute is absent or invalid, the tag continues to use
`https://forms.withsurface.com`.

## Embedding Types

- **Popup** -- modal overlay triggered by button click
Expand Down
2 changes: 1 addition & 1 deletion src/conversions/conversion-listener.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ const isConversionMessage = (data: any): data is ConversionMessage =>
// Handles a `surface:conversion` message from a Surface form iframe: fires the
// pixel in this (parent) page, then acks so the iframe knows not to fall back to
// in-frame firing. The caller guarantees the origin is already trusted (checked
// in the shared message listener against SURFACE_DOMAINS).
// in the shared message listener against the runtime Surface-domain allowlist).
export const handleConversionMessage = (event: MessageEvent, log: Logger): void => {
const data = event.data;
if (!isConversionMessage(data)) return;
Expand Down
4 changes: 2 additions & 2 deletions src/external-form/external-form.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import { EXTERNAL_FORM_API } from "../constants";
import { isDebugMode } from "../utils/debug";
import { sendBeacon } from "../utils/beacon";
import { getSiteIdFromScript } from "../lead/site-id";
import { attachFormHandlers } from "./form-handlers";
import type { ExternalFormProps } from "../types";
import { getSurfaceRuntimeConfig } from "../runtime-config";

export class SurfaceExternalForm {
initialRenderTime: Date;
Expand All @@ -27,7 +27,7 @@ export class SurfaceExternalForm {
this.formStarted = {};

this.config = {
serverBaseUrl: props?.serverBaseUrl || EXTERNAL_FORM_API,
serverBaseUrl: props?.serverBaseUrl || getSurfaceRuntimeConfig().apiBaseUrl,
debugMode: isDebugMode(),
};

Expand Down
6 changes: 4 additions & 2 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,15 @@ import { SurfaceExternalForm } from "./external-form/external-form";
import { SurfaceEmbed } from "./embed/embed";
import { resolveOpenTriggersOnLoad } from "./open-triggers/open-triggers";
import { initReview } from "./review/review";
import { initializeSurfaceRuntimeConfig } from "./runtime-config";

const scriptTag = document.currentScript as HTMLScriptElement;
const runtimeConfig = initializeSurfaceRuntimeConfig(scriptTag);
const environmentId = getSiteIdFromScript(scriptTag);
setEnvironmentId(environmentId);

// Create singleton store
const SurfaceTagStore = new SurfaceStore(environmentId);
const SurfaceTagStore = new SurfaceStore(environmentId, runtimeConfig);

// Expose public API on window (backwards compatible)
const w = window as unknown as Record<string, unknown>;
Expand All @@ -30,7 +32,7 @@ w.SurfaceGetSiteIdFromScript = getSiteIdFromScript;

// Auto-open a form when the host URL carries a configured `?<slug>=true` param.
// Fire-and-forget; only touches the network when params are present.
void resolveOpenTriggersOnLoad(environmentId);
void resolveOpenTriggersOnLoad(environmentId, runtimeConfig);

// Surface CMS review bridge. Inert unless the page is loaded inside the CMS
// review iframe (?surface_review= token) — adds no listeners otherwise.
Expand Down
11 changes: 8 additions & 3 deletions src/lead/identify.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
import { LEAD_DATA_TTL, LEAD_IDENTIFY_API } from "../constants";
import { LEAD_DATA_TTL } from "../constants";
import { getBrowserFingerprint } from "./fingerprint";
import type { LeadData } from "../types";
import {
getSurfaceRuntimeConfig,
type SurfaceRuntimeConfig,
} from "../runtime-config";

let environmentId: string | null = null;
let identifyInProgress = false;
Expand Down Expand Up @@ -49,7 +53,8 @@ export function getLeadDataWithTTL(): LeadData | null {
}

export async function identifyLead(
envId: string
envId: string,
config: SurfaceRuntimeConfig = getSurfaceRuntimeConfig()
): Promise<LeadData | null> {
if (identifyInProgress) {
return waitForCachedData();
Expand All @@ -66,7 +71,7 @@ export async function identifyLead(
const fingerprint = await getBrowserFingerprint(envId);
const parentUrl = new URL(window.location.href);

const response = await fetch(LEAD_IDENTIFY_API, {
const response = await fetch(config.leadIdentifyApi, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
Expand Down
21 changes: 15 additions & 6 deletions src/open-triggers/open-triggers.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
import { EXTERNAL_FORM_API } from "../constants";
import { SurfaceEmbed } from "../embed/embed";
import { openTriggerOverlay } from "./open-trigger-overlay";
import { OpenTriggerEntry, OpenTriggersMap, pickOpenTrigger } from "./resolve";
import {
getSurfaceRuntimeConfig,
type SurfaceRuntimeConfig,
} from "../runtime-config";

const SESSION_PREFIX = "surface_open_triggers:";
// Self-healing cache: re-fetch the map after this long so a slug retargeted/disabled
Expand Down Expand Up @@ -30,12 +33,15 @@ interface OverridableWindow {
* present as `?<slug>=true`. Opens a form even if it isn't already embedded on the
* page. No params → zero network. Always fails safe (never breaks the host page).
*/
export async function resolveOpenTriggersOnLoad(environmentId: string | null): Promise<void> {
export async function resolveOpenTriggersOnLoad(
environmentId: string | null,
config: SurfaceRuntimeConfig = getSurfaceRuntimeConfig()
): Promise<void> {
try {
if (!environmentId) return;
if (!window.location.search) return;

const map = await fetchOpenTriggersMap(environmentId);
const map = await fetchOpenTriggersMap(environmentId, config);
const entry = pickOpenTrigger(window.location.search, map);
if (!entry) return;

Expand All @@ -45,13 +51,16 @@ export async function resolveOpenTriggersOnLoad(environmentId: string | null): P
}
}

async function fetchOpenTriggersMap(environmentId: string): Promise<OpenTriggersMap | null> {
async function fetchOpenTriggersMap(
environmentId: string,
config: SurfaceRuntimeConfig
): Promise<OpenTriggersMap | null> {
const w = window as unknown as OverridableWindow;

// Test/escape hatch: a directly-injected map bypasses the network entirely.
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.

try {
const cached = sessionStorage.getItem(sessionKey);
if (cached) {
Expand All @@ -64,7 +73,7 @@ async function fetchOpenTriggersMap(environmentId: string): Promise<OpenTriggers
// sessionStorage unavailable / malformed (e.g. privacy mode) — fall through to a live fetch.
}

const base = w.__SURFACE_OPEN_TRIGGERS_BASE || EXTERNAL_FORM_API;
const base = w.__SURFACE_OPEN_TRIGGERS_BASE || config.apiBaseUrl;
const response = await fetch(`${base}/environments/${encodeURIComponent(environmentId)}/open-triggers`);
if (!response.ok) return null;

Expand Down
78 changes: 78 additions & 0 deletions src/runtime-config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import {
EXTERNAL_FORM_API,
LEAD_IDENTIFY_API,
SURFACE_DOMAINS,
USER_JOURNEY_TRACKING_API,
} from "./constants";

export const CUSTOM_DOMAIN_ATTRIBUTE = "data-custom-domain";

export interface SurfaceRuntimeConfig {
apiBaseUrl: string;
leadIdentifyApi: string;
userJourneyTrackingApi: string;
surfaceDomains: readonly string[];
customOrigin: string | null;
}

export const DEFAULT_SURFACE_RUNTIME_CONFIG: SurfaceRuntimeConfig = {
apiBaseUrl: EXTERNAL_FORM_API,
leadIdentifyApi: LEAD_IDENTIFY_API,
userJourneyTrackingApi: USER_JOURNEY_TRACKING_API,
surfaceDomains: SURFACE_DOMAINS,
customOrigin: null,
};

let runtimeConfig = DEFAULT_SURFACE_RUNTIME_CONFIG;

function normalizeCustomOrigin(value: string): string | null {
const trimmed = value.trim();
if (!trimmed) return null;

try {
const url = new URL(trimmed.includes("://") ? trimmed : `https://${trimmed}`);
if (
url.protocol !== "https:" ||
url.username ||
url.password ||
url.pathname !== "/" ||
url.search ||
url.hash
) {
return null;
}

return url.origin;
} catch {
return null;
}
}

export function resolveSurfaceRuntimeConfig(
scriptElement: HTMLScriptElement | null
): SurfaceRuntimeConfig {
const customOrigin = normalizeCustomOrigin(
scriptElement?.getAttribute(CUSTOM_DOMAIN_ATTRIBUTE) ?? ""
);
if (!customOrigin) return DEFAULT_SURFACE_RUNTIME_CONFIG;

const apiBaseUrl = `${customOrigin}/api/v1`;
return {
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.

customOrigin,
};
}

export function initializeSurfaceRuntimeConfig(
scriptElement: HTMLScriptElement | null
): SurfaceRuntimeConfig {
runtimeConfig = resolveSurfaceRuntimeConfig(scriptElement);
return runtimeConfig;
}

export function getSurfaceRuntimeConfig(): SurfaceRuntimeConfig {
return runtimeConfig;
}
10 changes: 7 additions & 3 deletions src/store/message-listener.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
import { SURFACE_DOMAINS } from "../constants";
import { handleConversionMessage } from "../conversions/conversion-listener";
import { SURFACE_DOMAINS } from "../constants";
import { identifyLead, getEnvironmentId } from "../lead/identify";
import type { SurfaceStore } from "./store";

export function initializeMessageListener(store: SurfaceStore): void {
const handleMessage = (event: MessageEvent) => {
if (!event.origin || !(SURFACE_DOMAINS as readonly string[]).includes(event.origin)) {
const surfaceDomains = store.surfaceDomains ?? SURFACE_DOMAINS;
if (!event.origin || !surfaceDomains.includes(event.origin)) {
return;
}

Expand All @@ -19,7 +20,10 @@ export function initializeMessageListener(store: SurfaceStore): void {

const envId = getEnvironmentId();
if (envId) {
identifyLead(envId)
const identify = store.config?.customOrigin
? identifyLead(envId, store.config)
: identifyLead(envId);
identify
.then(() => store.sendPayloadToIframes("LEAD_DATA_UPDATE"))
.catch((e) => console.log("Failed identify", e));
} else {
Expand Down
Loading
Loading