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 += `| ${header} | \n`;
+ tableHtml += `${sanitizeHtml(header)} | \n`;
});
tableHtml += '
\n\n\n';
rows.forEach(row => {
tableHtml += '\n';
row.forEach(cell => {
- tableHtml += `| ${cell} | \n`;
+ tableHtml += `${sanitizeHtml(cell)} | \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, '
')
- .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) => `
`)
+ .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}${tag}>`;
+};
+
+/**
+ * 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