qinghuan
← 回到知识探索

后端现场 / 可下载小项目

对象流过滤器写了允许条件,为什么其他类仍能读进来?

用自生成内存字节观察 UNDECIDED、空类回调、数组长度和直接字符串,把类型规则与内容校验分清。

展开本文目录
  1. 下载完整练习包
  2. 先动手看看
  3. 先用两个只含整数的类建立对照
  4. UNDECIDED 会继续,严格包装才拒绝这个普通类
  5. serialClass 为 null,可能仍有指标需要检查
  6. 数组长度零、未知和超过上限不是同一情况
  7. 直接写入的 String,拒绝回调一次也没有运行
  8. 用观察记录说明检查了什么,也说明没检查什么
  9. 运行单文件,逐项对照真实输出
  10. 附加实验:同样六个样本,交给四种策略逐格观察
  11. 记住这三点
  12. 别把例子用过头
  13. 自己试一小步
  14. 查原始资料
开始前,会这些就够了
  • 知道 Serializable、对象字段,以及 ObjectOutputStream 写入和 ObjectInputStream 读回的基本用法。
  • 先读:反序列化会重新调用构造器吗?,区分对象恢复和业务字段校验。
  • 使用完整 JDK 17 或更新版本;本实验只读取自己刚生成的内存字节,不需要文件、网络或第三方库。
遇到不熟悉的词,先看这 2 个解释
过滤未决状态
这次检查没有作出允许或拒绝判断。
无新类型的指标回调
没有新类需要判断时,过滤器仍可能收到深度、引用数和已读字节等信息。
打开完整术语小抄 ↗

你写了一个规则:Allowed.class 返回允许,其他类型返回 UNDECIDED。名字看起来像“只允许 Allowed”,结果 Other 也被读出来了。问题在于 UNDECIDED 表示这次检查没有作出判断,并不是拒绝。

下面所有样本都由当前程序先写成内存字节,再从新建输入流读回。我们同时检查读回的对象和过滤回调记录,以免“得到结果”被误解为“某个条件一定被检查过”。

把例子带到自己的电脑上

下载完整练习包

需要 JDK 17 或更新版本,使用 Java 标准库;制作时实测 Windows / JDK 17.0.20.1 / 21.0.12。附加矩阵程序也只用Java标准库,已独立实测JDK17/21。下方命令运行基础 Java 标准库示例;附加对照的命令见对应 README。下载 ZIP 后选择“全部提取”,进入能看到 README.md 的文件夹。

下载 ZIP · 13 个文件
包里有什么
  • ObjectFilterDemo.java:完整可运行示例
  • README.md:运行说明与独立练习
  • observed.stdout.txt:本次实际输出
  • verification.json:源码和产物验证记录
  • SHA256SUMS.txt:内容摘要
  • filter-evidence/FilterMatrixDemo.java:24格附加实验源码
  • filter-evidence/README.md:附加运行方法与观察边界
  • filter-evidence/matrix.json:双JDK一致的实际矩阵
  • filter-evidence/matrix-jdk17.json / matrix-jdk21.json:分版本实际输出
  • filter-evidence/validation.json / SHA256SUMS.txt:制作验证与文件摘要
  • supplement-verification.json:附加实现的独立复跑记录

在这个文件夹打开 PowerShell,逐行运行

java -version
javac -version
javac -encoding UTF-8 --release 17 ObjectFilterDemo.java
java -cp . ObjectFilterDemo

解压到独立练习文件夹,在含 ObjectFilterDemo.java 的目录打开终端运行。基础 ObjectFilterDemo 在 JDK17/21 各21项检查;附加 FilterMatrixDemo 在两版各105项检查,包括6个固定自产样本×4个策略的24格实际结果、回调类型/长度/状态、读回值和直接String零回调。

两份程序独立计数;没有外部输入或浏览器Java模拟。 附加实验进入 filter-evidence 目录,先执行 javac -encoding UTF-8 --release 17 FilterMatrixDemo.java,再执行 java -cp . FilterMatrixDemo。矩阵JSON是固定实际记录。

找不到命令或文件时,先按 README 检查环境和所在目录。文中标出的阅读时间只估算文章篇幅,安装环境和动手练习可按自己的节奏进行。

六种样本遇到四种策略,Java 实际记录了什么?

这里展示固定 Java 程序已经运行得到的 24 格观察记录。选择样本与策略,只会切换对应记录;浏览器没有执行或模拟 Java 反序列化。

Allowed.class 得到 ALLOWED,其他非空类型得到 UNDECIDED;未决不是拒绝。

预设样本:Other(7)。实际结果:读回成功。

读回类型:Other;读回值:7。

对象流调用外层过滤器 1 次。下面按实际发生顺序列出每次回调与最终策略状态。

实际回调顺序;组合器内部调用不重复计作对象流回调
次序class(简名)arrayLength最终状态
1Other-1UNDECIDED

arrayLength 为 −1 表示这次回调没有可用数组长度。数组可能先出现 int[] / −1 的类型事件,再出现长度为 3 或 5 的数组事件。被拒绝时没有完整读回对象,不把已知输入类型填成读回结果。

查看这格原始 JSON 记录
{
  "sampleId": "other-7",
  "sampleLabel": "Other(7)",
  "policyId": "loose",
  "policyLabel": "Allowed -> ALLOWED; others -> UNDECIDED",
  "outcome": "read",
  "observedType": "Other",
  "observedValue": 7,
  "exceptionType": null,
  "callbacks": [
    {
      "class": "Other",
      "arrayLength": -1,
      "status": "UNDECIDED"
    }
  ]
}
展开完整 24 格实际矩阵
每格同时显示结果和实际回调次数
固定样本允许 Allowed,其余未决(loose)拒绝未决类(strict)只限制数组最多 4 个元素组合 strict 与数组上限
Allowed(9)读回 · 1 次回调读回 · 1 次回调读回 · 1 次回调读回 · 1 次回调
Allowed(-9)读回 · 1 次回调读回 · 1 次回调读回 · 1 次回调读回 · 1 次回调
Other(7)读回 · 1 次回调拒绝 · 1 次回调读回 · 1 次回调拒绝 · 1 次回调
int[3] = [1,2,3]读回 · 2 次回调读回 · 2 次回调读回 · 2 次回调读回 · 2 次回调
int[5] = [1,2,3,4,5]读回 · 2 次回调读回 · 2 次回调拒绝 · 2 次回调拒绝 · 2 次回调
String own-text读回 · 0 次回调读回 · 0 次回调读回 · 0 次回调读回 · 0 次回调

实测日期:2026-10-18。Microsoft OpenJDK 17.0.20.1 与 Microsoft OpenJDK 21.0.12 使用同一源码,以 --release 17 编译运行,并在独立目录复跑;两版 JSON 一致。附加程序每版 105 项检查,其中 24 项核对矩阵结果。原文基础程序的 21 项检查单独计数。

全部字节由程序自己构造的六个样本产生。这组记录不覆盖任意对象图、外部字节、全局过滤器工厂或完整资源策略;共享引用与 class 为 null 的情况仍见正文的基础实验。

先用两个只含整数的类建立对照

Allowed 和 Other 是两个独立 Serializable 类,都只含一个 int。每个样本建立独立内存对象流;Trace 包装实际过滤器,保存此次回调的类型、数组长度、深度、引用数、已消耗字节数和返回状态。

decodeOwn 在 readObject 之前设置过滤器,并核对实际流过滤器就是所请求的对象。自定义全局过滤器工厂可能替换或组合规则;若发生这种情况,本实验明确停止,而不是继续假定运行环境仍与示例一致。它不修改全局过滤配置。

Trace 是观察工具,不读取业务字段,也不会把类型对象传成业务对象。后面通过不同 Trace 实例隔离每个样本,避免上一条流留下的计数混进下一条结论。

UNDECIDED 会继续,严格包装才拒绝这个普通类

loose 使用 allowFilter:Allowed 返回 ALLOWED,其他非空类型返回 UNDECIDED。读 Other(7) 后,程序既检查 value 仍为七,也检查回调确实见到 Other.class 和 UNDECIDED,证明它不是跳过了类型回调。

strict 用 rejectUndecidedClass 包装 loose。对完全相同的 Other 字节,回调结果变为 REJECTED,readObject 抛 InvalidClassException;Allowed(9) 仍能读回。我们检查异常类型和拒绝记录,避免把其他读流失败也当作过滤规则正确。

另外,Allowed(-9) 同样通过这条类型规则。这个类并未规定金额不能为负,过滤器也没有读取 value。输出 business_value 单独保留负九,提醒你类型获得允许并不能证明字段满足某项业务限制。

serialClass 为 null,可能仍有指标需要检查

程序把同一个 Allowed 对象放进数组两次。读回后 pair[0] == pair[1],说明共享引用被保留;回调记录中也出现 serialClass 为 null、arrayLength 为 -1 的事件。这里没有一个新的类需要判断,但仍能检查对象图的深度、引用和读入量。

示例过滤器先处理 type == null,再判断 Object[].class 和 Allowed.class。若直接调用 type.getName,就可能在这类合法回调中抛异常。返回值必须是 Status;serialClass 为 null 和“过滤器返回 null”是两件事,后者会使当前读取失败。

空类事件的指标检查只确认深度、引用数和字节数可用,不硬编码字节偏移。FilterInfo 文档说明 streamBytes 计数与实现有关,不能把某次回调的数值直接解释为当前对象的精确序列化大小。

数组长度零、未知和超过上限不是同一情况

maxFour 只检查 arrayLength > 4。程序分别写出长度零、三、四、五的 int 数组:前三种可以读回,五被拒绝。除了读回长度或异常,Trace 还核对真正带这些非负长度的数组回调;零长度与非数组事件的 -1 分开处理。

数组检查面对的是 int[].class 和实际长度。它不是对每个 int 元素单独调用类型过滤,也不会因为长度允许就验证数组内容。长度五的样本只是小数组,不需要制造大分配就能检验 > 4 的边界。

再将长度三的 int 数组交给前述 strict,仍得到 UNDECIDED 并成功读回。rejectUndecidedClass 对基本类型数组有专门规则,所以不能把它概括为“任何未决事件全部拒绝”。真正的数组长度上限应明确写出来;本文没有把这几个分离实验拼成一套完整生产规则。

直接写入的 String,拒绝回调一次也没有运行

最后只写入字符串 own-text,并安装一个只要被调用就返回 REJECTED 的 Trace。结果字符串仍然读出,Trace.events 却为空。程序既核对内容,也核对零次回调,这才支持“这个路径没有调用过滤器”的结论。

ObjectInputStream 对流中直接编码的 String 有明确特殊处理。这里没有说对象图里出现字符串就绕过所有过滤:外层普通对象、数组和重复引用仍各有自己的检查规则。实验只覆盖独立直接字符串样本。

因此,如果业务要求文本长度、格式或允许字符,应在合适的内容处理边界实现并测试这些规则,不能以为一个拒绝类型的回调已经审查过文本。类型、资源量和业务值是三个需要分别举证的问题。

用观察记录说明检查了什么,也说明没检查什么

21 项检查将普通类状态、读回字段、共享引用和空类事件、数组四个边界、基本类型数组特殊规则、直接字符串零回调分开验证。Windows 上两套 JDK 的输出一致,但程序没有依赖每条流的总回调次数或某个固定字节偏移。

所有输入都是当前程序自产的内存字节,示例没有接收外部文件,也不修改序列化二进制内容。它不实现自定义全局过滤器工厂,没有测试复杂类继承、代理、readResolve 或多层对象图资源限制。

已有构造路径文章讨论恢复后在哪里验证字段;本文补的是过滤器到底作出了哪种判断。理解这两个入口有助于设计检查,但这组小实验不构成一个外部反序列化接口的完整验收。

运行单文件,逐项对照真实输出

保存为 ObjectFilterDemo.java,在源码目录逐行运行下面命令。需要包含 javac 的完整 JDK 17 或更新版本;无需 Maven、网络或第三方依赖。

2026 年 10 月 18 日,已在 Windows 上分别使用 Microsoft OpenJDK 17.0.20.1 与 21.0.12,以 --release 17 在新建临时目录编译运行。下面直接保存两次一致的真实输出;断言失败不会继续打印 PASS。

展开收起代码 · 小实验java · 127 行

长行可在代码区域内左右滚动,按 Tab 可把焦点移入代码区。

小实验 · java
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InvalidClassException;
import java.io.ObjectInputFilter;
import java.io.ObjectInputStream;
import java.io.ObjectOutputStream;
import java.io.Serial;
import java.io.Serializable;
import java.util.ArrayList;
import java.util.List;

public class ObjectFilterDemo {
    private static int checks;
    static final class Allowed implements Serializable {
        @Serial private static final long serialVersionUID = 1L;
        final int value;
        Allowed(int value) { this.value = value; }
    }
    static final class Other implements Serializable {
        @Serial private static final long serialVersionUID = 1L;
        final int value;
        Other(int value) { this.value = value; }
    }
    record Event(Class<?> type, long length, long depth, long refs, long bytes,
                 ObjectInputFilter.Status status) {}

    static final class Trace implements ObjectInputFilter {
        final ObjectInputFilter delegate;
        final List<Event> events = new ArrayList<>();
        Trace(ObjectInputFilter delegate) { this.delegate = delegate; }
        @Override public Status checkInput(FilterInfo info) {
            Status status = delegate.checkInput(info);
            events.add(new Event(info.serialClass(), info.arrayLength(), info.depth(),
                    info.references(), info.streamBytes(), status));
            return status;
        }
        boolean saw(Class<?> type, long length, Status status) {
            return events.stream().anyMatch(e -> e.type() == type && e.length() == length && e.status() == status);
        }
    }
    private static void check(boolean ok, String message) {
        if (!ok) throw new AssertionError(message);
        checks++;
    }
    private static byte[] encodeOwn(Serializable value) throws IOException {
        ByteArrayOutputStream bytes = new ByteArrayOutputStream();
        try (ObjectOutputStream output = new ObjectOutputStream(bytes)) { output.writeObject(value); }
        return bytes.toByteArray();
    }
    private static Object decodeOwn(byte[] bytes, ObjectInputFilter filter) throws IOException, ClassNotFoundException {
        try (ObjectInputStream input = new ObjectInputStream(new ByteArrayInputStream(bytes))) {
            input.setObjectInputFilter(filter);
            // Do not silently run this isolated experiment under a custom filter factory.
            if (input.getObjectInputFilter() != filter)
                throw new IllegalStateException("experiment requires the requested stream filter");
            return input.readObject();
        }
    }
    private static boolean rejected(byte[] bytes, ObjectInputFilter filter) throws Exception {
        try { decodeOwn(bytes, filter); return false; }
        catch (InvalidClassException expected) { return true; }
    }

    public static void main(String[] args) throws Exception {
        ObjectInputFilter loose = ObjectInputFilter.allowFilter(
                type -> type == Allowed.class, ObjectInputFilter.Status.UNDECIDED);
        byte[] otherBytes = encodeOwn(new Other(7));
        Trace looseTrace = new Trace(loose);
        Other other = (Other) decodeOwn(otherBytes, looseTrace);
        check(other.value == 7, "undecided Other still restored");
        check(looseTrace.saw(Other.class, -1, ObjectInputFilter.Status.UNDECIDED), "Other class was actually checked");
        ObjectInputFilter strict = ObjectInputFilter.rejectUndecidedClass(loose);
        Trace strictTrace = new Trace(strict);
        check(rejected(otherBytes, strictTrace), "undecided ordinary class now rejected");
        check(strictTrace.saw(Other.class, -1, ObjectInputFilter.Status.REJECTED), "record rejected status");
        Allowed allowed = (Allowed) decodeOwn(encodeOwn(new Allowed(9)), strict);
        check(allowed.value == 9, "explicitly allowed class succeeds");
        System.out.println("classes: Other+UNDECIDED=read; Other+strict=InvalidClassException; Allowed+strict=read");
        Allowed negative = (Allowed) decodeOwn(encodeOwn(new Allowed(-9)), strict);
        check(negative.value == -9, "type-only rule does not validate the integer field");
        System.out.println("business_value: Allowed.value=-9; type_filter=read");

        // An existing reference triggers a metrics event with no new class to check.
        Allowed shared = new Allowed(11);
        Trace references = new Trace(info -> {
            Class<?> type = info.serialClass();
            if (type == null) return ObjectInputFilter.Status.UNDECIDED;
            return type == Object[].class || type == Allowed.class
                    ? ObjectInputFilter.Status.ALLOWED : ObjectInputFilter.Status.REJECTED;
        });
        Object[] pair = (Object[]) decodeOwn(encodeOwn(new Object[] {shared, shared}), references);
        check(pair.length == 2 && pair[0] == pair[1], "shared reference restored as shared");
        check(references.saw(null, -1, ObjectInputFilter.Status.UNDECIDED), "null class metrics event exists");
        check(references.events.stream().filter(e -> e.type() == null)
                .allMatch(e -> e.depth() >= 1 && e.refs() >= 1 && e.bytes() >= 0), "metrics available without a class");
        System.out.println("references: shared_identity=true; serialClass_null=true; arrayLength=-1; metrics_present=true");

        ObjectInputFilter maxFour = info -> info.arrayLength() > 4
                ? ObjectInputFilter.Status.REJECTED : ObjectInputFilter.Status.UNDECIDED;
        Trace small = new Trace(maxFour);
        int[] three = (int[]) decodeOwn(encodeOwn(new int[] {1, 2, 3}), small);
        check(three.length == 3 && three[2] == 3, "small primitive array restored");
        check(small.saw(int[].class, 3, ObjectInputFilter.Status.UNDECIDED), "actual length-three event");
        Trace emptyTrace = new Trace(maxFour);
        check(((int[]) decodeOwn(encodeOwn(new int[0]), emptyTrace)).length == 0, "empty array accepted");
        check(emptyTrace.saw(int[].class, 0, ObjectInputFilter.Status.UNDECIDED), "zero length is not minus one");
        Trace boundary = new Trace(maxFour);
        check(((int[]) decodeOwn(encodeOwn(new int[4]), boundary)).length == 4, "exact maximum accepted");
        check(boundary.saw(int[].class, 4, ObjectInputFilter.Status.UNDECIDED), "actual length-four event");
        Trace large = new Trace(maxFour);
        check(rejected(encodeOwn(new int[5]), large), "length-five array rejected");
        check(large.saw(int[].class, 5, ObjectInputFilter.Status.REJECTED), "actual length-five rejection event");
        Trace primitiveStrict = new Trace(strict);
        check(((int[]) decodeOwn(encodeOwn(new int[3]), primitiveStrict)).length == 3,
                "rejectUndecidedClass still permits undecided primitive arrays");
        check(primitiveStrict.saw(int[].class, 3, ObjectInputFilter.Status.UNDECIDED), "primitive array special case recorded");
        System.out.println("arrays: length0_3_4=read; length5=InvalidClassException; primitive_array_under_strict=UNDECIDED_read");

        Trace stringTrace = new Trace(info -> ObjectInputFilter.Status.REJECTED);
        Object text = decodeOwn(encodeOwn("own-text"), stringTrace);
        check("own-text".equals(text), "direct String restored");
        check(stringTrace.events.isEmpty(), "direct String invokes no filter callback");
        System.out.println("direct_string: value=own-text; reject_all_callback_calls=0; read=true");
        System.out.println("PASS checks=" + checks + "; input=program_generated_byte_arrays_only");
    }
}

怎样运行:在源码所在目录,先单独执行 javac -encoding UTF-8 --release 17 ObjectFilterDemo.java;编译成功后,再执行 java -cp . ObjectFilterDemo。两条命令分别回车。2026-10-18 已在 Windows、JDK 17.0.20.1 / 21.0.12 编译运行此份源码并通过检查。Microsoft OpenJDK17/21各21项检查;自产内存流,普通类UNDECIDED/REJECTED与业务字段、serialClass=null指标事件、共享身份、数组0/3/4/5边界、基本类型数组特例、直接String零回调。未测外部输入、完整资源策略或自定义全局过滤工厂。

运行后对照结果
classes: Other+UNDECIDED=read; Other+strict=InvalidClassException; Allowed+strict=read
business_value: Allowed.value=-9; type_filter=read
references: shared_identity=true; serialClass_null=true; arrayLength=-1; metrics_present=true
arrays: length0_3_4=read; length5=InvalidClassException; primitive_array_under_strict=UNDECIDED_read
direct_string: value=own-text; reject_all_callback_calls=0; read=true
PASS checks=21; input=program_generated_byte_arrays_only

附加实验:同样六个样本,交给四种策略逐格观察

上面的 ObjectFilterDemo 仍是基础实验,21项检查不变。下面另附 FilterMatrixDemo:六个固定样本逐一搭配四种策略,保存24格真实读取结果、返回值和回调顺序。浏览器交互只展示这些实际记录,不在JavaScript里模拟Java反序列化。

loose只允许Allowed,其余返回UNDECIDED;strict用rejectUndecidedClass包装它;maxArray4只拒绝超过四个元素的数组;merge将strict与maxArray4组合。每格建立新的对象流,核对请求的过滤器确实安装,并且只读本程序自产的内存字节。

两版JDK的24格输出一致。每版附加程序通过105项检查,其中24项是逐格结果;其他检查覆盖实际类型和值、拒绝回调、字符串零回调、数组长度和完整性。这个数字独立于原程序的21项,不把两种验证混成一个计数。

被拒绝时没有完整读回对象,observedType与observedValue留空。数组可能先出现class为int[]但arrayLength为-1的类型事件,再出现实际长度事件;回调表保留二者。这里未新增共享引用样本,serialClass为null的场景仍见基础实验。

  • Allowed(9)与Allowed(-9):四种策略均读回,说明类型允许没有代替业务值检查。
  • Other(7):loose和maxArray4读回;strict与merge拒绝。
  • int[3]:四种策略均读回;int[5]:loose和strict读回,maxArray4和merge拒绝。
  • 直接String own-text:四种策略均读回,过滤回调都是零次。
展开收起代码 · 小实验java · 216 行

长行可在代码区域内左右滚动,按 Tab 可把焦点移入代码区。

小实验 · java
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InvalidClassException;
import java.io.ObjectInputFilter;
import java.io.ObjectInputStream;
import java.io.ObjectOutputStream;
import java.io.Serial;
import java.io.Serializable;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;

/** Fixed local observations, not an input service or a browser simulation. */
public class FilterMatrixDemo {
    private static int checks;
    private static int outcomeChecks;

    static final class Allowed implements Serializable {
        @Serial private static final long serialVersionUID = 1L;
        final int value;
        Allowed(int value) { this.value = value; }
    }
    static final class Other implements Serializable {
        @Serial private static final long serialVersionUID = 1L;
        final int value;
        Other(int value) { this.value = value; }
    }
    record Sample(String id, String label, Serializable value) {}
    record Policy(String id, String label, ObjectInputFilter filter) {}
    record Callback(String className, long arrayLength, String status) {}
    record Cell(String sampleId, String sampleLabel, String policyId, String policyLabel,
                String outcome, String observedType, Object observedValue,
                String exceptionType, List<Callback> callbacks) {}

    static final class Trace implements ObjectInputFilter {
        final ObjectInputFilter delegate;
        final List<Callback> callbacks = new ArrayList<>();
        Trace(ObjectInputFilter delegate) { this.delegate = delegate; }
        @Override public Status checkInput(FilterInfo info) {
            Status status = delegate.checkInput(info);
            Class<?> type = info.serialClass();
            callbacks.add(new Callback(type == null ? null : type.getSimpleName(),
                    info.arrayLength(), status.name()));
            return status;
        }
    }

    private static void check(boolean condition, String message) {
        if (!condition) throw new AssertionError(message);
        checks++;
    }
    private static byte[] encodeOwn(Serializable value) throws IOException {
        ByteArrayOutputStream bytes = new ByteArrayOutputStream();
        try (ObjectOutputStream output = new ObjectOutputStream(bytes)) { output.writeObject(value); }
        return bytes.toByteArray();
    }
    private static Object valueOf(Object object) {
        if (object instanceof Allowed a) return a.value;
        if (object instanceof Other o) return o.value;
        if (object instanceof int[] array) return array.clone();
        if (object instanceof String text) return text;
        throw new AssertionError("unexpected observed type");
    }
    private static boolean sameValue(Object actual, Object expected) {
        if (actual instanceof int[] a && expected instanceof int[] e) return Arrays.equals(a, e);
        return actual.equals(expected);
    }

    private static Cell observe(Sample sample, Policy policy) throws Exception {
        // Every cell has a new byte stream and Trace; no callbacks leak from other cells.
        byte[] bytes = encodeOwn(sample.value());
        Trace trace = new Trace(policy.filter());
        try (ObjectInputStream input = new ObjectInputStream(new ByteArrayInputStream(bytes))) {
            input.setObjectInputFilter(trace);
            check(input.getObjectInputFilter() == trace,
                    "requested stream filter was replaced: " + sample.id() + "/" + policy.id());
            try {
                Object restored = input.readObject();
                return new Cell(sample.id(), sample.label(), policy.id(), policy.label(),
                        "read", restored.getClass().getSimpleName(), valueOf(restored), null,
                        List.copyOf(trace.callbacks));
            } catch (InvalidClassException rejected) {
                return new Cell(sample.id(), sample.label(), policy.id(), policy.label(),
                        "rejected", null, null, rejected.getClass().getSimpleName(),
                        List.copyOf(trace.callbacks));
            }
        }
    }

    private static String quote(String text) {
        if (text == null) return "null";
        StringBuilder out = new StringBuilder("\"");
        for (int i = 0; i < text.length(); i++) {
            char ch = text.charAt(i);
            switch (ch) {
                case '"' -> out.append("\\\"");
                case '\\' -> out.append("\\\\");
                case '\b' -> out.append("\\b");
                case '\f' -> out.append("\\f");
                case '\n' -> out.append("\\n");
                case '\r' -> out.append("\\r");
                case '\t' -> out.append("\\t");
                default -> {
                    if (ch < 0x20) {
                        String hex = Integer.toHexString(ch);
                        out.append("\\u").append("0".repeat(4 - hex.length())).append(hex);
                    } else out.append(ch);
                }
            }
        }
        return out.append('"').toString();
    }
    private static String jsonValue(Object value) {
        if (value == null) return "null";
        if (value instanceof Integer number) return number.toString();
        if (value instanceof String text) return quote(text);
        if (value instanceof int[] array) {
            StringBuilder out = new StringBuilder("[");
            for (int i = 0; i < array.length; i++) { if (i > 0) out.append(','); out.append(array[i]); }
            return out.append(']').toString();
        }
        throw new AssertionError("unexpected JSON value");
    }
    private static String render(List<Cell> cells) {
        StringBuilder out = new StringBuilder();
        out.append("{\n  \"schemaVersion\": 1,\n")
                .append("  \"recordType\": \"fixed-actual-java-observations\",\n")
                .append("  \"sampleCount\": 6,\n  \"policyCount\": 4,\n  \"cellCount\": 24,\n")
                .append("  \"outcomeChecks\": ").append(outcomeChecks).append(",\n")
                .append("  \"checks\": ").append(checks).append(",\n  \"cells\": [\n");
        for (int i = 0; i < cells.size(); i++) {
            Cell cell = cells.get(i);
            if (i > 0) out.append(",\n");
            out.append("    {\"sampleId\":").append(quote(cell.sampleId()))
                    .append(",\"sampleLabel\":").append(quote(cell.sampleLabel()))
                    .append(",\"policyId\":").append(quote(cell.policyId()))
                    .append(",\"policyLabel\":").append(quote(cell.policyLabel()))
                    .append(",\"outcome\":").append(quote(cell.outcome()))
                    .append(",\"observedType\":").append(quote(cell.observedType()))
                    .append(",\"observedValue\":").append(jsonValue(cell.observedValue()))
                    .append(",\"exceptionType\":").append(quote(cell.exceptionType()))
                    .append(",\"callbacks\":[");
            for (int j = 0; j < cell.callbacks().size(); j++) {
                if (j > 0) out.append(',');
                Callback callback = cell.callbacks().get(j);
                out.append("{\"class\":").append(quote(callback.className()))
                        .append(",\"arrayLength\":").append(callback.arrayLength())
                        .append(",\"status\":").append(quote(callback.status())).append('}');
            }
            out.append("]}");
        }
        return out.append("\n  ]\n}").toString();
    }

    public static void main(String[] args) throws Exception {
        List<Sample> samples = List.of(
                new Sample("allowed-9", "Allowed(9)", new Allowed(9)),
                new Sample("allowed-negative9", "Allowed(-9)", new Allowed(-9)),
                new Sample("other-7", "Other(7)", new Other(7)),
                new Sample("int-array-3", "int[3] = [1,2,3]", new int[] {1, 2, 3}),
                new Sample("int-array-5", "int[5] = [1,2,3,4,5]", new int[] {1, 2, 3, 4, 5}),
                new Sample("direct-string", "String own-text", "own-text"));
        ObjectInputFilter loose = ObjectInputFilter.allowFilter(
                type -> type == Allowed.class, ObjectInputFilter.Status.UNDECIDED);
        ObjectInputFilter strict = ObjectInputFilter.rejectUndecidedClass(loose);
        ObjectInputFilter maxArray4 = info -> info.arrayLength() > 4
                ? ObjectInputFilter.Status.REJECTED : ObjectInputFilter.Status.UNDECIDED;
        List<Policy> policies = List.of(
                new Policy("loose", "Allowed -> ALLOWED; others -> UNDECIDED", loose),
                new Policy("strict", "rejectUndecidedClass(loose)", strict),
                new Policy("max-array-4", "arrayLength > 4 -> REJECTED; otherwise UNDECIDED", maxArray4),
                new Policy("strict-and-max-array-4", "merge(strict,maxArray4)", ObjectInputFilter.merge(strict, maxArray4)));
        // Independent expected outcome table in sample order, then policy order.
        String[][] expected = {
                {"read", "read", "read", "read"},
                {"read", "read", "read", "read"},
                {"read", "rejected", "read", "rejected"},
                {"read", "read", "read", "read"},
                {"read", "read", "rejected", "rejected"},
                {"read", "read", "read", "read"}
        };
        List<Cell> cells = new ArrayList<>();
        for (int i = 0; i < samples.size(); i++) {
            Sample sample = samples.get(i);
            for (int j = 0; j < policies.size(); j++) {
                Policy policy = policies.get(j);
                Cell cell = observe(sample, policy);
                check(cell.outcome().equals(expected[i][j]), "outcome: " + sample.id() + "/" + policy.id());
                outcomeChecks++;
                if (cell.outcome().equals("read")) {
                    check(cell.observedType().equals(sample.value().getClass().getSimpleName())
                                    && sameValue(cell.observedValue(), valueOf(sample.value())),
                            "restored type/value: " + sample.id() + "/" + policy.id());
                } else {
                    check(cell.callbacks().stream().anyMatch(c -> c.status().equals("REJECTED")),
                            "rejection must have actual filter evidence");
                }
                if (sample.value() instanceof String) {
                    check(cell.callbacks().isEmpty(), "direct String must produce zero callbacks");
                } else {
                    check(!cell.callbacks().isEmpty(), "non-String sample must have observed callbacks");
                }
                if (sample.value() instanceof int[] array) {
                    String status = cell.outcome().equals("rejected") ? "REJECTED" : "UNDECIDED";
                    check(cell.callbacks().stream().anyMatch(c -> "int[]".equals(c.className())
                                    && c.arrayLength() == array.length && c.status().equals(status)),
                            "actual array length and status must be recorded");
                }
                cells.add(cell);
            }
        }
        check(cells.size() == 24 && outcomeChecks == 24, "complete six by four matrix");
        System.out.println(render(cells));
    }
}

怎样运行:在源码所在目录,先执行 javac -encoding UTF-8 --release 17 FilterMatrixDemo.java,再执行 java -cp . FilterMatrixDemo。已在Windows、JDK17.0.20.1/21.0.12独立编译运行;下方为两版一致的实际JSON输出。

运行后对照结果
{
  "schemaVersion": 1,
  "recordType": "fixed-actual-java-observations",
  "sampleCount": 6,
  "policyCount": 4,
  "cellCount": 24,
  "outcomeChecks": 24,
  "checks": 105,
  "cells": [
    {"sampleId":"allowed-9","sampleLabel":"Allowed(9)","policyId":"loose","policyLabel":"Allowed -> ALLOWED; others -> UNDECIDED","outcome":"read","observedType":"Allowed","observedValue":9,"exceptionType":null,"callbacks":[{"class":"Allowed","arrayLength":-1,"status":"ALLOWED"}]},
    {"sampleId":"allowed-9","sampleLabel":"Allowed(9)","policyId":"strict","policyLabel":"rejectUndecidedClass(loose)","outcome":"read","observedType":"Allowed","observedValue":9,"exceptionType":null,"callbacks":[{"class":"Allowed","arrayLength":-1,"status":"ALLOWED"}]},
    {"sampleId":"allowed-9","sampleLabel":"Allowed(9)","policyId":"max-array-4","policyLabel":"arrayLength > 4 -> REJECTED; otherwise UNDECIDED","outcome":"read","observedType":"Allowed","observedValue":9,"exceptionType":null,"callbacks":[{"class":"Allowed","arrayLength":-1,"status":"UNDECIDED"}]},
    {"sampleId":"allowed-9","sampleLabel":"Allowed(9)","policyId":"strict-and-max-array-4","policyLabel":"merge(strict,maxArray4)","outcome":"read","observedType":"Allowed","observedValue":9,"exceptionType":null,"callbacks":[{"class":"Allowed","arrayLength":-1,"status":"ALLOWED"}]},
    {"sampleId":"allowed-negative9","sampleLabel":"Allowed(-9)","policyId":"loose","policyLabel":"Allowed -> ALLOWED; others -> UNDECIDED","outcome":"read","observedType":"Allowed","observedValue":-9,"exceptionType":null,"callbacks":[{"class":"Allowed","arrayLength":-1,"status":"ALLOWED"}]},
    {"sampleId":"allowed-negative9","sampleLabel":"Allowed(-9)","policyId":"strict","policyLabel":"rejectUndecidedClass(loose)","outcome":"read","observedType":"Allowed","observedValue":-9,"exceptionType":null,"callbacks":[{"class":"Allowed","arrayLength":-1,"status":"ALLOWED"}]},
    {"sampleId":"allowed-negative9","sampleLabel":"Allowed(-9)","policyId":"max-array-4","policyLabel":"arrayLength > 4 -> REJECTED; otherwise UNDECIDED","outcome":"read","observedType":"Allowed","observedValue":-9,"exceptionType":null,"callbacks":[{"class":"Allowed","arrayLength":-1,"status":"UNDECIDED"}]},
    {"sampleId":"allowed-negative9","sampleLabel":"Allowed(-9)","policyId":"strict-and-max-array-4","policyLabel":"merge(strict,maxArray4)","outcome":"read","observedType":"Allowed","observedValue":-9,"exceptionType":null,"callbacks":[{"class":"Allowed","arrayLength":-1,"status":"ALLOWED"}]},
    {"sampleId":"other-7","sampleLabel":"Other(7)","policyId":"loose","policyLabel":"Allowed -> ALLOWED; others -> UNDECIDED","outcome":"read","observedType":"Other","observedValue":7,"exceptionType":null,"callbacks":[{"class":"Other","arrayLength":-1,"status":"UNDECIDED"}]},
    {"sampleId":"other-7","sampleLabel":"Other(7)","policyId":"strict","policyLabel":"rejectUndecidedClass(loose)","outcome":"rejected","observedType":null,"observedValue":null,"exceptionType":"InvalidClassException","callbacks":[{"class":"Other","arrayLength":-1,"status":"REJECTED"}]},
    {"sampleId":"other-7","sampleLabel":"Other(7)","policyId":"max-array-4","policyLabel":"arrayLength > 4 -> REJECTED; otherwise UNDECIDED","outcome":"read","observedType":"Other","observedValue":7,"exceptionType":null,"callbacks":[{"class":"Other","arrayLength":-1,"status":"UNDECIDED"}]},
    {"sampleId":"other-7","sampleLabel":"Other(7)","policyId":"strict-and-max-array-4","policyLabel":"merge(strict,maxArray4)","outcome":"rejected","observedType":null,"observedValue":null,"exceptionType":"InvalidClassException","callbacks":[{"class":"Other","arrayLength":-1,"status":"REJECTED"}]},
    {"sampleId":"int-array-3","sampleLabel":"int[3] = [1,2,3]","policyId":"loose","policyLabel":"Allowed -> ALLOWED; others -> UNDECIDED","outcome":"read","observedType":"int[]","observedValue":[1,2,3],"exceptionType":null,"callbacks":[{"class":"int[]","arrayLength":-1,"status":"UNDECIDED"},{"class":"int[]","arrayLength":3,"status":"UNDECIDED"}]},
    {"sampleId":"int-array-3","sampleLabel":"int[3] = [1,2,3]","policyId":"strict","policyLabel":"rejectUndecidedClass(loose)","outcome":"read","observedType":"int[]","observedValue":[1,2,3],"exceptionType":null,"callbacks":[{"class":"int[]","arrayLength":-1,"status":"UNDECIDED"},{"class":"int[]","arrayLength":3,"status":"UNDECIDED"}]},
    {"sampleId":"int-array-3","sampleLabel":"int[3] = [1,2,3]","policyId":"max-array-4","policyLabel":"arrayLength > 4 -> REJECTED; otherwise UNDECIDED","outcome":"read","observedType":"int[]","observedValue":[1,2,3],"exceptionType":null,"callbacks":[{"class":"int[]","arrayLength":-1,"status":"UNDECIDED"},{"class":"int[]","arrayLength":3,"status":"UNDECIDED"}]},
    {"sampleId":"int-array-3","sampleLabel":"int[3] = [1,2,3]","policyId":"strict-and-max-array-4","policyLabel":"merge(strict,maxArray4)","outcome":"read","observedType":"int[]","observedValue":[1,2,3],"exceptionType":null,"callbacks":[{"class":"int[]","arrayLength":-1,"status":"UNDECIDED"},{"class":"int[]","arrayLength":3,"status":"UNDECIDED"}]},
    {"sampleId":"int-array-5","sampleLabel":"int[5] = [1,2,3,4,5]","policyId":"loose","policyLabel":"Allowed -> ALLOWED; others -> UNDECIDED","outcome":"read","observedType":"int[]","observedValue":[1,2,3,4,5],"exceptionType":null,"callbacks":[{"class":"int[]","arrayLength":-1,"status":"UNDECIDED"},{"class":"int[]","arrayLength":5,"status":"UNDECIDED"}]},
    {"sampleId":"int-array-5","sampleLabel":"int[5] = [1,2,3,4,5]","policyId":"strict","policyLabel":"rejectUndecidedClass(loose)","outcome":"read","observedType":"int[]","observedValue":[1,2,3,4,5],"exceptionType":null,"callbacks":[{"class":"int[]","arrayLength":-1,"status":"UNDECIDED"},{"class":"int[]","arrayLength":5,"status":"UNDECIDED"}]},
    {"sampleId":"int-array-5","sampleLabel":"int[5] = [1,2,3,4,5]","policyId":"max-array-4","policyLabel":"arrayLength > 4 -> REJECTED; otherwise UNDECIDED","outcome":"rejected","observedType":null,"observedValue":null,"exceptionType":"InvalidClassException","callbacks":[{"class":"int[]","arrayLength":-1,"status":"UNDECIDED"},{"class":"int[]","arrayLength":5,"status":"REJECTED"}]},
    {"sampleId":"int-array-5","sampleLabel":"int[5] = [1,2,3,4,5]","policyId":"strict-and-max-array-4","policyLabel":"merge(strict,maxArray4)","outcome":"rejected","observedType":null,"observedValue":null,"exceptionType":"InvalidClassException","callbacks":[{"class":"int[]","arrayLength":-1,"status":"UNDECIDED"},{"class":"int[]","arrayLength":5,"status":"REJECTED"}]},
    {"sampleId":"direct-string","sampleLabel":"String own-text","policyId":"loose","policyLabel":"Allowed -> ALLOWED; others -> UNDECIDED","outcome":"read","observedType":"String","observedValue":"own-text","exceptionType":null,"callbacks":[]},
    {"sampleId":"direct-string","sampleLabel":"String own-text","policyId":"strict","policyLabel":"rejectUndecidedClass(loose)","outcome":"read","observedType":"String","observedValue":"own-text","exceptionType":null,"callbacks":[]},
    {"sampleId":"direct-string","sampleLabel":"String own-text","policyId":"max-array-4","policyLabel":"arrayLength > 4 -> REJECTED; otherwise UNDECIDED","outcome":"read","observedType":"String","observedValue":"own-text","exceptionType":null,"callbacks":[]},
    {"sampleId":"direct-string","sampleLabel":"String own-text","policyId":"strict-and-max-array-4","policyLabel":"merge(strict,maxArray4)","outcome":"read","observedType":"String","observedValue":"own-text","exceptionType":null,"callbacks":[]}
  ]
}

记住这三点

  • UNDECIDED 是未作判断,普通类在没有拒绝结果时仍可继续恢复。
  • serialClass 为 null 的指标回调需要单独处理,不能与过滤器返回 null 混淆。
  • 数组上限、直接 String 路径和业务字段各有检查边界,应分别记录证据。

别把例子用过头

  • 只写一个允许分支,却把其余 UNDECIDED 理解为全部拒绝。
  • 在判断 serialClass 是否为空之前访问它的方法。
  • 把 arrayLength=-1 当成空数组,或遗漏长度恰好等于上限的样本。
  • 以为 rejectUndecidedClass 会拒绝全部未决数组,或拒绝回调一定会检查字符串。

自己试一小步

把数组限制改为最多两个元素,并为 Allowed 的整数定义独立业务规则,怎样验证两层检查?

  1. 复制为新的单文件实验,先列出长度零、二、三的数组样本及预测。
  2. 修改资源过滤规则,在 Trace 中保存实际数组长度与状态,为每个边界写断言。
  3. 为读回 Allowed 后的 value 增加单独的业务检查,规则是零到十(含端点),设计负一、零、十、十一的样本。
  4. 分别保存类型过滤结果、对象是否完成读回和业务检查结果,不用一个笼统的成功标志替代。

做到这些,就算完成

  • 长度零和二被接受,长度三被拒绝;对应回调长度和状态可以从记录核对。
  • 负一和十一即使类型过滤允许,仍由独立业务检查拒绝;零与十通过。
  • 保留 serialClass 为 null 的分支,并用共享引用样本验证不会误报。
  • 全部字节由当前实验自己产生,保存 JDK 版本、实际输出和每项断言结果。

查原始资料

想确认一个细节,先看这份资料对应的部分,不必从头读完所有文档。

  • ObjectInputFilter:未决状态与组合方法 (新标签页)↗Oracle Java 官方文档 · 英文

    核对 allowFilter、rejectUndecidedClass 对普通类、空类事件与基本类型数组的区别。

    2026-10-18 已实际打开并核读:Filters 总述、checkInput、allowFilter、merge 和 rejectUndecidedClass 的方法说明与实现要求;未通读工厂示例。只记录相关阅读范围。

  • Java 17 序列化过滤指南 (新标签页)↗Oracle Java 官方文档 · 英文

    核对允许列表、资源限制和直接编码 String 的过滤例外。

    2026-10-18 已实际打开并核读:Allow-Lists and Reject-Lists、模式语法与限制、回调触发规则、Creating a Filter with ObjectInputFilter Methods。只记录相关阅读范围。

  • ObjectInputStream:过滤器调用时机 (新标签页)↗Oracle Java 官方文档 · 英文

    核对读取前设置一次过滤器、拒绝异常及引用、数组、字符串的回调规则。

    2026-10-18 已实际打开并核读:构造器的初始过滤器说明,getObjectInputFilter、setObjectInputFilter 全部方法详情及 Implementation Requirements;未通读全部对象流 API。只记录相关阅读范围。

  • FilterInfo:空类信息与数组长度 (新标签页)↗Oracle Java 官方文档 · 英文

    核对 null 类事件、arrayLength 的 -1/非负长度,以及深度、引用和字节计数。

    2026-10-18 已实际打开并核读:完整短页的类说明和五个方法;特别核对 streamBytes 是实现相关计数。只记录相关阅读范围。