fix(security): login rate limiting, SQL injection fix, error boundary

This commit is contained in:
matevip 2026-04-11 18:31:51 +08:00
parent 8dbb437e40
commit d3b72929c7
4 changed files with 96 additions and 5 deletions

4
.gitignore vendored
View File

@ -89,3 +89,7 @@ deploy/nginx/ssl/*.pem
# Deploy env
deploy/.env
# Claude Code local settings
.claude/settings.local.json
.claude/plans/

View File

@ -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<String, AtomicInteger> 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();
}
}

View File

@ -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);
};

View File

@ -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')
}