embed parsing

This commit is contained in:
Nate Kelley 2025-08-02 13:09:55 -06:00
parent 3b4394874e
commit 709c3bf627
No known key found for this signature in database
GPG Key ID: FD90372AB8D98B4F
6 changed files with 137 additions and 17 deletions

View File

@ -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

View File

@ -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<HTMLButtonElement, ButtonProps>(
return (
<Comp
className={cn(
'cursor-pointer',
buttonVariants({ variant, size, iconButton, rounding, block, className })
)}
className={buttonVariants({ variant, size, iconButton, rounding, block, className })}
onClick={onClick}
ref={ref}
disabled={disabled}

View File

@ -39,6 +39,7 @@ import { Separator } from '../../separator';
import { useBusterNotifications } from '@/context/BusterNotifications';
import { useEffect } from 'react';
import { useClickAway } from '@/hooks/useClickAway';
import { isUrlFromAcceptedDomain } from '@/lib/url';
const parseGenericUrl: EmbedUrlParser = (url: string) => {
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<TMediaEmbedElemen
return;
}
// Check if the URL is from an accepted domain
const isAccepted = isUrlFromAcceptedDomain(url);
if (!isAccepted) {
openInfoMessage(
`Please enter a valid URL from an accepted domain: ${ACCEPTED_DOMAINS.join(', ')}`
);
return;
}
// Update the current node with the URL
editor.tf.setNodes({ url }, { at: editor.api.findPath(props.element) });

View File

@ -83,18 +83,15 @@ export function MediaToolbar({
</div>
) : (
<div className="box-content flex items-center">
<FloatingMediaPrimitive.EditButton
className={buttonVariants({ size: 'small', variant: 'ghost' })}>
<FloatingMediaPrimitive.EditButton className={buttonVariants({ variant: 'ghost' })}>
Edit link
</FloatingMediaPrimitive.EditButton>
<CaptionButton size="small" variant="ghost">
Caption
</CaptionButton>
<CaptionButton variant="ghost">Caption</CaptionButton>
<Separator orientation="vertical" className="mx-1 h-6" />
<Button size="small" prefix={<Trash2 />} variant="ghost" {...buttonProps}></Button>
<Button prefix={<Trash2 />} variant="ghost" {...buttonProps}></Button>
</div>
)}
</PopoverContent>

View File

@ -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
});
});

View File

@ -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;
}
}