package com.battery.device.neware; import java.io.PrintWriter; import java.lang.reflect.InvocationTargetException; import java.lang.reflect.Method; import java.lang.reflect.Proxy; import java.sql.CallableStatement; import java.sql.Connection; import java.sql.PreparedStatement; import java.sql.SQLException; import java.sql.SQLFeatureNotSupportedException; import java.sql.Statement; import java.util.List; import java.util.Locale; import java.util.logging.Logger; import javax.sql.DataSource; /** * 厂商数据源的只读防线:在 JDBC 调用到达驱动之前按语句白名单放行。 * *

只允许 {@code SELECT} / {@code SHOW} / {@code DESCRIBE}(含 {@code DESC}); * 其余语句(含存储过程调用)直接抛 {@link NonQueryStatementException},不转发给驱动, * 因此在服务端不产生任何效果。被拒绝的语句记入 {@link ReadOnlyStatementAudit}, * 使"该数据源从未发出过非查询语句"可以被测试断言。 * *

防线是结构性的,不是数学上的:任何绕过本数据源另开连接的代码仍能写。 * 服务端只读账号(见现场对接清单第七节)是第一道防线,本层是第二道。 * *

{@link DataSource} 接口中与只读边界无关的 JDK 样板方法(日志写出器、登录超时、 * 父日志器、{@code unwrap} / {@code isWrapperFor} / {@code toString})直接转发或拒绝驱动对象, * 不承载业务语义,因此不单独加方法注释。 */ public final class ReadOnlyVendorDataSource implements DataSource { private static final List ALLOWED_PREFIXES = List.of("select", "show", "describe", "desc"); private final DataSource delegate; private final String sourceLabel; private final ReadOnlyStatementAudit audit = new ReadOnlyStatementAudit(); public ReadOnlyVendorDataSource(DataSource delegate, String sourceLabel) { if (delegate == null) throw new IllegalArgumentException("delegate data source must not be null"); this.delegate = delegate; this.sourceLabel = sourceLabel == null || sourceLabel.isBlank() ? "vendor" : sourceLabel.trim(); } /** 返回本数据源上的语句审计,用于断言从未发出过非查询语句。 */ public ReadOnlyStatementAudit audit() { return audit; } /** 取一条受只读约束的连接;语句白名单在返回的连接代理上生效。 */ @Override public Connection getConnection() throws SQLException { return guard(delegate.getConnection()); } /** 同上,按给定账号取连接;账号仍受只读白名单约束。 */ @Override public Connection getConnection(String username, String password) throws SQLException { return guard(delegate.getConnection(username, password)); } private Connection guard(Connection connection) { return (Connection) Proxy.newProxyInstance(connection.getClass().getClassLoader(), new Class[] { Connection.class }, (proxy, method, args) -> { switch (method.getName()) { case "prepareStatement" -> { String sql = (String) args[0]; requireQuery(sql); return guardStatement((Statement) invoke(connection, method, args), sql, true); } case "prepareCall" -> throw new NonQueryStatementException( "厂商数据源禁止存储过程调用:" + abbreviate((String) args[0])); case "createStatement" -> { return guardStatement((Statement) invoke(connection, method, args), null, false); } default -> { } } return invoke(connection, method, args); }); } private Statement guardStatement(Statement statement, String preparedSql, boolean prepared) { Class statementType = prepared ? PreparedStatement.class : Statement.class; return (Statement) Proxy.newProxyInstance(statement.getClass().getClassLoader(), new Class[] { statementType }, (proxy, method, args) -> { String sql = sqlArgument(method, args); if (sql != null) requireQuery(sql); return invoke(statement, method, args); }); } /** * 取出本次调用携带的 SQL 文本:语句级方法带 SQL 时按该 SQL 判定, * 预编译语句的无参执行按 prepare 时的 SQL 判定(已在 prepareStatement 中放行)。 */ private static String sqlArgument(Method method, Object[] args) { if (args == null || args.length == 0 || !(args[0] instanceof String sql)) return null; return switch (method.getName()) { case "execute", "executeUpdate", "executeLargeUpdate", "executeQuery", "addBatch", "executeBatch", "executeLargeBatch" -> sql; default -> null; }; } private void requireQuery(String sql) { if (isQuery(sql)) { audit.recordForwarded(sql); return; } audit.recordRejected(sql); throw new NonQueryStatementException("厂商数据源只允许查询语句:" + abbreviate(sql)); } /** 只按语句首关键字判定,避免用正则近似 SQL 语法。 */ static boolean isQuery(String sql) { if (sql == null) return false; String normalized = stripLeadingComments(sql).toLowerCase(Locale.ROOT); if (normalized.isEmpty()) return false; for (String prefix : ALLOWED_PREFIXES) { if (!normalized.startsWith(prefix)) continue; int boundary = prefix.length(); if (normalized.length() == boundary || Character.isWhitespace(normalized.charAt(boundary)) || normalized.charAt(boundary) == '(' || normalized.charAt(boundary) == '*' || normalized.charAt(boundary) == '`') return true; } return false; } private static String stripLeadingComments(String sql) { String remaining = sql.trim(); while (true) { if (remaining.startsWith("/*")) { int end = remaining.indexOf("*/"); if (end < 0) return remaining; remaining = remaining.substring(end + 2).trim(); continue; } if (remaining.startsWith("--") || remaining.startsWith("#")) { int end = remaining.indexOf('\n'); if (end < 0) return ""; remaining = remaining.substring(end + 1).trim(); continue; } return remaining; } } private static String abbreviate(String sql) { if (sql == null) return ""; String trimmed = sql.trim(); return trimmed.length() <= 200 ? trimmed : trimmed.substring(0, 200); } private static Object invoke(Object target, Method method, Object[] args) throws Throwable { try { return method.invoke(target, args); } catch (InvocationTargetException exception) { throw exception.getTargetException(); } } @Override public PrintWriter getLogWriter() throws SQLException { return delegate.getLogWriter(); } @Override public void setLogWriter(PrintWriter out) throws SQLException { delegate.setLogWriter(out); } @Override public void setLoginTimeout(int seconds) throws SQLException { delegate.setLoginTimeout(seconds); } @Override public int getLoginTimeout() throws SQLException { return delegate.getLoginTimeout(); } @Override public Logger getParentLogger() throws SQLFeatureNotSupportedException { return delegate.getParentLogger(); } @Override public T unwrap(Class iface) throws SQLException { if (iface.isInstance(this)) return iface.cast(this); throw new SQLFeatureNotSupportedException("厂商数据源不暴露未受约束的驱动对象"); } @Override public boolean isWrapperFor(Class iface) { return iface.isInstance(this); } @Override public String toString() { return "ReadOnlyVendorDataSource[" + sourceLabel + "]"; } /** 便于测试与排查的来源说明。 */ public String sourceLabel() { return sourceLabel; } }