Skip to content

Commit 24e9f45

Browse files
committed
revert(landing): drop landing-scoped CSP, put HubSpot in the shared policy
Cursor caught a fundamental problem with the landing-scoped CSP: the Content-Security-Policy header is fixed to the document's initial HTTP response and is NOT re-applied on Next.js client-side (soft) navigation. Both the landing navbar's ChipLink to /login and AuthShell's Link back to / are soft navigations (confirmed directly in the source, not assumed). That means: - /login -> / (soft nav): the browser keeps /login's CSP, which never allowed HubSpot hosts, so the loader gets silently blocked on landing. - / -> /login (soft nav): the browser keeps the landing CSP, which is MORE permissive than /login's, undoing the tightening entirely. A per-route CSP is fundamentally incompatible with this app's client-side-routed navigation. Greptile's original 'CSP too broad on /workspace' concern was valid in isolation, but the fix built across the last several rounds doesn't actually work — it's neither reliably tighter nor reliably functional, and each round's patch (exclusion list entries, /landing-preview handling) was really just papering over that core issue. Revert to a single shared CSP for the whole app, matching exactly how GTM/GA/ahrefs are already handled in this same file: HubSpot hosts land in STATIC_SCRIPT_SRC/STATIC_CONNECT_SRC under the existing isHosted gate. /workspace's CSP header technically allows origins it never requests (same accepted tradeoff as GTM/GA), but the tracking script only ever renders in the (landing) layout — matching the CSP scope Greptile originally objected to. This is now provably correct because it can't desync: proxy.ts, csp.ts, and proxy.test.ts are byte-for-byte identical to origin/staging except for the HubSpot host list itself.
1 parent cb02ba7 commit 24e9f45

3 files changed

Lines changed: 16 additions & 101 deletions

File tree

apps/sim/lib/core/security/csp.ts

Lines changed: 13 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,12 @@ const STATIC_SCRIPT_SRC = [
6060
'https://www.googletagmanager.com',
6161
'https://www.google-analytics.com',
6262
'https://analytics.ahrefs.com',
63+
// HubSpot tracking (landing pages) — loader plus the
64+
// analytics/form-tracking/banner scripts it injects as <script> tags
65+
'https://*.hs-scripts.com',
66+
'https://*.hs-analytics.net',
67+
'https://*.hscollectedforms.net',
68+
'https://*.hs-banner.com',
6369
]
6470
: []),
6571
] as const
@@ -96,30 +102,14 @@ const STATIC_CONNECT_SRC = [
96102
'https://www.google.com',
97103
'https://analytics.ahrefs.com',
98104
'https://*.g.doubleclick.net',
99-
]
100-
: []),
101-
] as const
102-
103-
/**
104-
* HubSpot tracking sources. The loader only renders inside the (landing)
105-
* route group, so these are kept out of STATIC_SCRIPT_SRC/STATIC_CONNECT_SRC
106-
* and merged in only by the landing-scoped CSP builders below.
107-
*/
108-
const HUBSPOT_SCRIPT_SRC = [
109-
...(isHosted
110-
? [
111-
'https://*.hs-scripts.com',
112-
'https://*.hs-analytics.net',
105+
// HubSpot tracking — form-tracking API (hscollectedforms.js) and
106+
// the visitor beacon (track.hubspot.com)
113107
'https://*.hscollectedforms.net',
114-
'https://*.hs-banner.com',
108+
'https://*.hubspot.com',
115109
]
116110
: []),
117111
] as const
118112

119-
const HUBSPOT_CONNECT_SRC = [
120-
...(isHosted ? ['https://*.hscollectedforms.net', 'https://*.hubspot.com'] : []),
121-
] as const
122-
123113
const STATIC_FRAME_SRC = [
124114
"'self'",
125115
'blob:',
@@ -203,12 +193,12 @@ export function buildCSPString(directives: CSPDirectives): string {
203193
}
204194

205195
/**
206-
* Build runtime CSP directives with dynamic environment variables.
196+
* Generate runtime CSP header with dynamic environment variables.
207197
* Composes from the same STATIC_* constants as buildTimeCSPDirectives,
208198
* but resolves env vars at request time via getEnv() to fix Docker
209199
* deployments where build-time values may be stale placeholders.
210200
*/
211-
function buildRuntimeCSPDirectives(): CSPDirectives {
201+
export function generateRuntimeCSP(): string {
212202
const appUrl = getEnv('NEXT_PUBLIC_APP_URL') || ''
213203

214204
const socketUrl = getEnv('NEXT_PUBLIC_SOCKET_URL') || (isDev ? DEFAULT_SOCKET_URL : '')
@@ -219,7 +209,7 @@ function buildRuntimeCSPDirectives(): CSPDirectives {
219209
const privacyDomains = getHostnameFromUrl(getEnv('NEXT_PUBLIC_PRIVACY_URL'))
220210
const termsDomains = getHostnameFromUrl(getEnv('NEXT_PUBLIC_TERMS_URL'))
221211

222-
return {
212+
const runtimeDirectives: CSPDirectives = {
223213
...buildTimeCSPDirectives,
224214

225215
'img-src': [...STATIC_IMG_SRC],
@@ -235,27 +225,8 @@ function buildRuntimeCSPDirectives(): CSPDirectives {
235225
...termsDomains,
236226
],
237227
}
238-
}
239-
240-
/**
241-
* Generate runtime CSP header for non-landing routes (proxy.ts).
242-
*/
243-
export function generateRuntimeCSP(): string {
244-
return buildCSPString(buildRuntimeCSPDirectives())
245-
}
246228

247-
/**
248-
* Generate runtime CSP header for the public marketing/landing site.
249-
* Extends the shared runtime policy with HubSpot tracking hosts.
250-
*/
251-
export function generateLandingRuntimeCSP(): string {
252-
const directives = buildRuntimeCSPDirectives()
253-
254-
return buildCSPString({
255-
...directives,
256-
'script-src': [...(directives['script-src'] ?? []), ...HUBSPOT_SCRIPT_SRC],
257-
'connect-src': [...(directives['connect-src'] ?? []), ...HUBSPOT_CONNECT_SRC],
258-
})
229+
return buildCSPString(runtimeDirectives)
259230
}
260231

261232
/**

apps/sim/proxy.test.ts

Lines changed: 1 addition & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ vi.mock('@/lib/core/config/env', () =>
99
createEnvMock({ NEXT_PUBLIC_APP_URL: 'https://app.sim.test' })
1010
)
1111

12-
import { isNonLandingPath, resolveApiCorsPolicy } from '@/proxy'
12+
import { resolveApiCorsPolicy } from '@/proxy'
1313

1414
function makeRequest(pathname: string, origin?: string): NextRequest {
1515
return {
@@ -122,38 +122,3 @@ describe('resolveApiCorsPolicy', () => {
122122
}
123123
})
124124
})
125-
126-
describe('isNonLandingPath', () => {
127-
it('matches known non-landing top-level pages and their subpaths', () => {
128-
const paths = [
129-
'/verify',
130-
'/sso',
131-
'/reset-password',
132-
'/resume',
133-
'/resume/workflow-123',
134-
'/f',
135-
'/f/token-abc',
136-
'/invite',
137-
'/invite/invite-id',
138-
'/playground',
139-
'/unsubscribe',
140-
]
141-
for (const path of paths) {
142-
expect(isNonLandingPath(path)).toBe(true)
143-
}
144-
})
145-
146-
it('does not match landing pages', () => {
147-
const paths = ['/', '/pricing', '/blog/some-post', '/integrations', '/terms', '/privacy']
148-
for (const path of paths) {
149-
expect(isNonLandingPath(path)).toBe(false)
150-
}
151-
})
152-
153-
it('does not false-positive on landing pages that share a prefix with a non-landing page', () => {
154-
// '/ffoo' and '/finance' start with 'f' but are not the '/f/:token' route
155-
expect(isNonLandingPath('/ffoo')).toBe(false)
156-
expect(isNonLandingPath('/finance')).toBe(false)
157-
expect(isNonLandingPath('/resumes')).toBe(false)
158-
})
159-
})

apps/sim/proxy.ts

Lines changed: 2 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { type NextRequest, NextResponse } from 'next/server'
44
import { sendToProfound } from './lib/analytics/profound'
55
import { getEnv } from './lib/core/config/env'
66
import { isAuthDisabled, isHosted } from './lib/core/config/env-flags'
7-
import { generateLandingRuntimeCSP, generateRuntimeCSP } from './lib/core/security/csp'
7+
import { generateRuntimeCSP } from './lib/core/security/csp'
88
import { getClientIp } from './lib/core/utils/request'
99

1010
const logger = createLogger('Proxy')
@@ -117,24 +117,6 @@ function buildPreflightResponse(policy: CorsPolicy): NextResponse {
117117
return response
118118
}
119119

120-
/** Top-level pages outside (landing) that still reach the catch-all branch below. */
121-
const NON_LANDING_PATH_PREFIXES = [
122-
'/verify',
123-
'/sso',
124-
'/reset-password',
125-
'/resume',
126-
'/f',
127-
'/invite',
128-
'/playground',
129-
'/unsubscribe',
130-
]
131-
132-
export function isNonLandingPath(pathname: string): boolean {
133-
return NON_LANDING_PATH_PREFIXES.some(
134-
(prefix) => pathname === prefix || pathname.startsWith(`${prefix}/`)
135-
)
136-
}
137-
138120
const SUSPICIOUS_UA_PATTERNS = [
139121
/^\s*$/, // Empty user agents
140122
/\.\./, // Path traversal attempt
@@ -302,13 +284,10 @@ export async function proxy(request: NextRequest) {
302284
const securityBlock = handleSecurityFiltering(request)
303285
if (securityBlock) return track(request, securityBlock)
304286

305-
// Mostly the public marketing/landing site, plus a few non-landing pages
306-
// (see isNonLandingPath) that also fall through to this branch.
307287
const response = NextResponse.next()
308288
response.headers.set('Vary', 'User-Agent')
309289

310-
const csp = isNonLandingPath(url.pathname) ? generateRuntimeCSP() : generateLandingRuntimeCSP()
311-
response.headers.set('Content-Security-Policy', csp)
290+
response.headers.set('Content-Security-Policy', generateRuntimeCSP())
312291
response.headers.set('X-Content-Type-Options', 'nosniff')
313292
response.headers.set('X-Frame-Options', 'SAMEORIGIN')
314293

0 commit comments

Comments
 (0)