From 99234bbf2268cf710134161279d9cf5f3d0e7268 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 8 Aug 2025 15:22:07 +0000 Subject: [PATCH 1/2] Initial plan From 6c7c16453da483af0e149ae3509e3e8043bca747 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 8 Aug 2025 15:39:44 +0000 Subject: [PATCH 2/2] Implement comprehensive XSS protection and security utilities Co-authored-by: litlfred <662242+litlfred@users.noreply.github.com> --- src/components/DecisionSupportLogicView.js | 3 +- src/components/DocumentationViewer.js | 33 +-- src/services/githubService.js | 47 +++- src/utils/navigationUtils.js | 27 ++ src/utils/securityUtils.js | 284 ++++++++++++++++++++ src/utils/securityUtils.test.js | 294 +++++++++++++++++++++ 6 files changed, 661 insertions(+), 27 deletions(-) create mode 100644 src/utils/securityUtils.js create mode 100644 src/utils/securityUtils.test.js diff --git a/src/components/DecisionSupportLogicView.js b/src/components/DecisionSupportLogicView.js index f525f0050..b7e65bff1 100644 --- a/src/components/DecisionSupportLogicView.js +++ b/src/components/DecisionSupportLogicView.js @@ -3,6 +3,7 @@ import { useNavigate } from 'react-router-dom'; import githubService from '../services/githubService'; import MDEditor from '@uiw/react-md-editor'; import { PageLayout, useDAKParams } from './framework'; +import { sanitizeHtml } from '../utils/securityUtils'; import './DecisionSupportLogicView.css'; const DecisionSupportLogicView = () => { @@ -965,7 +966,7 @@ define "Contraindication Present": {selectedDialog.type === 'html' ? (
) : (
diff --git a/src/components/DocumentationViewer.js b/src/components/DocumentationViewer.js
index c4666af4f..8fd3ee183 100644
--- a/src/components/DocumentationViewer.js
+++ b/src/components/DocumentationViewer.js
@@ -1,6 +1,7 @@
 import React, { useState, useEffect } from 'react';
 import { useNavigate, useParams } from 'react-router-dom';
 import { PageLayout } from './framework';
+import { sanitizeHtml } from '../utils/securityUtils';
 import './DocumentationViewer.css';
 
 // Dynamically generate documentation files structure
@@ -91,7 +92,9 @@ const DocumentationViewer = () => {
 
   const renderMarkdown = (markdown) => {
     // Simple markdown to HTML conversion for basic formatting
-    let html = markdown;
+    // First sanitize the input markdown to prevent XSS in the source
+    const safeMd = sanitizeHtml(markdown);
+    let html = safeMd;
 
     // Process tables first (before paragraph processing)
     html = html.replace(/(\|[^\n]+\|\n\|[-\s|:]+\|\n(?:\|[^\n]+\|\n?)*)/gm, (match) => {
@@ -101,14 +104,14 @@ const DocumentationViewer = () => {
       
       let tableHtml = '\n\n\n';
       headers.forEach(header => {
-        tableHtml += `\n`;
+        tableHtml += `\n`;
       });
       tableHtml += '\n\n\n';
       
       rows.forEach(row => {
         tableHtml += '\n';
         row.forEach(cell => {
-          tableHtml += `\n`;
+          tableHtml += `\n`;
         });
         tableHtml += '\n';
       });
@@ -117,19 +120,19 @@ const DocumentationViewer = () => {
       return tableHtml;
     });
 
-    // Apply other markdown formatting
+    // Apply other markdown formatting with safe replacements
     return html
-      .replace(/^# (.*$)/gim, '

$1

') - .replace(/^## (.*$)/gim, '

$1

') - .replace(/^### (.*$)/gim, '

$1

') - .replace(/^#### (.*$)/gim, '

$1

') - .replace(/\*\*(.*)\*\*/gim, '$1') - .replace(/\*(.*)\*/gim, '$1') - .replace(/!\[([^\]]*)\]\(([^)]*)\)/gim, '$1') - .replace(/\[([^\]]*)\]\(([^)]*)\)/gim, '$1') - .replace(/`([^`]*)`/gim, '$1') - .replace(/^- (.*$)/gim, '
  • $1
  • ') - .replace(/^(\d+)\. (.*$)/gim, '
  • $2
  • ') + .replace(/^# (.*$)/gim, (match, p1) => `

    ${sanitizeHtml(p1)}

    `) + .replace(/^## (.*$)/gim, (match, p1) => `

    ${sanitizeHtml(p1)}

    `) + .replace(/^### (.*$)/gim, (match, p1) => `

    ${sanitizeHtml(p1)}

    `) + .replace(/^#### (.*$)/gim, (match, p1) => `

    ${sanitizeHtml(p1)}

    `) + .replace(/\*\*(.*)\*\*/gim, (match, p1) => `${sanitizeHtml(p1)}`) + .replace(/\*(.*)\*/gim, (match, p1) => `${sanitizeHtml(p1)}`) + .replace(/!\[([^\]]*)\]\(([^)]*)\)/gim, (match, p1, p2) => `${sanitizeHtml(p1)}`) + .replace(/\[([^\]]*)\]\(([^)]*)\)/gim, (match, p1, p2) => `${sanitizeHtml(p1)}`) + .replace(/`([^`]*)`/gim, (match, p1) => `${sanitizeHtml(p1)}`) + .replace(/^- (.*$)/gim, (match, p1) => `
  • ${sanitizeHtml(p1)}
  • `) + .replace(/^(\d+)\. (.*$)/gim, (match, p1, p2) => `
  • ${sanitizeHtml(p2)}
  • `) .replace(/\n\n/gim, '

    ') .replace(/^\n/gim, '

    ') .replace(/\n$/gim, '

    '); diff --git a/src/services/githubService.js b/src/services/githubService.js index 66a9de989..17fd62d37 100644 --- a/src/services/githubService.js +++ b/src/services/githubService.js @@ -2,6 +2,7 @@ import { Octokit } from '@octokit/rest'; import { processConcurrently } from '../utils/concurrency'; import repositoryCompatibilityCache from '../utils/repositoryCompatibilityCache'; import logger from '../utils/logger'; +import { validateGitHubApiParams } from '../utils/securityUtils'; class GitHubService { constructor() { @@ -199,32 +200,44 @@ class GitHubService { // Get specific organization data (public data, no auth required) async getOrganization(orgLogin) { + // Validate input parameters + const validatedParams = validateGitHubApiParams({ orgLogin }); + if (!validatedParams) { + throw new Error('Invalid organization login provided'); + } + try { // Create a temporary Octokit instance for public API calls if we don't have one const octokit = this.octokit || new Octokit(); const { data } = await octokit.rest.orgs.get({ - org: orgLogin + org: validatedParams.orgLogin }); return data; } catch (error) { - console.error(`Failed to fetch organization ${orgLogin}:`, error); + console.error(`Failed to fetch organization ${validatedParams.orgLogin}:`, error); throw error; } } // Get specific user data (public data, no auth required) async getUser(username) { + // Validate input parameters + const validatedParams = validateGitHubApiParams({ username }); + if (!validatedParams) { + throw new Error('Invalid username provided'); + } + try { // Create a temporary Octokit instance for public API calls if we don't have one const octokit = this.octokit || new Octokit(); const { data } = await octokit.rest.users.getByUsername({ - username + username: validatedParams.username }); return data; } catch (error) { - console.error(`Failed to fetch user ${username}:`, error); + console.error(`Failed to fetch user ${validatedParams.username}:`, error); throw error; } } @@ -604,13 +617,19 @@ class GitHubService { // Get a specific repository async getRepository(owner, repo) { + // Validate input parameters + const validatedParams = validateGitHubApiParams({ owner, repo }); + if (!validatedParams) { + throw new Error('Invalid repository parameters provided'); + } + try { // Use authenticated octokit if available, otherwise create a public instance for public repos const octokit = this.isAuth() ? this.octokit : new Octokit(); const { data } = await octokit.rest.repos.get({ - owner, - repo, + owner: validatedParams.owner, + repo: validatedParams.repo, }); return data; } catch (error) { @@ -621,8 +640,14 @@ class GitHubService { // Get repository branches async getBranches(owner, repo) { + // Validate input parameters + const validatedParams = validateGitHubApiParams({ owner, repo }); + if (!validatedParams) { + throw new Error('Invalid repository parameters provided'); + } + try { - console.log(`githubService.getBranches: Fetching branches for ${owner}/${repo}`); + console.log(`githubService.getBranches: Fetching branches for ${validatedParams.owner}/${validatedParams.repo}`); console.log('githubService.getBranches: Authentication status:', this.isAuth()); // Use authenticated octokit if available, otherwise create a public instance for public repos @@ -630,8 +655,8 @@ class GitHubService { console.log('githubService.getBranches: Using', this.isAuth() ? 'authenticated' : 'public', 'octokit instance'); const { data } = await octokit.rest.repos.listBranches({ - owner, - repo, + owner: validatedParams.owner, + repo: validatedParams.repo, per_page: 100 }); @@ -642,8 +667,8 @@ class GitHubService { console.error('githubService.getBranches: Error details:', { status: error.status, message: error.message, - owner, - repo + owner: validatedParams.owner, + repo: validatedParams.repo }); throw error; } diff --git a/src/utils/navigationUtils.js b/src/utils/navigationUtils.js index ac1b581ad..9d30420e1 100644 --- a/src/utils/navigationUtils.js +++ b/src/utils/navigationUtils.js @@ -2,6 +2,8 @@ * Utility functions for handling navigation with command-click support */ +import { validateUrlScheme } from './securityUtils'; + /** * Detects if a click event should open in a new tab * @param {MouseEvent} event - The click event @@ -17,6 +19,17 @@ export const shouldOpenInNewTab = (event) => { * @returns {string} - The full URL */ export const constructFullUrl = (relativePath) => { + // Validate the relative path to prevent XSS + if (!relativePath || typeof relativePath !== 'string') { + return window.location.origin; + } + + // Basic validation - don't allow dangerous protocols + if (relativePath.includes('javascript:') || relativePath.includes('data:')) { // eslint-disable-line no-script-url + console.warn('Blocked dangerous URL scheme in path:', relativePath); + return window.location.origin; + } + const basePath = process.env.PUBLIC_URL || ''; const baseUrl = window.location.origin; @@ -42,6 +55,20 @@ export const constructFullUrl = (relativePath) => { * @param {Object} state - Optional state to pass with navigation */ export const handleNavigationClick = (event, path, navigate, state = null) => { + // Validate the path to prevent malicious navigation + if (!path || typeof path !== 'string') { + console.warn('Invalid navigation path provided'); + return; + } + + // For external URLs, validate the scheme + if (path.includes('://')) { + if (!validateUrlScheme(path)) { + console.warn('Blocked navigation to potentially dangerous URL:', path); + return; + } + } + if (shouldOpenInNewTab(event)) { // Open in new tab const fullUrl = constructFullUrl(path); diff --git a/src/utils/securityUtils.js b/src/utils/securityUtils.js new file mode 100644 index 000000000..b6a6cee39 --- /dev/null +++ b/src/utils/securityUtils.js @@ -0,0 +1,284 @@ +/** + * Security utilities for XSS protection and input validation + * Provides comprehensive sanitization and validation functions for user inputs + */ + +/** + * HTML entity mapping for escaping dangerous characters + */ +const HTML_ENTITIES = { + '&': '&', + '<': '<', + '>': '>', + '"': '"', + "'": ''', + '/': '/', + '`': '`', + '=': '=' +}; + +/** + * Escapes HTML entities to prevent XSS attacks + * @param {string} input - The input string to escape + * @returns {string} - The escaped string safe for HTML insertion + */ +export const sanitizeHtml = (input) => { + if (typeof input !== 'string') { + return ''; + } + + return input.replace(/[&<>"'`=/]/g, (match) => HTML_ENTITIES[match] || match); +}; + +/** + * Validates URL schemes to prevent dangerous redirects + * Only allows http, https, and mailto schemes + * @param {string} url - The URL to validate + * @returns {boolean} - True if the URL is safe + */ +export const validateUrlScheme = (url) => { + if (typeof url !== 'string') { + return false; + } + + // Empty string or relative URLs are safe + if (!url || url.startsWith('/') || url.startsWith('#') || url.startsWith('?')) { + return true; + } + + try { + const urlObj = new URL(url); + const allowedSchemes = ['http:', 'https:', 'mailto:']; + return allowedSchemes.includes(urlObj.protocol.toLowerCase()); + } catch { + // If URL constructor fails, treat as relative URL (safe) + return !url.includes(':'); + } +}; + +/** + * Validates repository, username, or branch names for GitHub + * Prevents injection attacks via repository identifiers + * @param {string} name - The name to validate + * @param {string} type - The type of name ('user', 'repo', 'branch') + * @returns {boolean} - True if the name is valid + */ +export const validateRepositoryIdentifier = (name, type = 'repo') => { + if (typeof name !== 'string' || !name) { + return false; + } + + // Common restrictions for all types + if (name.length > 100 || name.includes('..')) { + return false; + } + + switch (type) { + case 'user': + // GitHub username rules: alphanumeric and hyphens, no consecutive hyphens + return /^[a-zA-Z0-9]([a-zA-Z0-9-]{0,37}[a-zA-Z0-9])?$/.test(name) && !name.includes('--'); + + case 'repo': + // GitHub repository name rules: alphanumeric, hyphens, underscores, dots + return /^[a-zA-Z0-9._-]+$/.test(name) && + !name.startsWith('.') && + !name.endsWith('.') && + !name.startsWith('-') && + !name.endsWith('-'); + + case 'branch': + // Git branch name rules: more permissive but prevent dangerous characters + return /^[^\s~^:?*[\]\\@{}<>|"';`$()]+$/.test(name) && // eslint-disable-line no-useless-escape + !name.startsWith('.') && + !name.endsWith('.') && + !name.includes('//') && + !name.includes('@{') && + name !== 'HEAD'; + + default: + return false; + } +}; + +/** + * Validates and sanitizes file paths to prevent path traversal attacks + * @param {string} path - The file path to validate + * @param {string[]} allowedExtensions - Optional array of allowed file extensions + * @returns {string|null} - Sanitized path or null if invalid + */ +export const validateAndSanitizePath = (path, allowedExtensions = []) => { + if (typeof path !== 'string' || !path) { + return null; + } + + // Check for null bytes and control characters first + if (/[\u0000-\u001f\u007f-\u009f]/.test(path)) { // eslint-disable-line no-control-regex + return null; + } + + // Remove null bytes and normalize + const cleanPath = path.replace(/\0/g, '').trim(); + + // Prevent path traversal attacks + if (cleanPath.includes('..') || + cleanPath.includes('//') || + cleanPath.startsWith('/')) { + return null; + } + + // Check for dangerous file patterns + const dangerousPatterns = [ + /\.(exe|bat|cmd|com|pif|scr|vbs|jar|dll)$/i, + /^(CON|PRN|AUX|NUL|COM[1-9]|LPT[1-9])(\.|$)/i, // Windows reserved names + /\.(htaccess|htpasswd|php|asp|jsp|cgi)$/i + ]; + + if (dangerousPatterns.some(pattern => pattern.test(cleanPath))) { + return null; + } + + // Check allowed extensions if specified + if (allowedExtensions.length > 0) { + const hasAllowedExtension = allowedExtensions.some(ext => + cleanPath.toLowerCase().endsWith(ext.toLowerCase()) + ); + if (!hasAllowedExtension) { + return null; + } + } + + return cleanPath; +}; + +/** + * Comprehensive input sanitization function + * Applies appropriate sanitization based on the input type + * @param {any} input - The input to sanitize + * @param {string} type - The type of input ('html', 'url', 'user', 'repo', 'branch', 'path') + * @param {Object} options - Additional options for specific types + * @returns {string|null} - Sanitized input or null if invalid + */ +export const sanitizeInput = (input, type = 'html', options = {}) => { + if (input === null || input === undefined) { + return null; + } + + const inputStr = String(input); + + switch (type) { + case 'html': + return sanitizeHtml(inputStr); + + case 'url': + return validateUrlScheme(inputStr) ? inputStr : null; + + case 'user': + return validateRepositoryIdentifier(inputStr, 'user') ? inputStr : null; + + case 'repo': + return validateRepositoryIdentifier(inputStr, 'repo') ? inputStr : null; + + case 'branch': + return validateRepositoryIdentifier(inputStr, 'branch') ? inputStr : null; + + case 'path': + return validateAndSanitizePath(inputStr, options.allowedExtensions); + + default: + // Default to HTML sanitization for unknown types + return sanitizeHtml(inputStr); + } +}; + +/** + * Safe HTML creator that prevents XSS while allowing basic formatting + * Creates HTML elements with sanitized content + * @param {string} tag - The HTML tag to create + * @param {string} content - The content to sanitize and insert + * @param {Object} attributes - Safe attributes to add to the element + * @returns {string} - Safe HTML string + */ +export const createSafeHtml = (tag, content, attributes = {}) => { + if (typeof tag !== 'string' || typeof content !== 'string') { + return ''; + } + + // Only allow safe HTML tags + const allowedTags = ['div', 'span', 'p', 'br', 'strong', 'em', 'code', 'pre', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6']; + if (!allowedTags.includes(tag.toLowerCase())) { + tag = 'div'; + } + + const sanitizedContent = sanitizeHtml(content); + + // Sanitize attributes + const safeAttributes = Object.entries(attributes) + .filter(([key]) => /^[a-zA-Z-]+$/.test(key) && !key.startsWith('on')) // No event handlers + .map(([key, value]) => `${key}="${sanitizeHtml(String(value))}"`) + .join(' '); + + const attributeStr = safeAttributes ? ` ${safeAttributes}` : ''; + + return `<${tag}${attributeStr}>${sanitizedContent}`; +}; + +/** + * Validates GitHub API parameters to prevent injection attacks + * @param {Object} params - Object containing GitHub API parameters + * @returns {Object|null} - Validated parameters or null if invalid + */ +export const validateGitHubApiParams = (params) => { + if (!params || typeof params !== 'object') { + return null; + } + + const validatedParams = {}; + + // Validate common GitHub API parameters + if (params.owner) { + const sanitizedOwner = sanitizeInput(params.owner, 'user'); + if (!sanitizedOwner) return null; + validatedParams.owner = sanitizedOwner; + } + + if (params.repo) { + const sanitizedRepo = sanitizeInput(params.repo, 'repo'); + if (!sanitizedRepo) return null; + validatedParams.repo = sanitizedRepo; + } + + if (params.branch) { + const sanitizedBranch = sanitizeInput(params.branch, 'branch'); + if (!sanitizedBranch) return null; + validatedParams.branch = sanitizedBranch; + } + + if (params.path) { + const sanitizedPath = sanitizeInput(params.path, 'path'); + if (sanitizedPath === null) return null; + validatedParams.path = sanitizedPath; + } + + // Copy other string parameters with HTML sanitization + Object.entries(params).forEach(([key, value]) => { + if (!validatedParams[key] && typeof value === 'string') { + validatedParams[key] = sanitizeHtml(value); + } else if (!validatedParams[key]) { + validatedParams[key] = value; + } + }); + + return validatedParams; +}; + +const securityUtils = { + sanitizeHtml, + validateUrlScheme, + validateRepositoryIdentifier, + validateAndSanitizePath, + sanitizeInput, + createSafeHtml, + validateGitHubApiParams +}; + +export default securityUtils; \ No newline at end of file diff --git a/src/utils/securityUtils.test.js b/src/utils/securityUtils.test.js new file mode 100644 index 000000000..69fcc07eb --- /dev/null +++ b/src/utils/securityUtils.test.js @@ -0,0 +1,294 @@ +import { + sanitizeHtml, + validateUrlScheme, + validateRepositoryIdentifier, + validateAndSanitizePath, + sanitizeInput, + createSafeHtml, + validateGitHubApiParams +} from './securityUtils'; + +describe('securityUtils', () => { + describe('sanitizeHtml', () => { + it('should escape HTML entities', () => { + expect(sanitizeHtml('')).toBe('<script>alert("xss")</script>'); + expect(sanitizeHtml('&<>"\'`=/')).toBe('&<>"'`=/'); + }); + + it('should handle non-string inputs', () => { + expect(sanitizeHtml(null)).toBe(''); + expect(sanitizeHtml(undefined)).toBe(''); + expect(sanitizeHtml(123)).toBe(''); + expect(sanitizeHtml({})).toBe(''); + }); + + it('should preserve safe text', () => { + expect(sanitizeHtml('Hello World')).toBe('Hello World'); + expect(sanitizeHtml('123 abc')).toBe('123 abc'); + }); + + it('should handle empty string', () => { + expect(sanitizeHtml('')).toBe(''); + }); + }); + + describe('validateUrlScheme', () => { + it('should allow safe URL schemes', () => { + expect(validateUrlScheme('https://example.com')).toBe(true); + expect(validateUrlScheme('http://example.com')).toBe(true); + expect(validateUrlScheme('mailto:test@example.com')).toBe(true); + }); + + it('should allow relative URLs', () => { + expect(validateUrlScheme('/path/to/resource')).toBe(true); + expect(validateUrlScheme('#anchor')).toBe(true); + expect(validateUrlScheme('?query=param')).toBe(true); + expect(validateUrlScheme('')).toBe(true); + }); + + it('should reject dangerous schemes', () => { + expect(validateUrlScheme('javascript:alert("xss")')).toBe(false); + expect(validateUrlScheme('data:text/html,')).toBe(false); + expect(validateUrlScheme('file:///etc/passwd')).toBe(false); + expect(validateUrlScheme('ftp://example.com')).toBe(false); + }); + + it('should handle malformed URLs', () => { + expect(validateUrlScheme('not a url')).toBe(true); // treated as relative + expect(validateUrlScheme('http:// invalid')).toBe(false); + }); + + it('should handle non-string inputs', () => { + expect(validateUrlScheme(null)).toBe(false); + expect(validateUrlScheme(undefined)).toBe(false); + expect(validateUrlScheme(123)).toBe(false); + }); + }); + + describe('validateRepositoryIdentifier', () => { + describe('user validation', () => { + it('should allow valid usernames', () => { + expect(validateRepositoryIdentifier('john', 'user')).toBe(true); + expect(validateRepositoryIdentifier('john-doe', 'user')).toBe(true); + expect(validateRepositoryIdentifier('user123', 'user')).toBe(true); + expect(validateRepositoryIdentifier('a', 'user')).toBe(true); + }); + + it('should reject invalid usernames', () => { + expect(validateRepositoryIdentifier('', 'user')).toBe(false); + expect(validateRepositoryIdentifier('-john', 'user')).toBe(false); + expect(validateRepositoryIdentifier('john-', 'user')).toBe(false); + expect(validateRepositoryIdentifier('john--doe', 'user')).toBe(false); + expect(validateRepositoryIdentifier('john_doe', 'user')).toBe(false); + expect(validateRepositoryIdentifier('john.doe', 'user')).toBe(false); + }); + }); + + describe('repo validation', () => { + it('should allow valid repository names', () => { + expect(validateRepositoryIdentifier('my-repo', 'repo')).toBe(true); + expect(validateRepositoryIdentifier('my_repo', 'repo')).toBe(true); + expect(validateRepositoryIdentifier('my.repo', 'repo')).toBe(true); + expect(validateRepositoryIdentifier('repo123', 'repo')).toBe(true); + }); + + it('should reject invalid repository names', () => { + expect(validateRepositoryIdentifier('', 'repo')).toBe(false); + expect(validateRepositoryIdentifier('.gitignore', 'repo')).toBe(false); + expect(validateRepositoryIdentifier('repo.', 'repo')).toBe(false); + expect(validateRepositoryIdentifier('-repo', 'repo')).toBe(false); + expect(validateRepositoryIdentifier('repo-', 'repo')).toBe(false); + expect(validateRepositoryIdentifier('my repo', 'repo')).toBe(false); + }); + }); + + describe('branch validation', () => { + it('should allow valid branch names', () => { + expect(validateRepositoryIdentifier('main', 'branch')).toBe(true); + expect(validateRepositoryIdentifier('feature/new-ui', 'branch')).toBe(true); + expect(validateRepositoryIdentifier('hotfix-123', 'branch')).toBe(true); + expect(validateRepositoryIdentifier('release/v1.0', 'branch')).toBe(true); + }); + + it('should reject invalid branch names', () => { + expect(validateRepositoryIdentifier('', 'branch')).toBe(false); + expect(validateRepositoryIdentifier('HEAD', 'branch')).toBe(false); + expect(validateRepositoryIdentifier('.hidden', 'branch')).toBe(false); + expect(validateRepositoryIdentifier('branch.', 'branch')).toBe(false); + expect(validateRepositoryIdentifier('branch..name', 'branch')).toBe(false); + expect(validateRepositoryIdentifier('feature//bug', 'branch')).toBe(false); + expect(validateRepositoryIdentifier('branch@{', 'branch')).toBe(false); + expect(validateRepositoryIdentifier('branch with spaces', 'branch')).toBe(false); + }); + }); + + it('should handle path traversal attempts', () => { + expect(validateRepositoryIdentifier('../etc/passwd', 'repo')).toBe(false); + expect(validateRepositoryIdentifier('../../etc', 'user')).toBe(false); + expect(validateRepositoryIdentifier('branch../malicious', 'branch')).toBe(false); + }); + + it('should handle long names', () => { + const longName = 'a'.repeat(101); + expect(validateRepositoryIdentifier(longName, 'user')).toBe(false); + expect(validateRepositoryIdentifier(longName, 'repo')).toBe(false); + expect(validateRepositoryIdentifier(longName, 'branch')).toBe(false); + }); + + it('should handle non-string inputs', () => { + expect(validateRepositoryIdentifier(null, 'user')).toBe(false); + expect(validateRepositoryIdentifier(undefined, 'repo')).toBe(false); + expect(validateRepositoryIdentifier(123, 'branch')).toBe(false); + }); + }); + + describe('validateAndSanitizePath', () => { + it('should allow safe paths', () => { + expect(validateAndSanitizePath('docs/readme.md')).toBe('docs/readme.md'); + expect(validateAndSanitizePath('src/components/App.js')).toBe('src/components/App.js'); + expect(validateAndSanitizePath('config.json')).toBe('config.json'); + }); + + it('should reject path traversal attempts', () => { + expect(validateAndSanitizePath('../etc/passwd')).toBe(null); + expect(validateAndSanitizePath('../../secret')).toBe(null); + expect(validateAndSanitizePath('/etc/passwd')).toBe(null); + expect(validateAndSanitizePath('docs/../../../etc')).toBe(null); + }); + + it('should reject dangerous files', () => { + expect(validateAndSanitizePath('malware.exe')).toBe(null); + expect(validateAndSanitizePath('script.bat')).toBe(null); + expect(validateAndSanitizePath('hack.php')).toBe(null); + expect(validateAndSanitizePath('.htaccess')).toBe(null); + expect(validateAndSanitizePath('CON')).toBe(null); + expect(validateAndSanitizePath('PRN.txt')).toBe(null); + }); + + it('should validate allowed extensions', () => { + expect(validateAndSanitizePath('doc.md', ['.md', '.txt'])).toBe('doc.md'); + expect(validateAndSanitizePath('file.txt', ['.md', '.txt'])).toBe('file.txt'); + expect(validateAndSanitizePath('script.js', ['.md', '.txt'])).toBe(null); + }); + + it('should handle null bytes and control characters', () => { + expect(validateAndSanitizePath('file\0.txt')).toBe(null); + expect(validateAndSanitizePath('file\x00.txt')).toBe(null); + expect(validateAndSanitizePath('file\x1f.txt')).toBe(null); + }); + + it('should handle non-string inputs', () => { + expect(validateAndSanitizePath(null)).toBe(null); + expect(validateAndSanitizePath(undefined)).toBe(null); + expect(validateAndSanitizePath(123)).toBe(null); + }); + }); + + describe('sanitizeInput', () => { + it('should sanitize based on type', () => { + expect(sanitizeInput('', 'html')).toBe('<script>alert("xss")</script>'); + expect(sanitizeInput('javascript:alert("xss")', 'url')).toBe(null); + expect(sanitizeInput('https://example.com', 'url')).toBe('https://example.com'); + expect(sanitizeInput('valid-user', 'user')).toBe('valid-user'); + expect(sanitizeInput('invalid user', 'user')).toBe(null); + }); + + it('should default to HTML sanitization for unknown types', () => { + expect(sanitizeInput('')).toBe('
    <script>alert("xss")</script>
    '); + }); + + it('should restrict to safe tags', () => { + expect(createSafeHtml('script', 'content')).toBe('
    content
    '); + expect(createSafeHtml('iframe', 'content')).toBe('
    content
    '); + }); + + it('should sanitize attributes', () => { + expect(createSafeHtml('div', 'content', { onclick: 'alert()' })).toBe('
    content
    '); + expect(createSafeHtml('div', 'content', { 'data-test': '' + }; + + const result = validateGitHubApiParams(params); + expect(result.message).toBe('<script>alert("xss")</script>'); + }); + + it('should handle non-object inputs', () => { + expect(validateGitHubApiParams(null)).toBe(null); + expect(validateGitHubApiParams(undefined)).toBe(null); + expect(validateGitHubApiParams('string')).toBe(null); + }); + + it('should preserve non-string parameters', () => { + const params = { + owner: 'valid-user', + repo: 'valid-repo', + count: 10, + enabled: true + }; + + const result = validateGitHubApiParams(params); + expect(result.count).toBe(10); + expect(result.enabled).toBe(true); + }); + }); +}); \ No newline at end of file
    ${header}${sanitizeHtml(header)}
    ${cell}${sanitizeHtml(cell)}