diff --git a/.gitignore b/.gitignore index 3078e3e4..ee0f6448 100644 --- a/.gitignore +++ b/.gitignore @@ -89,3 +89,7 @@ deploy/nginx/ssl/*.pem # Deploy env deploy/.env + +# Claude Code local settings +.claude/settings.local.json +.claude/plans/ diff --git a/mateclaw-server/src/main/java/vip/mate/config/LoginRateLimitFilter.java b/mateclaw-server/src/main/java/vip/mate/config/LoginRateLimitFilter.java new file mode 100644 index 00000000..a56a32ce --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/config/LoginRateLimitFilter.java @@ -0,0 +1,68 @@ +package vip.mate.config; + +import com.github.benmanes.caffeine.cache.Cache; +import com.github.benmanes.caffeine.cache.Caffeine; +import jakarta.servlet.*; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +import java.io.IOException; +import java.time.Duration; +import java.util.concurrent.atomic.AtomicInteger; + +/** + * Rate limiter for login endpoint — prevents brute force attacks. + * Allows max 5 login attempts per IP per minute. + * + * @author MateClaw Team + */ +@Slf4j +@Component +public class LoginRateLimitFilter implements Filter { + + private static final int MAX_ATTEMPTS = 5; + private static final String LOGIN_PATH = "/api/v1/auth/login"; + + /** IP → attempt count, auto-expires after 1 minute */ + private final Cache attempts = Caffeine.newBuilder() + .expireAfterWrite(Duration.ofMinutes(1)) + .maximumSize(10_000) + .build(); + + @Override + public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) + throws IOException, ServletException { + HttpServletRequest httpReq = (HttpServletRequest) request; + + if ("POST".equalsIgnoreCase(httpReq.getMethod()) && LOGIN_PATH.equals(httpReq.getRequestURI())) { + String ip = getClientIp(httpReq); + AtomicInteger count = attempts.get(ip, k -> new AtomicInteger(0)); + int current = count.incrementAndGet(); + + if (current > MAX_ATTEMPTS) { + log.warn("[RateLimit] Login rate limit exceeded for IP: {} (attempts: {})", ip, current); + HttpServletResponse httpResp = (HttpServletResponse) response; + httpResp.setStatus(429); + httpResp.setContentType("application/json;charset=UTF-8"); + httpResp.getWriter().write("{\"code\":429,\"msg\":\"Too many login attempts, please try again later\",\"data\":null}"); + return; + } + } + + chain.doFilter(request, response); + } + + private static String getClientIp(HttpServletRequest request) { + String xff = request.getHeader("X-Forwarded-For"); + if (xff != null && !xff.isEmpty()) { + return xff.split(",")[0].trim(); + } + String realIp = request.getHeader("X-Real-IP"); + if (realIp != null && !realIp.isEmpty()) { + return realIp; + } + return request.getRemoteAddr(); + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/tool/builtin/DatasourceTool.java b/mateclaw-server/src/main/java/vip/mate/tool/builtin/DatasourceTool.java index 64ae98cf..069510a6 100644 --- a/mateclaw-server/src/main/java/vip/mate/tool/builtin/DatasourceTool.java +++ b/mateclaw-server/src/main/java/vip/mate/tool/builtin/DatasourceTool.java @@ -15,6 +15,7 @@ import vip.mate.datasource.service.DatasourceService; import java.sql.*; import java.util.ArrayList; import java.util.List; +import java.util.regex.Pattern; /** * 内置工具:数据源发现 @@ -32,6 +33,16 @@ public class DatasourceTool { private final DatasourceService datasourceService; private final DatasourceConnectionManager connectionManager; + /** SQL identifier whitelist: letters, digits, underscore, dot, hyphen only */ + private static final Pattern SAFE_IDENTIFIER = Pattern.compile("^[a-zA-Z0-9_][a-zA-Z0-9_.\\-]{0,127}$"); + + private static String sanitizeIdentifier(String name) { + if (name == null || !SAFE_IDENTIFIER.matcher(name).matches()) { + throw new IllegalArgumentException("Invalid SQL identifier: " + name); + } + return name; + } + @Tool(description = """ 查询外部数据源的元数据。支持三种动作: 1. action='list_datasources' — 列出所有可用数据源(无需其他参数) @@ -84,12 +95,12 @@ public class DatasourceTool { String sql = switch (dbType) { case "mysql", "mariadb" -> String.format( "SELECT TABLE_NAME, TABLE_COMMENT, TABLE_ROWS FROM information_schema.TABLES WHERE TABLE_SCHEMA = '%s' ORDER BY TABLE_NAME", - entity.getDatabaseName()); + sanitizeIdentifier(entity.getDatabaseName())); case "postgresql" -> String.format( "SELECT tablename AS table_name, obj_description(c.oid) AS table_comment " + "FROM pg_tables t LEFT JOIN pg_class c ON c.relname = t.tablename " + "WHERE t.schemaname = '%s' ORDER BY tablename", - entity.getSchemaName() != null ? entity.getSchemaName() : "public"); + sanitizeIdentifier(entity.getSchemaName() != null ? entity.getSchemaName() : "public")); case "clickhouse" -> "SHOW TABLES"; default -> throw new IllegalArgumentException("不支持的数据库类型: " + dbType); }; @@ -113,7 +124,7 @@ public class DatasourceTool { case "mysql", "mariadb" -> String.format( "SELECT COLUMN_NAME, COLUMN_TYPE, IS_NULLABLE, COLUMN_KEY, COLUMN_DEFAULT, COLUMN_COMMENT " + "FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = '%s' AND TABLE_NAME = '%s' ORDER BY ORDINAL_POSITION", - entity.getDatabaseName(), tableName); + sanitizeIdentifier(entity.getDatabaseName()), sanitizeIdentifier(tableName)); case "postgresql" -> String.format( "SELECT c.column_name, c.data_type, c.is_nullable, " + "CASE WHEN pk.column_name IS NOT NULL THEN 'PRI' ELSE '' END AS column_key, " + @@ -125,8 +136,10 @@ public class DatasourceTool { "LEFT JOIN pg_catalog.pg_statio_all_tables st ON st.relname = c.table_name " + "LEFT JOIN pg_catalog.pg_description pgd ON pgd.objoid = st.relid AND pgd.objsubid = c.ordinal_position " + "WHERE c.table_schema = '%s' AND c.table_name = '%s' ORDER BY c.ordinal_position", - tableName, entity.getSchemaName() != null ? entity.getSchemaName() : "public", tableName); - case "clickhouse" -> String.format("DESCRIBE TABLE %s", tableName); + sanitizeIdentifier(tableName), + sanitizeIdentifier(entity.getSchemaName() != null ? entity.getSchemaName() : "public"), + sanitizeIdentifier(tableName)); + case "clickhouse" -> String.format("DESCRIBE TABLE %s", sanitizeIdentifier(tableName)); default -> throw new IllegalArgumentException("不支持的数据库类型: " + dbType); }; diff --git a/mateclaw-ui/src/main.ts b/mateclaw-ui/src/main.ts index dd173170..ae390256 100644 --- a/mateclaw-ui/src/main.ts +++ b/mateclaw-ui/src/main.ts @@ -22,6 +22,12 @@ async function bootstrap() { app.use(router) app.use(i18n) app.use(ElementPlus) + + // Global error handler — prevents uncaught Vue errors from causing white screens + app.config.errorHandler = (err, instance, info) => { + console.error('[Vue Error]', info, err) + } + app.mount('#app') }