diff --git a/helper/src/universal_site_contract_test.go b/helper/src/universal_site_contract_test.go new file mode 100644 index 0000000..123a0ae --- /dev/null +++ b/helper/src/universal_site_contract_test.go @@ -0,0 +1,69 @@ +package main + +import "testing" + +// First-slice contract: the user explicitly opens a public video page and +// clicks FramePier. A different supported site must reach the yt-dlp metadata +// probe rather than being refused solely because it is not one of our three +// original integrations. These tests do not perform network requests. +func TestUniversalSiteAcceptsPublicHTTPSPages(t *testing.T) { + urls := []string{ + "https://vimeo.com/123456789", + "https://www.reddit.com/r/videos/comments/abc123/example/", + "https://www.tiktok.com/@creator/video/123456789", + } + for _, raw := range urls { + t.Run(raw, func(t *testing.T) { + got, err := validateMediaURL(raw, "") + if err != nil { + t.Fatalf("public HTTPS page rejected: %v", err) + } + if got.Source != mediaSource("generic") { + t.Fatalf("source = %q; want generic", got.Source) + } + if got.CanonicalURL != raw { + t.Fatalf("canonical URL = %q; want %q", got.CanonicalURL, raw) + } + // Generic sites have no trusted site-specific ID until yt-dlp + // returns validated metadata. + if got.ID != "" { + t.Fatalf("generic ID must not be guessed: %q", got.ID) + } + }) + } +} + +func TestUniversalSiteRejectsUnsafeEntryURLs(t *testing.T) { + cases := []string{ + "http://vimeo.com/123456789", + "https://localhost/watch", + "https://nas.local/watch", + "https://intranet/watch", + "https://metadata.google.internal/latest/meta-data/", + "https://127.0.0.1/watch", + "https://10.0.0.1/watch", + "https://172.16.1.1/watch", + "https://192.168.1.1/watch", + "https://169.254.169.254/latest/meta-data/", + "https://[::1]/watch", + "https://[fc00::1]/watch", + "https://[fe80::1]/watch", + "https://user:password@vimeo.com/123456789", + "https://vimeo.com:8443/123456789", + "file:///C:/Windows/System32/", + "https://vimeo.com/\nInjected: header", + } + for _, raw := range cases { + t.Run(raw, func(t *testing.T) { + if got, err := validateMediaURL(raw, ""); err == nil { + t.Fatalf("unsafe entry URL accepted: %#v", got) + } + }) + } +} + +func TestUniversalSiteDoesNotTrustArbitrarySuppliedVideoID(t *testing.T) { + if got, err := validateMediaURL("https://vimeo.com/123456789", "forged-id"); err == nil { + t.Fatalf("untrusted supplied ID accepted: %#v", got) + } +} diff --git a/helper/src/url_validation.go b/helper/src/url_validation.go index dea6622..430da9a 100644 --- a/helper/src/url_validation.go +++ b/helper/src/url_validation.go @@ -14,12 +14,14 @@ const ( sourceTwitchClip mediaSource = "twitch_clip" sourceTwitchVOD mediaSource = "twitch_vod" sourceDailymotion mediaSource = "dailymotion" + sourceGeneric mediaSource = "generic" ) type mediaTarget struct { CanonicalURL string ID string Source mediaSource + Extractor string // validated extractor for bounded additional sites } var ( @@ -30,6 +32,9 @@ var ( // slug that older share links append after "_" is validated and discarded. dailymotionIDPattern = regexp.MustCompile(`^x[0-9a-z]{4,10}$`) dailymotionSlugPattern = regexp.MustCompile(`^[A-Za-z0-9-]{1,200}$`) + vimeoVideoPattern = regexp.MustCompile(`^/[0-9]{1,20}$`) + redditVideoPattern = regexp.MustCompile(`^/r/[A-Za-z0-9_]{1,21}/comments/[A-Za-z0-9]{3,12}/[A-Za-z0-9_-]{1,200}/?$`) + tiktokVideoPattern = regexp.MustCompile(`^/@[A-Za-z0-9_.]{2,32}/video/[0-9]{6,24}$`) ) func validateMediaURL(raw, suppliedID string) (mediaTarget, error) { @@ -49,8 +54,10 @@ func validateMediaURL(raw, suppliedID string) (mediaTarget, error) { target, err = validateParsedTwitchURL(parsed, host) case "dailymotion.com", "www.dailymotion.com": target, err = validateParsedDailymotionURL(parsed) + case "vimeo.com", "www.vimeo.com", "reddit.com", "www.reddit.com", "tiktok.com", "www.tiktok.com": + target, err = validateSupportedGenericURL(parsed, host) default: - err = errors.New("Only official YouTube, Twitch and Dailymotion domains are supported.") + err = errors.New("Only approved YouTube, Twitch, Dailymotion, Vimeo, Reddit and TikTok video URLs are supported.") } if err != nil { return mediaTarget{}, err @@ -127,6 +134,35 @@ func validateParsedDailymotionURL(parsed *url.URL) (mediaTarget, error) { return mediaTarget{}, errors.New("This Dailymotion page is not a supported public video.") } +// This is a constrained set of additional sites, not arbitrary URL passthrough. +// The named extractors are not network isolation; cross-host fetches and DNS +// remain subject to independent security review before release. +func validateSupportedGenericURL(parsed *url.URL, host string) (mediaTarget, error) { + if parsed.RawQuery != "" || parsed.Fragment != "" || parsed.Opaque != "" || parsed.ForceQuery || + parsed.RawPath != "" || parsed.EscapedPath() != parsed.Path { + return mediaTarget{}, errors.New("Use a direct public video URL without extra parameters.") + } + var extractor string + switch host { + case "vimeo.com", "www.vimeo.com": + if vimeoVideoPattern.MatchString(parsed.Path) { + extractor = "Vimeo" + } + case "reddit.com", "www.reddit.com": + if redditVideoPattern.MatchString(parsed.Path) { + extractor = "Reddit" + } + case "tiktok.com", "www.tiktok.com": + if tiktokVideoPattern.MatchString(parsed.Path) { + extractor = "TikTok" + } + } + if extractor == "" { + return mediaTarget{}, errors.New("Open a single public video page, not a feed or profile.") + } + return mediaTarget{CanonicalURL: parsed.String(), Source: sourceGeneric, Extractor: extractor}, nil +} + func pathParts(value string) []string { trimmed := strings.Trim(value, "/") if trimmed == "" { @@ -154,6 +190,8 @@ func sourceLabel(source mediaSource) string { return "Twitch VOD" case sourceDailymotion: return "Dailymotion" + case sourceGeneric: + return "Supported site" default: return "YouTube" } diff --git a/helper/src/ytdlp_command.go b/helper/src/ytdlp_command.go index e3f91d1..85be9a0 100644 --- a/helper/src/ytdlp_command.go +++ b/helper/src/ytdlp_command.go @@ -43,6 +43,10 @@ func ytdlpExtractorFor(source mediaSource) string { if source == sourceDailymotion { return "dailymotion" } + if source == sourceGeneric { + // Explicit extractors only; never invoke yt-dlp generic/fallback on arbitrary sites. + return "Vimeo,Reddit,TikTok" + } return "" }