diff --git a/src/features/browser/utils/urlUtils.ts b/src/features/browser/utils/urlUtils.ts new file mode 100644 index 0000000..b22f256 --- /dev/null +++ b/src/features/browser/utils/urlUtils.ts @@ -0,0 +1,164 @@ +/** + * Determines whether a given string is a valid URL. + * + * @param url - The string to validate as a URL. + * @returns `true` if the string is a valid URL, `false` otherwise. + */ +export const isValidUrl = (url: string): boolean => { + try { + new URL(url); + return true; + } catch { + return false; + } +}; + +/** + * Checks if a given string is a valid domain name. + * + * @param domain - The domain name string to validate. + * @returns `true` if the domain is valid, otherwise `false`. + */ +export const isValidDomain = (domain: string): boolean => { + const domainRegex = + /^[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$/; + return domainRegex.test(domain); +}; + +/** + * Normalizes a given input string into a valid URL or a Google search URL. + * + * @param input - The string to normalize as a URL or search query. + * @returns The normalized URL or search URL. + */ +export const normalizeUrl = (input: string): string => { + if (!input.trim()) { + return ""; + } + + const trimmedInput = input.trim(); + + // If it looks like a search query (contains spaces or no dots), use Google search + if ( + trimmedInput.includes(" ") || + (!trimmedInput.includes(".") && !trimmedInput.startsWith("localhost")) + ) { + return `https://www.google.com/search?q=${encodeURIComponent( + trimmedInput + )}`; + } + + // Handle localhost + if (trimmedInput.startsWith("localhost")) { + return trimmedInput.startsWith("http") + ? trimmedInput + : `http://${trimmedInput}`; + } + + // Add protocol if missing + if ( + !trimmedInput.startsWith("http://") && + !trimmedInput.startsWith("https://") + ) { + return `https://${trimmedInput}`; + } + + return trimmedInput; +}; + +export const extractDomain = (url: string): string => { + try { + return new URL(url).hostname; + } catch { + return url; + } +}; + +export const extractProtocol = (url: string): string => { + try { + return new URL(url).protocol; + } catch { + return "https:"; + } +}; + +/** + * Determines if a given URL is considered secure. + * + * @param url - The URL string to check. + * @returns `true` if the URL is secure, otherwise `false`. + */ +export const isSecureUrl = (url: string): boolean => { + return url.startsWith("https://") || url.startsWith("localhost"); +}; + +/** + * Formats a given URL string for display by extracting the hostname, + * pathname (if not root), and search parameters. + * If the input is not a valid URL, returns the original string. + * + * @param url - The URL string to format for display. + * @returns A formatted string suitable for display, or the original string if invalid. + */ +export const formatUrlForDisplay = (url: string): string => { + try { + const urlObj = new URL(url); + let displayUrl = urlObj.hostname; + + if (urlObj.pathname !== "/") { + displayUrl += urlObj.pathname; + } + + if (urlObj.search) { + displayUrl += urlObj.search; + } + + return displayUrl; + } catch { + return url; + } +}; + +export const getUrlWithoutProtocol = (url: string): string => { + try { + const urlObj = new URL(url); + return url.replace(`${urlObj.protocol}//`, ""); + } catch { + return url; + } +}; + +export const isSameOrigin = (url1: string, url2: string): boolean => { + try { + const urlObj1 = new URL(url1); + const urlObj2 = new URL(url2); + return urlObj1.origin === urlObj2.origin; + } catch { + return false; + } +}; + +/** + * Builds a search URL for the specified search engine using the provided query. + * + * @param query - The search query string to be encoded and appended to the URL. + * @param searchEngine - The search engine to use ("google", "bing", or "duckduckgo"). Defaults to "google". + * @returns The complete search URL for the specified search engine. + */ +export const buildSearchUrl = ( + query: string, + searchEngine: string = "google" +): string => { + const encodedQuery = encodeURIComponent(query); + + const searchEngines = { + google: `https://www.google.com/search?q=${encodedQuery}`, + bing: `https://www.bing.com/search?q=${encodedQuery}`, + duckduckgo: `https://duckduckgo.com/?q=${encodedQuery}`, + }; + + return ( + searchEngines[searchEngine as keyof typeof searchEngines] || + searchEngines.google + ); +}; diff --git a/src/features/scripts/hooks/useScripts.ts b/src/features/scripts/hooks/useScripts.ts index 5d7da34..4e5d1b7 100644 --- a/src/features/scripts/hooks/useScripts.ts +++ b/src/features/scripts/hooks/useScripts.ts @@ -10,7 +10,7 @@ import { updateUserScript, } from "../scriptsSlice"; import { ScriptExecution, UserScript } from "../types"; -import { createPatternRegex } from "../utils"; +import { createPatternRegex } from "../utils/patternUtils"; export const useScripts = () => { const dispatch = useAppDispatch(); diff --git a/src/features/scripts/scriptsSlice.ts b/src/features/scripts/scriptsSlice.ts index e615c84..4a3225e 100644 --- a/src/features/scripts/scriptsSlice.ts +++ b/src/features/scripts/scriptsSlice.ts @@ -6,7 +6,8 @@ import { } from "@reduxjs/toolkit"; import { importScript } from "./api/scriptApi"; import { ScriptExecution, ScriptsState, UserScript } from "./types"; -import { validateScript } from "./utils"; +import { validateUrlPattern } from "./utils/patternUtils"; +import { validateScript } from "./utils/scriptValidator"; // Async thunk to add a new user script by importing from a URL or direct input export const addUserScript = createAsyncThunk< @@ -22,6 +23,17 @@ export const addUserScript = createAsyncThunk< { rejectValue: string } >("scripts/createUserScript", async (args, { rejectWithValue }) => { try { + // Validate URL patterns + const urlPatternValidations = args.urlPatterns.map(validateUrlPattern); + const invalidPatterns = urlPatternValidations.filter( + (result) => !result.valid + ); + if (invalidPatterns.length > 0) { + return rejectWithValue( + invalidPatterns.map((result) => result.error).join(", ") + ); + } + let code: string | undefined; if (args.code) { @@ -33,9 +45,11 @@ export const addUserScript = createAsyncThunk< } // Validate the script code before creating the script - const validation = validateScript(code); - if (!validation.valid) { - return rejectWithValue(validation.error ?? "Unknown validation error"); + const scriptValidation = validateScript(code); + if (!scriptValidation.valid) { + return rejectWithValue( + scriptValidation.error ?? "Unknown validation error" + ); } const now = new Date().toISOString(); diff --git a/src/features/scripts/utils/index.ts b/src/features/scripts/utils/index.ts deleted file mode 100644 index 1d11311..0000000 --- a/src/features/scripts/utils/index.ts +++ /dev/null @@ -1,59 +0,0 @@ -export interface ScriptValidationResult { - valid: boolean; - error: string | null; -} - -/** - * Validates the syntax of a given script code string. - * - * @param code - The script code to validate. - * @returns An object containing the validation result: - * - `valid`: `true` if the code is syntactically correct, `false` otherwise. - * - `error`: The error message if validation fails, or `null` if validation succeeds. - */ -export function validateScript(code: string): ScriptValidationResult { - try { - new Function(code); // Basic syntax check - return { valid: true, error: null }; - } catch (error) { - return { valid: false, error: (error as Error).message }; - } -} - -/** - * Creates a regular expression from a URL pattern string, supporting wildcards. - * - * @param pattern - The URL pattern string, which may include '*' wildcards. - * @returns A RegExp object that matches URLs according to the given pattern. - * - * @example - * createPatternRegex("*.example.com"); - * Matches: "https://sub.example.com", "http://foo.bar.example.com" - * - * createPatternRegex("example.com/path*"); - * Matches: "https://example.com/path", "https://example.com/path/to/resource" - */ -export function createPatternRegex(pattern: string): RegExp { - // Escape regex special characters except '*' - let escaped = pattern.replace(/[-/\\^$+?.()|[\]{}]/g, "\\$&"); - // Replace '*' with '.*' - escaped = escaped.replace(/\*/g, ".*"); - - // Build the final regex string - let regexStr = "^(https?:\\/\\/)?"; - if (pattern.startsWith("*.")) { - // Wildcard subdomain - regexStr += "([\\w-]+\\.)+" + escaped.slice(2); - } else { - regexStr += escaped; - } - - // Determine if pattern allows paths - if (pattern.endsWith("/*") || pattern.endsWith("*")) { - regexStr += "(\\/.*)?$"; - } else { - regexStr += "\\/?$"; // Only allow optional trailing slash, not paths - } - - return new RegExp(regexStr, "i"); -} diff --git a/src/features/scripts/utils/patternUtils.ts b/src/features/scripts/utils/patternUtils.ts new file mode 100644 index 0000000..f8f67e4 --- /dev/null +++ b/src/features/scripts/utils/patternUtils.ts @@ -0,0 +1,85 @@ +export interface PatternValidationResult { + valid: boolean; + error?: string; +} + +/** + * Validates a URL pattern string to ensure it meets specific criteria. + * + * The validation checks include: + * - The pattern is not empty or whitespace. + * - The pattern does not contain invalid characters: `<`, `>`, `"`, `|`, or `\`. + * - The pattern can be converted to a valid regular expression using `globToRegex`. + * + * @param pattern - The URL pattern string to validate. + * @returns An object indicating whether the pattern is valid and, if invalid, an error message. + */ +export const validateUrlPattern = ( + pattern: string +): PatternValidationResult => { + if (!pattern.trim()) { + return { valid: false, error: "Pattern cannot be empty" }; + } + + const trimmedPattern = pattern.trim(); + + // Check for invalid characters + const invalidChars = /[<>"|\\]/; + if (invalidChars.test(trimmedPattern)) { + return { valid: false, error: "Pattern contains invalid characters" }; + } + + try { + // Convert glob pattern to regex and test if it's valid + const regexPattern = globToRegex(trimmedPattern); + new RegExp(regexPattern, "i"); + return { valid: true }; + } catch { + return { valid: false, error: "Invalid pattern format" }; + } +}; + +export const globToRegex = (pattern: string): string => { + return pattern + .replace(/[.*+?^${}()|[\]\\]/g, "\\$&") // Escape special regex chars + .replace(/\\\*/g, ".*") // Convert * to .* + .replace(/\\\?/g, "."); // Convert ? to . +}; + +/** + * Creates a regular expression from a URL pattern string, supporting wildcards. + * + * @param pattern - The URL pattern string, which may include '*' wildcards. + * @returns A RegExp object that matches URLs according to the given pattern. + * + * @example + * createPatternRegex("*.example.com"); + * Matches: "https://sub.example.com", "http://foo.bar.example.com" + * + * createPatternRegex("example.com/path*"); + * Matches: "https://example.com/path", "https://example.com/path/to/resource" + */ +export function createPatternRegex(pattern: string): RegExp { + // Escape regex special characters except '*' + let escaped = pattern.replace(/[-/\\^$+?.()|[\]{}]/g, "\\$&"); + // Replace '*' with '.*' + escaped = escaped.replace(/\*/g, ".*"); + + // Build the final regex string + let regexStr = "^(https?:\\/\\/)?"; + if (pattern.startsWith("*.")) { + // Wildcard subdomain + regexStr += "([\\w-]+\\.)+" + escaped.slice(2); + } else { + regexStr += escaped; + } + + // Determine if pattern allows paths + if (pattern.endsWith("/*") || pattern.endsWith("*")) { + regexStr += "(\\/.*)?$"; + } else { + regexStr += "\\/?$"; // Only allow optional trailing slash, not paths + } + + return new RegExp(regexStr, "i"); +} diff --git a/src/features/scripts/utils/scriptValidator.ts b/src/features/scripts/utils/scriptValidator.ts new file mode 100644 index 0000000..1a02f63 --- /dev/null +++ b/src/features/scripts/utils/scriptValidator.ts @@ -0,0 +1,127 @@ +export interface ValidationResult { + valid: boolean; + error?: string; + warnings?: string[]; +} + +const DANGEROUS_PATTERNS = [ + /eval\s*\(/gi, + /Function\s*\(/gi, + /setTimeout\s*\(\s*['"`][^'"`]*['"`]/gi, + /setInterval\s*\(\s*['"`][^'"`]*['"`]/gi, + /document\.write/gi, + /innerHTML\s*=/gi, + /outerHTML\s*=/gi, + /\.src\s*=/gi, + /window\.location/gi, + /document\.location/gi, + /XMLHttpRequest/gi, + /fetch\s*\(/gi, +]; + +const BLOCKED_GLOBALS = [ + "eval", + "Function", + "WebAssembly", + "importScripts", + "Worker", + "SharedWorker", + "ServiceWorker", +]; + +/** + * Validates a script code string for safety, syntax, and length. + * + * @param code - The script code to validate. + * @returns A {@link ValidationResult} object containing the validation status, + * error message if invalid, and any warnings about unsafe patterns. + */ +export function validateScript(code: string): ValidationResult { + if (!code || !code.trim()) { + return { + valid: false, + error: "Script code cannot be empty", + }; + } + + const trimmedCode = code.trim(); + const warnings: string[] = []; + + // Check for dangerous patterns + for (const pattern of DANGEROUS_PATTERNS) { + if (pattern.test(trimmedCode)) { + warnings.push(`Potentially unsafe pattern detected: ${pattern.source}`); + } + } + + // Check for blocked globals + for (const globalVar of BLOCKED_GLOBALS) { + const regex = new RegExp(`\\b${globalVar}\\b`, "gi"); + if (regex.test(trimmedCode)) { + return { + valid: false, + error: `Blocked global variable or function: ${globalVar}`, + }; + } + } + + // Basic syntax validation + try { + // Try to parse as function body + new Function(trimmedCode); + } catch (syntaxError) { + return { + valid: false, + error: `Syntax error: ${(syntaxError as Error).message}`, + }; + } + + // Check script length + if (trimmedCode.length > 50000) { + return { + valid: false, + error: "Script is too large (maximum 50KB allowed)", + }; + } + + return { + valid: true, + warnings: warnings.length > 0 ? warnings : undefined, + }; +} + +/** + * Sanitizes a script by trimming whitespace and removing comments. + * + * @param code - The script code to sanitize. + * @returns The sanitized script code with comments removed and whitespace trimmed. + */ +export function sanitizeScript(code: string): string { + let sanitized = code.trim(); + + // Remove potentially harmful patterns + sanitized = sanitized.replace(/\/\*[\s\S]*?\*\//g, ""); // Remove block comments + sanitized = sanitized.replace(/\/\/.*$/gm, ""); // Remove line comments + + return sanitized; +} + +/** + * Analyzes the complexity of a given script by counting lines, functions, loops, and conditionals. + * + * @param code - The source code to analyze as a string. + * @returns An object containing the number of lines, functions, loops, and conditionals in the code. + */ +export function analyzeScriptComplexity(code: string): { + lines: number; + functions: number; + loops: number; + conditionals: number; +} { + const lines = code.split("\n").length; + const functions = (code.match(/function\s+\w+/g) || []).length; + const loops = (code.match(/\b(for|while|do)\s*\(/g) || []).length; + const conditionals = (code.match(/\b(if|else|switch)\s*\(/g) || []).length; + + return { lines, functions, loops, conditionals }; +}