后台列表已经按当前公司过滤。甲登录后只能看到自家两笔订单。点开其中一笔,页面却出现了乙的客户名称。更别扭的是:列表里根本没有的订单号,只要写进地址栏,详情也能打开。
旧文《MyBatis只查两次,为什么还会查到别家客户?》讲的是列表查完以后,批量补客户名称时漏掉租户。这次查的是另一条入口:详情接口只按订单号查,没有再带当前身份。
这组对照跑在本地 H2 上,JDK 21、MyBatis 3.5.19。甲、乙是教学数据,不是某次线上事故。订单号只在租户内部唯一,主键是 (tenant_id, id)。
列表过了,详情还要再过一次
实验数据:
| 租户 | 订单号 | 客户 |
|---|---|---|
| 甲(1) | 1001 | 甲药店 |
| 甲(1) | 1003 | 甲诊所 |
| 乙(2) | 1001 | 乙医院 |
| 乙(2) | 1002 | 乙药房 |
列表查询带了租户:
SELECT id, customer
FROM orders
WHERE tenant_id = ?
ORDER BY id
甲拿到的就是 1001、1003。详情却写成了:
SELECT id, customer
FROM orders
WHERE id = ?
ORDER BY tenant_id DESC
ORDER BY 只是为了让这次实验稳定读到乙,生产代码里未必有这一句。真正的问题是:条件里没有租户。同号 1001 会查出两行;本实验先拿到乙医院。甲列表里没有的 1002,详情同样能返回乙药房。
MyBatis 如果把这条详情当成单条结果,同号两行还会直接报 TooManyResultsException。无论是报错还是碰巧展示第一行,范围都已经错了。本实验故意用 List 接收,再取第一行,方便把串数据这件事钉死。
身份跟参数,不要混着用
补上租户之后,详情变成:
SELECT id, customer
FROM orders
WHERE tenant_id = ?
AND id = ?
甲打开 1001,回到「甲药店」;打开 1002,查不到,返回空。空结果按业务可以做成「订单不存在」,不要改去借乙的数据把页面填满。
租户必须来自服务端已经确认的身份。本例用测试夹具模拟登录后的用户:甲固定是租户 1,乙固定是租户 2。没有认证的请求,列表和详情都不查库。拦截器统计到匿名请求的 SQL 次数为 0。
如果详情接口另外接收 ?tenantId=,而这个值和身份不一致,查询前就应拒绝。两条 SQL 都带租户,却用请求里随便填的数字,只是一起查向别人的范围。
列表过滤不能代替详情过滤。导出、打印、按号查询这些入口,也要按同一套身份来限范围。客户编号如果全局唯一,同号覆盖这个反例未必成立;「查得到这个 ID」和「当前用户有权看它」仍然要分开判断。
最小复现
按下方创建 pom.xml、src/main/java/demo/DetailLeakLab.java,运行:
mvn -q compile exec:java -Dexec.mainClass=demo.DetailLeakLab
预期:
LIST alice=[Order[id=1001, customer=甲药店], Order[id=1003, customer=甲诊所]]
UNSAFE alice/1001 rows=[Order[id=1001, customer=乙医院], Order[id=1001, customer=甲药店]]
UNSAFE alice/1002=Order[id=1002, customer=乙药房]
SAFE alice/1001=Order[id=1001, customer=甲药店]
SAFE alice/1002=null
LIST bob=[Order[id=1001, customer=乙医院], Order[id=1002, customer=乙药房]]
ANONYMOUS denied queries=0
PASS: 7 scenarios
pom.xml:
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>demo</groupId>
<artifactId>wechat-oct-labs</artifactId>
<version>1.0</version>
<properties>
<maven.compiler.release>21</maven.compiler.release>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
</properties>
<dependencies>
<dependency>
<groupId>org.mybatis</groupId>
<artifactId>mybatis</artifactId>
<version>3.5.19</version>
</dependency>
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
<version>2.3.232</version>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<version>3.13.0</version>
</plugin>
<plugin>
<groupId>org.codehaus.mojo</groupId>
<artifactId>exec-maven-plugin</artifactId>
<version>3.5.0</version>
</plugin>
</plugins>
</build>
</project>
src/main/java/demo/DetailLeakLab.java:
package demo;
import java.sql.Statement;
import java.util.List;
import org.apache.ibatis.annotations.Arg;
import org.apache.ibatis.annotations.ConstructorArgs;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import org.apache.ibatis.datasource.unpooled.UnpooledDataSource;
import org.apache.ibatis.executor.statement.StatementHandler;
import org.apache.ibatis.mapping.Environment;
import org.apache.ibatis.plugin.Intercepts;
import org.apache.ibatis.plugin.Interceptor;
import org.apache.ibatis.plugin.Invocation;
import org.apache.ibatis.plugin.Signature;
import org.apache.ibatis.session.Configuration;
import org.apache.ibatis.session.ResultHandler;
import org.apache.ibatis.session.SqlSessionFactory;
import org.apache.ibatis.session.SqlSessionFactoryBuilder;
import org.apache.ibatis.transaction.jdbc.JdbcTransactionFactory;
/**
* 列表按租户过滤,详情只按租户内订单号查询。
* 教学数据:订单号只在租户内唯一。JDK 21 / MyBatis 3.5.19 / H2 2.3.232。
*/
public class DetailLeakLab {
public record Order(int id, String customer) {}
public static final class AuthenticatedUser {
final int tenantId;
private AuthenticatedUser(int tenantId) { this.tenantId = tenantId; }
}
static AuthenticatedUser fixture(String login) {
return switch (login) {
case "alice" -> new AuthenticatedUser(1);
case "bob" -> new AuthenticatedUser(2);
default -> throw new SecurityException("未认证");
};
}
public interface Mapper {
@Select("SELECT id, customer FROM orders WHERE tenant_id=#{tenant} ORDER BY id")
@ConstructorArgs({
@Arg(column = "id", javaType = int.class),
@Arg(column = "customer", javaType = String.class)
})
List<Order> list(@Param("tenant") int tenant);
// 错误对照:详情只认订单号。同号会查出多家;ORDER BY 只为固定本实验先读到乙。
@Select("SELECT id, customer FROM orders WHERE id=#{id} ORDER BY tenant_id DESC")
@ConstructorArgs({
@Arg(column = "id", javaType = int.class),
@Arg(column = "customer", javaType = String.class)
})
List<Order> unsafeDetail(@Param("id") int id);
@Select("SELECT id, customer FROM orders WHERE tenant_id=#{tenant} AND id=#{id}")
@ConstructorArgs({
@Arg(column = "id", javaType = int.class),
@Arg(column = "customer", javaType = String.class)
})
Order safeDetail(@Param("tenant") int tenant, @Param("id") int id);
}
@Intercepts(@Signature(type = StatementHandler.class, method = "query",
args = {Statement.class, ResultHandler.class}))
public static class Counter implements Interceptor {
int count;
public Object intercept(Invocation invocation) throws Throwable {
count++;
return invocation.proceed();
}
}
static class OrderEndpoint {
private final SqlSessionFactory factory;
OrderEndpoint(SqlSessionFactory factory) { this.factory = factory; }
List<Order> list(AuthenticatedUser user) {
if (user == null) throw new SecurityException("未认证");
try (var session = factory.openSession()) {
return session.getMapper(Mapper.class).list(user.tenantId);
}
}
Order detail(AuthenticatedUser user, int id, boolean safe) {
if (user == null) throw new SecurityException("未认证");
try (var session = factory.openSession()) {
var mapper = session.getMapper(Mapper.class);
if (safe) return mapper.safeDetail(user.tenantId, id);
var rows = mapper.unsafeDetail(id);
return rows.isEmpty() ? null : rows.getFirst();
}
}
}
public static void main(String[] args) throws Exception {
var ds = new UnpooledDataSource("org.h2.Driver", "jdbc:h2:mem:detail_leak", "sa", "");
try (var keeper = ds.getConnection(); var sql = keeper.createStatement()) {
sql.execute("""
CREATE TABLE orders(
tenant_id INT,
id INT,
customer VARCHAR(50),
PRIMARY KEY(tenant_id, id)
)
""");
sql.execute("""
INSERT INTO orders VALUES
(1,1001,'甲药店'),
(1,1003,'甲诊所'),
(2,1001,'乙医院'),
(2,1002,'乙药房')
""");
var config = new Configuration(new Environment("lab", new JdbcTransactionFactory(), ds));
var counter = new Counter();
config.addInterceptor(counter);
config.addMapper(Mapper.class);
var factory = new SqlSessionFactoryBuilder().build(config);
var api = new OrderEndpoint(factory);
var alice = fixture("alice");
var bob = fixture("bob");
counter.count = 0;
var aliceList = api.list(alice);
require(aliceList.equals(List.of(new Order(1001, "甲药店"), new Order(1003, "甲诊所")))
&& counter.count == 1, "甲的列表只能有自家订单");
System.out.println("LIST alice=" + aliceList);
counter.count = 0;
List<Order> sameIdRows;
try (var session = factory.openSession()) {
sameIdRows = session.getMapper(Mapper.class).unsafeDetail(1001);
}
var leakSameId = sameIdRows.getFirst();
require(sameIdRows.equals(List.of(new Order(1001, "乙医院"), new Order(1001, "甲药店")))
&& new Order(1001, "乙医院").equals(leakSameId) && counter.count == 1,
"同号详情漏租户会查出两家,页面拿到乙");
System.out.println("UNSAFE alice/1001 rows=" + sameIdRows);
counter.count = 0;
var leakHidden = api.detail(alice, 1002, false);
require(new Order(1002, "乙药房").equals(leakHidden) && counter.count == 1,
"列表没有的乙订单,详情仍能打开");
System.out.println("UNSAFE alice/1002=" + leakHidden);
counter.count = 0;
var safeSame = api.detail(alice, 1001, true);
require(new Order(1001, "甲药店").equals(safeSame) && counter.count == 1, "详情补上租户");
System.out.println("SAFE alice/1001=" + safeSame);
counter.count = 0;
var missing = api.detail(alice, 1002, true);
require(missing == null && counter.count == 1, "甲没有的订单号应查不到");
System.out.println("SAFE alice/1002=null");
counter.count = 0;
var bobList = api.list(bob);
require(bobList.equals(List.of(new Order(1001, "乙医院"), new Order(1002, "乙药房")))
&& counter.count == 1, "乙的列表");
System.out.println("LIST bob=" + bobList);
counter.count = 0;
denied(() -> api.list(null));
denied(() -> api.detail(null, 1001, true));
require(counter.count == 0, "未认证不得查库");
System.out.println("ANONYMOUS denied queries=0");
System.out.println("PASS: 7 scenarios");
}
}
static void denied(Runnable action) {
try { action.run(); } catch (SecurityException expected) { return; }
throw new AssertionError("应该拒绝却成功了");
}
static void require(boolean ok, String message) {
if (!ok) throw new AssertionError(message);
}
}
你们的订单号是全局唯一,还是必须和租户一起才能确定?用列表里有的号、列表里没有的号各点一次详情,比只看列表页更早发现问题。