Skip to content
Open
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
15 changes: 15 additions & 0 deletions apps/web/src/app/api/marketing-tags/gtm/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { NextResponse } from 'next/server';
import { buildGoogleTagManagerScript } from '@/lib/marketing-tag-scripts';

export function GET() {
if (!process.env.NEXT_PUBLIC_GTM_ID) {
return new NextResponse(null, { status: 404 });
}

return new NextResponse(buildGoogleTagManagerScript(process.env.NEXT_PUBLIC_GTM_ID), {
headers: {
'Content-Type': 'application/javascript; charset=utf-8',
'Cache-Control': 'public, max-age=300, s-maxage=300',
},
});
}
15 changes: 15 additions & 0 deletions apps/web/src/app/api/marketing-tags/impact/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { NextResponse } from 'next/server';
import { buildImpactUttScript } from '@/lib/marketing-tag-scripts';

export function GET() {
if (!process.env.NEXT_PUBLIC_IMPACT_UTT_ID) {
return new NextResponse(null, { status: 404 });
}

return new NextResponse(buildImpactUttScript(process.env.NEXT_PUBLIC_IMPACT_UTT_ID), {
headers: {
'Content-Type': 'application/javascript; charset=utf-8',
'Cache-Control': 'public, max-age=300, s-maxage=300',
},
});
}
11 changes: 6 additions & 5 deletions apps/web/src/app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@ import './globals.css';
import { PostHogProvider } from '../components/PostHogProvider';
import { Providers } from '../components/Providers';
import { DataLayerProvider } from '../components/DataLayerProvider';
import { GoogleTagManager } from '@next/third-parties/google';
import { APP_URL } from '@/lib/constants';

const inter = Inter({
Expand Down Expand Up @@ -113,13 +112,15 @@ export default function RootLayout({
</Providers>

{process.env.NEXT_PUBLIC_GTM_ID && (
<GoogleTagManager gtmId={process.env.NEXT_PUBLIC_GTM_ID} />
<Script
id="google-tag-manager"
src="/api/marketing-tags/gtm"
strategy="afterInteractive"
/>
)}

{process.env.NEXT_PUBLIC_IMPACT_UTT_ID && (
<Script id="impact-utt" strategy="beforeInteractive">
{`(function(a,b,c,d,e,f,g){e.ire_o=c;e[c]=e[c]||function(){(e[c].a=e[c].a||[]).push(arguments)};f=d.createElement(b);g=d.getElementsByTagName(b)[0];f.async=1;f.src=a;g.parentNode.insertBefore(f,g);})('https://utt.impactcdn.com/${process.env.NEXT_PUBLIC_IMPACT_UTT_ID}.js','script','ire',document,window);`}
</Script>
<Script id="impact-utt" src="/api/marketing-tags/impact" strategy="beforeInteractive" />
)}
</body>
</html>
Expand Down
1 change: 1 addition & 0 deletions apps/web/src/components/DataLayerProvider.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
'use client';

import type {} from '@/types/datalayer';
import { useSession } from 'next-auth/react';
import { useEffect } from 'react';

Expand Down
20 changes: 20 additions & 0 deletions apps/web/src/lib/marketing-tag-scripts.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { buildGoogleTagManagerScript, buildImpactUttScript } from '@/lib/marketing-tag-scripts';

describe('marketing tag scripts', () => {
it('escapes GTM IDs embedded into bootstrap JavaScript', () => {
const script = buildGoogleTagManagerScript('GTM-TEST</script><script>alert(1)</script>');

expect(script).toContain('GTM-TEST\\u003C\\u002Fscript\\u003E');
expect(script).not.toContain('</script>');
expect(script).not.toContain('<script>');
});

it('escapes Impact IDs embedded into bootstrap JavaScript', () => {
const script = buildImpactUttScript('impact</script><script>alert(1)</script>');

expect(script).toContain('https:\\u002F\\u002Futt.impactcdn.com\\u002Fimpact');
expect(script).toContain('\\u003C\\u002Fscript\\u003E');
expect(script).not.toContain('</script>');
expect(script).not.toContain('<script>');
});
});
28 changes: 28 additions & 0 deletions apps/web/src/lib/marketing-tag-scripts.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
function toJavaScriptStringLiteral(value: string): string {
return JSON.stringify(value).replace(/[<>/\u2028\u2029]/g, char => {
switch (char) {
case '<':
return '\\u003C';
case '>':
return '\\u003E';
case '/':
return '\\u002F';
case '\u2028':
return '\\u2028';
case '\u2029':
return '\\u2029';
default:
return char;
}
});
}

export function buildGoogleTagManagerScript(gtmId: string): string {
const encodedGtmId = toJavaScriptStringLiteral(gtmId);
return `(function(w,d,s,l,i){w[l]=w[l]||[];w[l].push({'gtm.start':new Date().getTime(),event:'gtm.js'});var f=d.getElementsByTagName(s)[0],j=d.createElement(s),dl=l!='dataLayer'?'&l='+l:'';j.async=true;j.src='https://www.googletagmanager.com/gtm.js?id='+i+dl;f.parentNode.insertBefore(j,f);})(window,document,'script','dataLayer',${encodedGtmId});`;
}

export function buildImpactUttScript(impactUttId: string): string {
const encodedScriptUrl = toJavaScriptStringLiteral(`https://utt.impactcdn.com/${impactUttId}.js`);
return `(function(a,b,c,d,e,f,g){e.ire_o=c;e[c]=e[c]||function(){(e[c].a=e[c].a||[]).push(arguments)};f=d.createElement(b);g=d.getElementsByTagName(b)[0];f.async=1;f.src=a;g.parentNode.insertBefore(f,g);})(${encodedScriptUrl},'script','ire',document,window);`;
}
82 changes: 82 additions & 0 deletions apps/web/src/lib/security-headers.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import { buildContentSecurityPolicy, getConfiguredConnectSrcOrigins } from '@/lib/security-headers';

describe('security headers', () => {
it('builds CSP with required third-party sources', () => {
const policy = buildContentSecurityPolicy({
connectSrcUrls: ['wss://cloud-agent.example.com/socket'],
});

expect(policy).toContain("default-src 'self'");
expect(policy).toContain("script-src 'self' 'unsafe-inline'");
expect(policy).toContain('https://js.stripe.com');
expect(policy).toContain('https://*.js.stripe.com');
expect(policy).toContain('https://api.stripe.com');
expect(policy).toContain('https://hooks.stripe.com');
expect(policy).toContain('https://login.kilo.ai');
expect(policy).toContain('https://login-test.kilo.ai');
expect(policy).toContain('https://www.googletagmanager.com');
expect(policy).toContain('https://utt.impactcdn.com');
expect(policy).toContain('https://challenges.cloudflare.com');
expect(policy).toContain('https://cdn.jsdelivr.net');
expect(policy).toContain('https://unpkg.com');
expect(policy).toContain('https://*.d.kiloapps.io');
expect(policy).toContain('https://www.youtube.com');
expect(policy).toContain("'wasm-unsafe-eval'");
expect(policy).toContain('wss://cloud-agent.example.com');
});

it('adds development-only sources only in development mode', () => {
const productionPolicy = buildContentSecurityPolicy({ isDevelopment: false });
const developmentPolicy = buildContentSecurityPolicy({ isDevelopment: true });

expect(productionPolicy).not.toContain("'unsafe-eval'");
expect(productionPolicy).not.toContain('ws://localhost:*');
expect(developmentPolicy).toContain("'unsafe-eval'");
expect(developmentPolicy).toContain('ws://localhost:*');
});

it('derives configured connect-src origins from public URLs', () => {
const env: Record<string, string | undefined> = {
NEXT_PUBLIC_CLOUD_AGENT_WS_URL: 'wss://agent.example.com/path',
NEXT_PUBLIC_CLOUD_AGENT_NEXT_WS_URL: 'wss://next-agent.example.com/path',
NEXT_PUBLIC_SESSION_INGEST_WS_URL: 'wss://ingest.example.com/path',
NEXT_PUBLIC_GASTOWN_URL: 'https://gastown.example.com/api',
};

expect(getConfiguredConnectSrcOrigins(env)).toEqual([
'wss://agent.example.com',
'wss://next-agent.example.com',
'wss://ingest.example.com',
'https://gastown.example.com',
]);
});

it('allows extra CSP sources through directive-specific env vars', () => {
const policy = buildContentSecurityPolicy({
env: {
CSP_ADDITIONAL_SCRIPT_SRC: 'https://tag.example.com, https://cdn.example.com',
CSP_ADDITIONAL_CONNECT_SRC: 'https://api.example.com',
CSP_ADDITIONAL_IMG_SRC: 'https://images.example.com',
CSP_ADDITIONAL_FRAME_SRC: 'https://frames.example.com',
},
});

expect(policy).toContain('script-src');
expect(policy).toContain('https://tag.example.com');
expect(policy).toContain('https://cdn.example.com');
expect(policy).toContain('https://api.example.com');
expect(policy).toContain('https://images.example.com');
expect(policy).toContain('https://frames.example.com');
});

it('ignores additional CSP sources that would inject directives', () => {
const policy = buildContentSecurityPolicy({
env: {
CSP_ADDITIONAL_SCRIPT_SRC: 'https://good.example.com; object-src *',
},
});

expect(policy).not.toContain('https://good.example.com;');
expect(policy).not.toContain('object-src *');
});
});
153 changes: 153 additions & 0 deletions apps/web/src/lib/security-headers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
type CspDirective =
| 'script-src'
| 'connect-src'
| 'img-src'
| 'style-src'
| 'font-src'
| 'frame-src'
| 'worker-src'
| 'media-src';

export type ContentSecurityPolicyOptions = {
isDevelopment?: boolean;
connectSrcUrls?: Array<string | undefined>;
env?: Record<string, string | undefined>;
};

const ADDITIONAL_SOURCE_ENV_BY_DIRECTIVE = {
'script-src': 'CSP_ADDITIONAL_SCRIPT_SRC',
'connect-src': 'CSP_ADDITIONAL_CONNECT_SRC',
'img-src': 'CSP_ADDITIONAL_IMG_SRC',
'style-src': 'CSP_ADDITIONAL_STYLE_SRC',
'font-src': 'CSP_ADDITIONAL_FONT_SRC',
'frame-src': 'CSP_ADDITIONAL_FRAME_SRC',
'worker-src': 'CSP_ADDITIONAL_WORKER_SRC',
'media-src': 'CSP_ADDITIONAL_MEDIA_SRC',
} satisfies Record<CspDirective, string>;

function compactUnique(values: Array<string | null | undefined>): string[] {
const compacted = values.filter((value): value is string => Boolean(value && value.length > 0));
return Array.from(new Set(compacted));
}

function originFromUrl(value: string | undefined): string | null {
if (!value) return null;
try {
return new URL(value).origin;
} catch {
return null;
}
}

function parseAdditionalCspSources(value: string | undefined): string[] {
if (!value || value.includes(';')) return [];
return compactUnique(
value
.split(/[\s,]+/)
.map(source => source.trim())
.filter(source => source.length > 0)
);
}

function getAdditionalCspSources(
directive: CspDirective,
env: Record<string, string | undefined>
): string[] {
return parseAdditionalCspSources(env[ADDITIONAL_SOURCE_ENV_BY_DIRECTIVE[directive]]);
}

export function getConfiguredConnectSrcOrigins(
env: Record<string, string | undefined> = process.env
): string[] {
return compactUnique([
originFromUrl(env.NEXT_PUBLIC_CLOUD_AGENT_WS_URL),
originFromUrl(env.NEXT_PUBLIC_CLOUD_AGENT_NEXT_WS_URL),
originFromUrl(env.NEXT_PUBLIC_SESSION_INGEST_WS_URL),
originFromUrl(env.NEXT_PUBLIC_GASTOWN_URL),
]);
}

export function buildContentSecurityPolicy({
isDevelopment = false,
connectSrcUrls,
env = process.env,
}: ContentSecurityPolicyOptions = {}): string {
const configuredConnectSrcUrls = connectSrcUrls ?? getConfiguredConnectSrcOrigins(env);
const scriptSrc = compactUnique([
"'self'",
"'unsafe-inline'",
"'wasm-unsafe-eval'",
isDevelopment ? "'unsafe-eval'" : null,
'https://www.googletagmanager.com',
'https://utt.impactcdn.com',
'https://login.kilo.ai',
'https://login-test.kilo.ai',
'https://js.stripe.com',
'https://*.js.stripe.com',
'https://checkout.stripe.com',
'https://challenges.cloudflare.com',
...getAdditionalCspSources('script-src', env),
]);

const connectSrc = compactUnique([
"'self'",
'https://auth.kilo.ai',
'https://us.i.posthog.com',
'https://us-assets.i.posthog.com',
'https://api.stripe.com',
'https://r.stripe.com',
'https://m.stripe.com',
'https://checkout.stripe.com',
'https://utt.impactcdn.com',
'https://challenges.cloudflare.com',
'https://cdn.jsdelivr.net',
'https://unpkg.com',
'https://*.d.kiloapps.io',
isDevelopment ? 'http://localhost:*' : null,
isDevelopment ? 'ws://localhost:*' : null,
...configuredConnectSrcUrls.map(originFromUrl),
...getAdditionalCspSources('connect-src', env),
]);

const directives: Record<string, string[]> = {
'default-src': ["'self'"],
'base-uri': ["'self'"],
'object-src': ["'none'"],
'frame-ancestors': ["'self'"],
'form-action': ["'self'"],
'script-src': scriptSrc,
'connect-src': connectSrc,
'img-src': [
"'self'",
'data:',
'blob:',
'https://lh3.googleusercontent.com',
'https://avatars.githubusercontent.com',
'https://*.stripe.com',
'https://www.googletagmanager.com',
'https://utt.impactcdn.com',
'https://challenges.cloudflare.com',
...getAdditionalCspSources('img-src', env),
],
'style-src': ["'self'", "'unsafe-inline'", ...getAdditionalCspSources('style-src', env)],
'font-src': ["'self'", 'data:', ...getAdditionalCspSources('font-src', env)],
'frame-src': [
"'self'",
'https://js.stripe.com',
'https://*.js.stripe.com',
'https://hooks.stripe.com',
'https://checkout.stripe.com',
'https://challenges.cloudflare.com',
'https://www.youtube.com',
'https://*.d.kiloapps.io',
...getAdditionalCspSources('frame-src', env),
],
'worker-src': ["'self'", 'blob:', ...getAdditionalCspSources('worker-src', env)],
'media-src': ["'self'", 'blob:', ...getAdditionalCspSources('media-src', env)],
'manifest-src': ["'self'"],
};

return Object.entries(directives)
.map(([directive, sources]) => `${directive} ${sources.join(' ')}`)
.join('; ');
}
14 changes: 13 additions & 1 deletion apps/web/src/proxy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,27 @@ import { NextResponse } from 'next/server';
import { withAuthenticatedAdminApiRoutes } from './middleware/withAuthenticatedAdminApiRoutes';
import { withBlockedClients } from './middleware/withBlockedClients';
import { withKiloEditorCookie } from './middleware/withKiloEditorCookie';
import { buildContentSecurityPolicy, getConfiguredConnectSrcOrigins } from '@/lib/security-headers';

function baseProxy(request: NextRequestWithAuth) {
const requestHeaders = new Headers(request.headers);
requestHeaders.set('x-pathname', request.nextUrl.pathname);
return NextResponse.next({

const response = NextResponse.next({
request: {
headers: requestHeaders,
},
});

response.headers.set(
'Content-Security-Policy',
buildContentSecurityPolicy({
isDevelopment: process.env.NODE_ENV === 'development',
connectSrcUrls: getConfiguredConnectSrcOrigins(),
})
);

return response;
}

export const proxy = withBlockedClients(
Expand Down
5 changes: 3 additions & 2 deletions apps/web/src/types/datalayer.d.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
// Extend the Window interface to include dataLayer
export {};

declare global {
interface Window {
datalayer: object[];
dataLayer?: Array<Record<string, unknown>>;
}
}
Loading