diff --git a/apps/web/next.config.mjs b/apps/web/next.config.mjs index 7bff289a9..0756e4c9c 100644 --- a/apps/web/next.config.mjs +++ b/apps/web/next.config.mjs @@ -29,10 +29,14 @@ const createCspHeader = (isEmbed = false) => { "img-src 'self' blob: data: https: http:", // Fonts "font-src 'self' https://fonts.gstatic.com https://cdn.jsdelivr.net", - // Frame ancestors - isEmbed ? `frame-ancestors 'self' *` : "frame-ancestors 'none'", - // Frame sources - "frame-src 'self' https://vercel.live", + // Frame ancestors - allow framing in development, restrict in production + isEmbed + ? `frame-ancestors 'self' *` + : isDev + ? `frame-ancestors 'self' *` + : "frame-ancestors 'none'", + // Frame sources - allow embeds from accepted domains + "frame-src 'self' https://vercel.live https://*.twitter.com https://twitter.com https://*.x.com https://x.com https://*.youtube.com https://youtube.com https://*.youtube-nocookie.com https://youtube-nocookie.com https://*.youtu.be https://youtu.be https://*.vimeo.com https://vimeo.com", // Connect sources for API calls (() => { const connectSources = [ @@ -44,6 +48,19 @@ const createCspHeader = (isEmbed = false) => { 'wss://*.supabase.co', 'https://*.posthog.com', 'https://*.slack.com', + // Social media and video platform APIs for embeds + 'https://*.twitter.com', + 'https://twitter.com', + 'https://*.x.com', + 'https://x.com', + 'https://*.youtube.com', + 'https://youtube.com', + 'https://*.youtube-nocookie.com', + 'https://youtube-nocookie.com', + 'https://*.youtu.be', + 'https://youtu.be', + 'https://*.vimeo.com', + 'https://vimeo.com', apiUrl, api2Url, profilePictureURL @@ -53,8 +70,8 @@ const createCspHeader = (isEmbed = false) => { return `connect-src ${connectSources.join(' ')}`; })(), - // Media - "media-src 'self'", + // Media - allow media content from accepted domains + "media-src 'self' https://*.twitter.com https://twitter.com https://*.x.com https://x.com https://*.youtube.com https://youtube.com https://*.youtube-nocookie.com https://youtube-nocookie.com https://*.youtu.be https://youtu.be https://*.vimeo.com https://vimeo.com", // Object "object-src 'none'", // Form actions diff --git a/apps/web/src/components/ui/buttons/Button.tsx b/apps/web/src/components/ui/buttons/Button.tsx index 61cfcf910..8465c52a4 100644 --- a/apps/web/src/components/ui/buttons/Button.tsx +++ b/apps/web/src/components/ui/buttons/Button.tsx @@ -38,7 +38,7 @@ const sizeVariants = { }; export const buttonVariants = cva( - 'inline-flex whitespace-nowrap items-center overflow-hidden text-base justify-center gap-1.5 shadow rounded transition-all duration-300 focus-visible:outline-none disabled:pointer-events-none disabled:cursor-not-allowed data-[loading=true]:cursor-progress', + 'inline-flex cursor-pointer whitespace-nowrap items-center overflow-hidden text-base justify-center gap-1.5 shadow rounded transition-all duration-300 focus-visible:outline-none disabled:pointer-events-none disabled:cursor-not-allowed data-[loading=true]:cursor-progress', { variants: { variant: buttonTypeClasses, @@ -168,10 +168,7 @@ export const Button = React.forwardRef( return ( { return { @@ -48,6 +49,13 @@ const parseGenericUrl: EmbedUrlParser = (url: string) => { }; const urlParsers: EmbedUrlParser[] = [parseTwitterUrl, parseVideoUrl, parseGenericUrl]; +const ACCEPTED_DOMAINS = [ + process.env.NEXT_PUBLIC_URL, + 'twitter.com', + 'x.com', + 'youtube.com', + 'vimeo.com' +]; export const MediaEmbedElement = withHOC( ResizableProvider, @@ -186,6 +194,16 @@ export const MediaEmbedPlaceholder = (props: PlateElementProps ) : (
- + Edit link - - Caption - + Caption - +
)} diff --git a/apps/web/src/lib/url.test.ts b/apps/web/src/lib/url.test.ts new file mode 100644 index 000000000..735d692ae --- /dev/null +++ b/apps/web/src/lib/url.test.ts @@ -0,0 +1,52 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { isUrlFromAcceptedDomain } from './url'; + +describe('isUrlFromAcceptedDomain', () => { + beforeEach(() => { + // Reset environment variable mock before each test + vi.resetModules(); + }); + + it('should return true for URLs from exact accepted domains', () => { + // Test case: URLs from exact accepted domains should be allowed + // Expected: All these URLs should return true + expect(isUrlFromAcceptedDomain('https://twitter.com/status/123')).toBe(true); + expect(isUrlFromAcceptedDomain('https://x.com/user/post')).toBe(true); + expect(isUrlFromAcceptedDomain('https://youtube.com/watch?v=123')).toBe(true); + expect(isUrlFromAcceptedDomain('https://youtube-nocookie.com/embed/123')).toBe(true); + expect(isUrlFromAcceptedDomain('https://vimeo.com/123456')).toBe(true); + expect(isUrlFromAcceptedDomain('http://twitter.com')).toBe(true); + expect(isUrlFromAcceptedDomain('https://youtu.be/QrM39m22jH4?list=RDQrM39m22jH4')).toBe(true); + }); + + it('should return true for URLs from subdomains of accepted domains', () => { + // Test case: URLs from subdomains of accepted domains should be allowed + // Expected: All these subdomain URLs should return true + expect(isUrlFromAcceptedDomain('https://www.twitter.com/status/123')).toBe(true); + expect(isUrlFromAcceptedDomain('https://mobile.twitter.com/user')).toBe(true); + expect(isUrlFromAcceptedDomain('https://www.youtube.com/watch?v=123')).toBe(true); + expect(isUrlFromAcceptedDomain('https://m.youtube.com/watch?v=123')).toBe(true); + expect(isUrlFromAcceptedDomain('https://www.youtube-nocookie.com/embed/123')).toBe(true); + expect(isUrlFromAcceptedDomain('https://player.vimeo.com/video/123')).toBe(true); + }); + + it('should return false for URLs from non-accepted domains', () => { + // Test case: URLs from domains not in the accepted list should be rejected + // Expected: All these URLs should return false + expect(isUrlFromAcceptedDomain('https://facebook.com/post/123')).toBe(false); + expect(isUrlFromAcceptedDomain('https://instagram.com/user')).toBe(false); + expect(isUrlFromAcceptedDomain('https://tiktok.com/video/123')).toBe(false); + expect(isUrlFromAcceptedDomain('https://malicious-site.com')).toBe(false); + }); + + it('should return false for invalid URLs and handle errors gracefully', () => { + // Test case: Invalid URL strings should be handled gracefully and return false + // Expected: All invalid URLs should return false without throwing errors + expect(isUrlFromAcceptedDomain('not-a-url')).toBe(false); + expect(isUrlFromAcceptedDomain('invalid://url')).toBe(false); + expect(isUrlFromAcceptedDomain('')).toBe(false); + expect(isUrlFromAcceptedDomain('javascript:alert("xss")')).toBe(false); + expect(isUrlFromAcceptedDomain('ftp://twitter.com')).toBe(true); // Still valid URL format + expect(isUrlFromAcceptedDomain('https://')).toBe(false); // Invalid URL + }); +}); diff --git a/apps/web/src/lib/url.ts b/apps/web/src/lib/url.ts index 8aad5ff18..4cde3a4e6 100644 --- a/apps/web/src/lib/url.ts +++ b/apps/web/src/lib/url.ts @@ -2,3 +2,42 @@ export const getURLPathname = (url: string): string => { const parsedUrl = new URL(url); return parsedUrl.pathname.toString(); }; + +const ACCEPTED_DOMAINS = [ + process.env.NEXT_PUBLIC_URL, + 'twitter.com', + 'x.com', + 'youtube.com', + 'youtube-nocookie.com', + 'vimeo.com', + 'youtu.be' +]; + +/** + * Checks if a URL is from an accepted domain + * @param url - The URL string to validate + * @returns boolean - true if the URL is from an accepted domain, false otherwise + */ +export function isUrlFromAcceptedDomain(url: string): boolean { + try { + console.log('url', url); + const parsedUrl = new URL(url); + console.log('parsedUrl', parsedUrl); + return ACCEPTED_DOMAINS.some((accepted) => { + try { + // If accepted is a full URL, compare origins + if (accepted?.startsWith('http') || accepted?.startsWith('https')) { + const acceptedUrl = new URL(accepted); + return parsedUrl.origin === acceptedUrl.origin; + } + // Otherwise, compare hostnames (e.g., 'youtube.com') + return parsedUrl.hostname === accepted || parsedUrl.hostname.endsWith(`.${accepted}`); + } catch { + // If accepted is not a valid URL, fallback to hostname match + return parsedUrl.hostname === accepted || parsedUrl.hostname.endsWith(`.${accepted}`); + } + }); + } catch { + return false; + } +}