mirror of https://github.com/buster-so/buster.git
embed parsing
This commit is contained in:
parent
3b4394874e
commit
709c3bf627
|
@ -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
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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) });
|
||||
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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
|
||||
});
|
||||
});
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
Loading…
Reference in New Issue