列表是自家的,点进去为什么能看到别家客户?

2 阅读1分钟

后台列表已经按当前公司过滤。甲登录后只能看到自家两笔订单。点开其中一笔,页面却出现了乙的客户名称。更别扭的是:列表里根本没有的订单号,只要写进地址栏,详情也能打开。

旧文《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);
    }
}

你们的订单号是全局唯一,还是必须和租户一起才能确定?用列表里有的号、列表里没有的号各点一次详情,比只看列表页更早发现问题。