mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-13 03:13:41 +08:00
fix(security): login rate limiting, SQL injection fix, error boundary
This commit is contained in:
parent
8dbb437e40
commit
d3b72929c7
4
.gitignore
vendored
4
.gitignore
vendored
@ -89,3 +89,7 @@ deploy/nginx/ssl/*.pem
|
||||
|
||||
# Deploy env
|
||||
deploy/.env
|
||||
|
||||
# Claude Code local settings
|
||||
.claude/settings.local.json
|
||||
.claude/plans/
|
||||
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
};
|
||||
|
||||
|
||||
@ -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')
|
||||
}
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user