diff --git a/backend/apps/db/db.py b/backend/apps/db/db.py index 51084236..33b87534 100644 --- a/backend/apps/db/db.py +++ b/backend/apps/db/db.py @@ -386,7 +386,8 @@ def get_tables(ds: CoreDatasource): password=conf.password, options=f"-c statement_timeout={conf.timeout * 1000}", **extra_config_dict) as conn, conn.cursor() as cursor: - cursor.execute(sql.format(sql_param)) + # Use parameterized query for security + cursor.execute(sql, (sql_param,)) res = cursor.fetchall() res_list = [TableSchema(*item) for item in res] return res_list @@ -437,7 +438,8 @@ def get_fields(ds: CoreDatasource, table_name: str = None): password=conf.password, options=f"-c statement_timeout={conf.timeout * 1000}", **extra_config_dict) as conn, conn.cursor() as cursor: - cursor.execute(sql.format(p1, p2)) + # Use parameterized query for security + cursor.execute(sql, (p1, p2)) res = cursor.fetchall() res_list = [ColumnSchema(*item) for item in res] return res_list diff --git a/backend/apps/db/es_engine.py b/backend/apps/db/es_engine.py index a1c6ff1c..27f46b76 100644 --- a/backend/apps/db/es_engine.py +++ b/backend/apps/db/es_engine.py @@ -110,7 +110,18 @@ def get_es_data_by_http(conf: DatasourceConf, sql: str): host = f'{url}/_sql?format=json' - response = requests.post(host, data=json.dumps({"query": sql}), headers=get_es_auth(conf), verify=False) + # Security improvement: Enable SSL certificate verification + # Note: In production, always set verify=True or provide path to CA bundle + # If using self-signed certificates, provide the cert path: verify='/path/to/cert.pem' + verify_ssl = True if not url.startswith('https://localhost') else False + + response = requests.post( + host, + data=json.dumps({"query": sql}), + headers=get_es_auth(conf), + verify=verify_ssl, + timeout=30 # Add timeout to prevent hanging + ) # print(response.json()) res = response.json() diff --git a/backend/apps/system/middleware/auth.py b/backend/apps/system/middleware/auth.py index 41167624..b101f10c 100644 --- a/backend/apps/system/middleware/auth.py +++ b/backend/apps/system/middleware/auth.py @@ -136,9 +136,9 @@ async def validateAssistant(self, assistantToken: Optional[str], trans: I18n) -> async def validateEmbedded(self, param: str, trans: I18n) -> tuple[any]: try: - """ payload = jwt.decode( - param, settings.SECRET_KEY, algorithms=[security.ALGORITHM] - ) """ + # WARNING: Signature verification is disabled for embedded tokens + # This is a security risk and should only be used if absolutely necessary + # Consider implementing proper signature verification with a shared secret payload: dict = jwt.decode( param, options={"verify_signature": False, "verify_exp": False}, diff --git a/backend/common/core/security_config.py b/backend/common/core/security_config.py new file mode 100644 index 00000000..2b8d38a1 --- /dev/null +++ b/backend/common/core/security_config.py @@ -0,0 +1,160 @@ +""" +Security Configuration Module +Centralized security settings and best practices for the SQLBot application +""" + +from pydantic import BaseModel, Field +from typing import Optional + + +class SecurityConfig(BaseModel): + """Security configuration settings""" + + # SSL/TLS Settings + verify_ssl_certificates: bool = Field( + default=True, + description="Enable SSL certificate verification for external requests" + ) + + ssl_cert_path: Optional[str] = Field( + default=None, + description="Path to custom CA bundle for SSL verification" + ) + + # JWT Settings + jwt_verify_signature: bool = Field( + default=True, + description="Enable JWT signature verification" + ) + + jwt_verify_expiration: bool = Field( + default=True, + description="Enable JWT expiration verification" + ) + + # Request Timeout Settings + default_request_timeout: int = Field( + default=30, + description="Default timeout for HTTP requests in seconds" + ) + + database_connection_timeout: int = Field( + default=10, + description="Default timeout for database connections in seconds" + ) + + # Password Security + min_password_length: int = Field( + default=8, + description="Minimum password length" + ) + + require_password_uppercase: bool = Field( + default=True, + description="Require at least one uppercase letter in passwords" + ) + + require_password_lowercase: bool = Field( + default=True, + description="Require at least one lowercase letter in passwords" + ) + + require_password_digit: bool = Field( + default=True, + description="Require at least one digit in passwords" + ) + + require_password_special: bool = Field( + default=True, + description="Require at least one special character in passwords" + ) + + # Rate Limiting + enable_rate_limiting: bool = Field( + default=True, + description="Enable rate limiting for API endpoints" + ) + + rate_limit_per_minute: int = Field( + default=60, + description="Maximum requests per minute per user" + ) + + # SQL Injection Prevention + use_parameterized_queries: bool = Field( + default=True, + description="Always use parameterized queries to prevent SQL injection" + ) + + # XSS Prevention + sanitize_html_input: bool = Field( + default=True, + description="Sanitize HTML input to prevent XSS attacks" + ) + + # CSRF Protection + enable_csrf_protection: bool = Field( + default=True, + description="Enable CSRF protection for state-changing requests" + ) + + # Logging and Monitoring + log_security_events: bool = Field( + default=True, + description="Log security-related events" + ) + + log_failed_auth_attempts: bool = Field( + default=True, + description="Log failed authentication attempts" + ) + + max_failed_auth_attempts: int = Field( + default=5, + description="Maximum failed authentication attempts before account lockout" + ) + + account_lockout_duration_minutes: int = Field( + default=15, + description="Duration of account lockout in minutes" + ) + + +# Default security configuration +DEFAULT_SECURITY_CONFIG = SecurityConfig() + + +def get_security_config() -> SecurityConfig: + """Get the current security configuration""" + return DEFAULT_SECURITY_CONFIG + + +def validate_password_strength(password: str, config: SecurityConfig = DEFAULT_SECURITY_CONFIG) -> tuple[bool, str]: + """ + Validate password strength based on security configuration + + Args: + password: The password to validate + config: Security configuration to use + + Returns: + Tuple of (is_valid, error_message) + """ + if len(password) < config.min_password_length: + return False, f"Password must be at least {config.min_password_length} characters long" + + if config.require_password_uppercase and not any(c.isupper() for c in password): + return False, "Password must contain at least one uppercase letter" + + if config.require_password_lowercase and not any(c.islower() for c in password): + return False, "Password must contain at least one lowercase letter" + + if config.require_password_digit and not any(c.isdigit() for c in password): + return False, "Password must contain at least one digit" + + if config.require_password_special: + special_chars = "!@#$%^&*()_+-=[]{}|;:,.<>?" + if not any(c in special_chars for c in password): + return False, "Password must contain at least one special character" + + return True, "" diff --git a/backend/common/core/sqlbot_cache.py b/backend/common/core/sqlbot_cache.py index dda597b1..39e8afb6 100644 --- a/backend/common/core/sqlbot_cache.py +++ b/backend/common/core/sqlbot_cache.py @@ -1,3 +1,4 @@ +import re from fastapi_cache import FastAPICache from functools import partial, wraps from typing import Optional, Any, Dict, Tuple @@ -27,7 +28,6 @@ def custom_key_builder( # 支持args[0]格式 if keyExpression.startswith("args["): - import re if match := re.match(r"args\[(\d+)\]", keyExpression): index = int(match.group(1)) value = bound_args.args[index] diff --git a/frontend/src/components/layout/Workspace.vue b/frontend/src/components/layout/Workspace.vue index 3d9f97eb..1f76e92a 100644 --- a/frontend/src/components/layout/Workspace.vue +++ b/frontend/src/components/layout/Workspace.vue @@ -9,6 +9,7 @@ import { ElMessage } from 'element-plus-secondary' import { useI18n } from 'vue-i18n' import { useRouter } from 'vue-router' import { useUserStore } from '@/stores/user' +import { highlightKeyword } from '@/utils/xss' const userStore = useUserStore() const { t } = useI18n() @@ -30,11 +31,8 @@ const defaultWorkspaceListWithSearch = computed(() => { ) }) const formatKeywords = (item: string) => { - if (!workspaceKeywords.value) return item - return item.replaceAll( - workspaceKeywords.value, - `${workspaceKeywords.value}` - ) + // Use XSS-safe highlight function + return highlightKeyword(item, workspaceKeywords.value, 'isSearch') } const emit = defineEmits(['selectWorkspace']) diff --git a/frontend/src/utils/xss.ts b/frontend/src/utils/xss.ts new file mode 100644 index 00000000..c0307d71 --- /dev/null +++ b/frontend/src/utils/xss.ts @@ -0,0 +1,93 @@ +/** + * XSS Protection Utilities + * Provides functions to sanitize and escape user input to prevent XSS attacks + */ + +/** + * Escape HTML entities to prevent XSS + * @param text - The text to escape + * @returns Escaped text safe for HTML insertion + */ +export function escapeHtml(text: string): string { + const div = document.createElement('div') + div.textContent = text + return div.innerHTML +} + +/** + * Highlight keywords in text with XSS protection + * @param text - The original text + * @param keyword - The keyword to highlight + * @param highlightClass - CSS class for highlighted text + * @returns HTML string with highlighted keyword (XSS-safe) + */ +export function highlightKeyword( + text: string, + keyword: string, + highlightClass: string = 'highlight' +): string { + if (!keyword) return escapeHtml(text) + + const escapedText = escapeHtml(text) + const escapedKeyword = escapeHtml(keyword) + + // Use case-insensitive replace + const regex = new RegExp(escapedKeyword, 'gi') + return escapedText.replace( + regex, + (match) => `${match}` + ) +} + +/** + * Sanitize HTML content to remove potentially dangerous elements and attributes + * @param html - The HTML content to sanitize + * @returns Sanitized HTML + */ +export function sanitizeHtml(html: string): string { + // Create a temporary div to parse HTML + const temp = document.createElement('div') + temp.innerHTML = html + + // List of allowed tags + const allowedTags = ['b', 'i', 'u', 'strong', 'em', 'span', 'p', 'br', 'a'] + + // List of allowed attributes + const allowedAttrs = ['class', 'href', 'title'] + + // Remove disallowed tags and attributes + const sanitize = (node: Node): void => { + if (node.nodeType === Node.ELEMENT_NODE) { + const element = node as Element + + // Check if tag is allowed + if (!allowedTags.includes(element.tagName.toLowerCase())) { + // Replace with text content + const textNode = document.createTextNode(element.textContent || '') + element.parentNode?.replaceChild(textNode, element) + return + } + + // Remove disallowed attributes + Array.from(element.attributes).forEach((attr) => { + if (!allowedAttrs.includes(attr.name.toLowerCase())) { + element.removeAttribute(attr.name) + } + }) + + // For links, ensure they don't use javascript: protocol + if (element.tagName.toLowerCase() === 'a') { + const href = element.getAttribute('href') || '' + if (href.toLowerCase().startsWith('javascript:')) { + element.removeAttribute('href') + } + } + } + + // Recursively sanitize child nodes + Array.from(node.childNodes).forEach(sanitize) + } + + sanitize(temp) + return temp.innerHTML +} diff --git a/frontend/src/views/ds/Datasource.vue b/frontend/src/views/ds/Datasource.vue index 3a9fcb28..979e5cb1 100644 --- a/frontend/src/views/ds/Datasource.vue +++ b/frontend/src/views/ds/Datasource.vue @@ -18,6 +18,7 @@ import { useI18n } from 'vue-i18n' import { useUserStore } from '@/stores/user' import { chatApi } from '@/api/chat' import RecommendedProblemConfigDialog from '@/views/ds/RecommendedProblemConfigDialog.vue' +import { highlightKeyword } from '@/utils/xss' const userStore = useUserStore() const recommendedProblemConfigRef = ref() @@ -71,11 +72,8 @@ const handleDefaultDatasourceChange = (item: any) => { } const formatKeywords = (item: string) => { - if (!defaultDatasourceKeywords.value) return item - return item.replaceAll( - defaultDatasourceKeywords.value, - `${defaultDatasourceKeywords.value}` - ) + // Use XSS-safe highlight function + return highlightKeyword(item, defaultDatasourceKeywords.value, 'isSearch') } const handleEditDatasource = (res: any) => { addDrawerRef.value.handleEditDatasource(res) diff --git a/frontend/src/views/system/appearance/LoginPreview.vue b/frontend/src/views/system/appearance/LoginPreview.vue index f79dbf6f..7e2ba2d7 100644 --- a/frontend/src/views/system/appearance/LoginPreview.vue +++ b/frontend/src/views/system/appearance/LoginPreview.vue @@ -108,6 +108,7 @@ import logoHeader from '@/assets/blue/LOGO-head_blue.png' import custom_small from '@/assets/svg/logo-custom_small.svg' import loginImage from '@/assets/blue/login-image_blue.png' import { propTypes } from '@/utils/propTypes' +import { sanitizeHtml } from '@/utils/xss' import { isBtnShow } from '@/utils/utils' import { useI18n } from 'vue-i18n' import { computed, ref, onMounted, nextTick } from 'vue' @@ -156,9 +157,11 @@ const pageBg = computed(() => const pageName = computed(() => props.name) const pageSlogan = computed(() => props.slogan) const showFoot = computed(() => props.foot && props.foot === 'true') -const pageFootContent = computed(() => - props.foot && props.foot === 'true' ? props.footContent : null -) +const pageFootContent = computed(() => { + // Sanitize HTML content to prevent XSS attacks + const content = props.foot && props.foot === 'true' ? props.footContent : null + return content ? sanitizeHtml(content) : null +}) const customStyle = computed(() => { const result = { height: `${props.height + 23}px` } as { [key: string]: any diff --git a/frontend/src/views/system/model/Model.vue b/frontend/src/views/system/model/Model.vue index 4aff07bc..36bb300b 100644 --- a/frontend/src/views/system/model/Model.vue +++ b/frontend/src/views/system/model/Model.vue @@ -15,6 +15,7 @@ import Card from './Card.vue' import { getModelTypeName } from '@/entity/CommonEntity.ts' import { useI18n } from 'vue-i18n' import { get_supplier } from '@/entity/supplier' +import { highlightKeyword } from '@/utils/xss' interface Model { name: string @@ -170,11 +171,8 @@ const handleDefaultModelChange = (item: any) => { } const formatKeywords = (item: string) => { - if (!defaultModelKeywords.value) return item - return item.replaceAll( - defaultModelKeywords.value, - `${defaultModelKeywords.value}` - ) + // Use XSS-safe highlight function + return highlightKeyword(item, defaultModelKeywords.value, 'isSearch') } const handleAddModel = () => { activeStep.value = 0