有时候,为了解决一个问题,我们需要一个解决办法。可是,这个办法本身还会带来更多的问题。新问题的解决带来更新的问题,就这样周而复始,绵延不绝。

比如上一篇文章,我们说到的敏感信息通过异常信息泄露的问题,就是面向对象设计和实现给我们带来的小困扰。再比如前面还有一个案例,说到了共享内存或者缓存技术带来的潜在危害和挑战,这些都是成熟技术发展背后需要做出的小妥协。只是有时候,这些小小的妥协如果没有被安排好和处理好,可能就会带来不成比例的代价。

评审案例

我们一起来看一段节选的java.io.FilePermission类的定义。你知道为什么FilePermission被定义为final类吗?

package java.io;

// <snipped>
/**
 * This class represents access to a file or directory.  A
 * FilePermission consists of a pathname and a set of actions
 * valid for that pathname.
 * <snipped>
 */
public final class FilePermission
        extends Permission implements Serializable {
    /**
     * Creates a new FilePermission object with the specified actions.
     * <i>path</i> is the pathname of a file or directory, and
     * <i>actions</i> contains a comma-separated list of the desired
     * actions granted on the file or directory. Possible actions are
     * "read", "write", "execute", "delete", and "readlink".
     * <snipped>
     */
    public FilePermission(String path, String actions);

    /**
     * Returns the "canonical string representation" of the actions.
     * That is, this method always returns present actions in the
     * following order: read, write, execute, delete, readlink. 
     * <snipped>
     */
    @Override
    public String getActions();

    /**
     * Checks if this FilePermission object "implies" the 
     * specified permission.
     * <snipped>
     * @param p the permission to check against.
     *
     * @return <code>true</code> if the specified permission
     *         is not <code>null</code> and is implied by this
     *         object, <code>false</code> otherwise.
     */
    @Override
    public boolean implies(Permission p);

    // <snipped>
}

FilePermission被声明为final,也就意味着该类不能被继承,不能被扩展了。我们都知道,在面向对象的设计中,是否具备可扩展性是一个衡量设计优劣的好指标。如果允许扩展的话,那么想要增加一个“link”的操作就会方便很多,只要扩展FilePermission类就可以了。 但是对于FilePermission这个类,OpenJDK为什么放弃了可扩展性?

案例分析

如果我们保留FilePermission的可扩展性,你来评审一下下面的代码,可以看出这段代码的问题吗?

package com.example;

public final class MyFilePermission extends FilePermission {
    @Override
    public String getActions() {
      return "read";
    }

    @Override
    public boolean implies(Permission p) {
      return true;
    }  
}

如果你还没有找出这个问题,可能是因为我还遗漏了对FilePermission常见使用场景的介绍。在Java的安全管理模式下,一个用户通常可能会被授予有限的权限。 比如用户“xuelei”可以读取用户“duke”的文件,但不能更改用户“duke”的文件。

授权的策咯可能看起来像下面的描述:

grant Principal com.sun.security.auth.UnixPrincipal "xuelei" {
    permission com.example.MyFilePermission "/home/duke", "read";
};

这项策略要想起作用,上面的描述就要转换成一个MyFilePermission的实例。然后调用该实例的implies()方法类判断是否可以授权一项操作。

Permission myPermission = ...  // read "/home/duke"

public void checkRead() {
  if (myPermission.implies(New FilePermission(file, "read"))) {
    // read is allowed.
  } else {
    // throw exception, read is not allowed.
  }
}

public void checkWrite() {
  if (myPermission.implies(New FilePermission(file, "write"))) {
    // writeis allowed.
  } else {
    // throw exception, write is not allowed.
  }  
}

这里请注意,MyFilePermission.implies()总是返回“true”, 所以上述的checkRead()和checkWrite()方法总是成功的,不管用户被明确指示授予了什么权限,实际上暗地里他已经被授予了所有权限。这就成功地绕过了Java的安全管理。

能够绕过Java安全机制的主要原因,在于我们允许了FilePermission的扩展。而扩展类的实现,有可能有意或者无意地改变了FilePermission的规范和运行,从而带来不可预料的行为。

如果你关注OpenJDK安全组的代码评审邮件组,你可能会注意到,对于面向对象的可扩展性这一便利和诱惑,很多工程师能够保持住克制。

保持克制,可能会遗漏一两颗看似近在眼前的甜甜的糖果,但可以减轻你对未来长期的担忧。

一个类或者方法如果使用了final关键字,我们可以稍微放宽心。如果没有使用final关键字,我们可能需要反复揣摩好长时间,仔细权衡可扩展性可能会带来的弊端。

一个公共类或者方法如果使用了final关键字,将来如果需要扩展性,就可以去掉这个关键字。但是,如果最开始没有使用final关键字,特别是对于公开的接口来说,将来想要加上就可能是一件非常困难的事。

上面的例子是子类通过改变父类的规范和行为带来的潜在问题。那么父类是不是也可以改变子类的行为呢? 这听起来有点怪异,但是父类对子类行为的影响,有时候也的确是一个让人非常头疼的问题。

麻烦的继承

我先总结一下,父类对子类行为的影响大致有三种:

  1. 改变未继承方法的实现,或者子类调用的方法的实现(super);

  2. 变更父类或者父类方法的规范;

  3. 为父类添加新方法。

第一种和第三种相对比较容易理解,第二种稍微复杂一点。我们还是通过一个例子来看看其中的问题。

Hashtable是一个古老的,被广泛使用的类,它最先出现在JDK 1.0中。其中,put()和remove()是两个关键的方法。在JDK 1.2中,又有更多的方法被添加进来,比如entrySet()方法。

public class Hashtable<K,V> ... {
    // snipped
    /**
     * Returns a {@link Set} view of the mappings contained in
     ( this map.
     * The set is backed by the map, so changes to the map are
     * reflected in the set, and vice-versa.  If the map is modified
     * while an iteration over the set is in progress (except through
     * the iterator's own {@code remove} operation, or through the
     * {@code setValue} operation on a map entry returned by the
     * iterator) the results of the iteration are undefined.  The set
     * supports element removal, which removes the corresponding
     * mapping from the map, via the {@code Iterator.remove},
     * {@code Set.remove}, {@code removeAll}, {@code retainAll} and
     * {@code clear} operations.  It does not support the
     * {@code add} or {@code addAll} operations.
     *
     * @since 1.2
     */
    public Set<Map.Entry<K,V>> entrySet() {
        // snipped
    }
    // snipped
}

这就引入了一个难以察觉的潜在的安全漏洞。 你可能会问,添加一个方法不是很常见吗?这能有什么问题呢?

问题在于继承Hashtable的子类。假设有一个子类,它的Hashtable里要存放敏感数据,数据的添加和删除都需要授权,在JDK 1.2之前,这个子类可以重写put()和remove()方法,加载权限检查的代码。在JDK 1.2中,这个子类可能意识不到Hashtable添加了entrySet()这个新方法,从而也没有意识到要重写覆盖entrySet()方法,然而,通过对entrySet()返回值的直接操作,就可以执行数据的添加和删除的操作,成功地绕过了授权。

public class MySensitiveData extends Hashtable<Object, Object> {
    // snipped
    @Override
    public synchronized Object put(Object key, Object value) {
        // check permission and then add the key-value
        // snipped
        super.put(key, value)
    }
    
    @Override
    public synchronized Object remove(Object key) {
        // check permission and then remove the key-value
        // snipped
        return super.remove(key);
    }
    // snipped, no override of entrySet()
}
MySensitiveData sensitiveData = ...   // get the handle of the data
Set<Map.Entry<Object, Object>> sdSet = sensitiveData.entrySet();
sdSet.remove(...);    // no permission check
sdSet.add(...);       // no permission check

// the sensitive data get modified, unwarranted.

现实中,这种问题非常容易发生。一般来说,我们的代码总是依赖一定的类库,有时候需要扩展某些类。这个类库可能是第三方的产品,也可能是一个独立的内部类库。但遗憾的是,类库并不知道我们需要拓展哪些类,也可能没办法知道我们该如何拓展。

所以,当有一个新方法添加到类库的新版本中时,这个新方法会如何影响扩展类,该类库也没有特别多的想象空间和处理办法。就像Hashtable要增加entrySet()方法时,让Hashtable的维护者意识到有一个特殊的MySensitiveData扩展,是非常困难和不现实的。然而Hashtable增加entrySet()方法,合情又合理,也没有什么值得抱怨的。

然而,当JDK 1.0/1.1升级到JDK 1.2时,Hashtable增加了entrySet()方法,上述的MySensitiveData的实现就存在严重的安全漏洞。要想修复该安全漏洞,MySensitiveData需要重写覆盖entrySet()方法,植入权限检查的代码。

可是,我们怎样可能知道MySensitiveData需要修改呢! 一般来说,如果依赖的类库进行了升级,没有影响应用的正常运营,我们就正常升级了,而不会想到检查依赖类库做了哪些具体的变更,以及评估每个变更潜在的影响。这实在不是软件升级的初衷,也远远超越了大部分组织的能力范围。

而且,如果MySensitiveData不是直接继承Hashtable,而是经过了中间环节,这个问题就会更加隐晦,更加难以察觉。

public class IntermediateOne extends Hashtable<Object, Object>;

public class IntermediateTwo extends IntermediateOne;

public class Intermediate extends IntermediateTwo;

public class MySensitiveData extends Intermediate;

糟糕的是,随着语言变得越来越高级,类库越来越丰富,发现这些潜在问题的难度也是节节攀升。我几乎已经不期待肉眼可以发现并防范这类问题了。

那么,到底有没有办法可以防范此类风险呢?

主要有两个方法。

一方面,当我们变更一个可扩展类时,要极其谨慎小心。一个类如果可以不变更,就尽量不要变更,能在现有框架下解决问题,就尽量不要试图创造新的轮子。有时候,我们的确难以压制想要创造出什么好东西的冲动,这是非常好的品质。只是变更公开类库时,一定要多考虑这么做的潜在影响。你是不是开始思念final关键字的好处了?

另一方面,当我们扩展一个类时,如果涉及到敏感信息的授权与保护,可以考虑使用代理的模式,而不是继承的模式。代理模式可以有效地降低可扩展对象的新增方法带来的影响。

public class MySensitiveData {
    private final Hashtable hashtable = ...

    public synchronized Object put(Object key, Object value) {
        // check permission and then add the key-value
        hashtable.put(key, value)
    }

    public synchronized Object remove(Object key) {
        // check permission and then remove the key-value
        return hashtable.remove(key);
    }
}

我们使用了Java语言来讨论继承的问题,其实这是一个面向对象机制的普遍的问题,甚至它也不单单是面向对象语言的问题,比如使用C语言的设计和实现,也存在类似的问题。

小结

通过对这个案例的讨论,我想和你分享下面两点个人看法。

  1. 一个可扩展的类,子类和父类可能会相互影响,从而导致不可预知的行为。

  2. 涉及敏感信息的类,增加可扩展性不一定是个优先选项,要尽量避免父类或者子类的影响。

学会处理和保护敏感信息,是一个优秀工程师必须迈过的门槛。

一起来动手

了解语言和各种固定模式的缺陷,是我们打怪升级的一个很好的办法。有时候,我们偏重于学习语言或者设计经验的优点,忽视了它们背后做出小小的妥协,或者缺陷。如果能利用好优点,处理好缺陷,我们就可以更好地掌握这些经验总结。毕竟世上哪有什么完美的东西呢?不完美的东西,用好了,就是好东西。

我们利用讨论区,来聊聊设计模式这个老掉牙的、备受争议的话题。说起“老掉牙”,科技的进步真是快,设计模式十多年前还是一个时髦的话题,如今已经不太受待见了,虽然我们或多或少,或直接或间接地都受益于设计模式的思想。如果你了解过设计模式,你能够分享某个设计模式的优点和缺陷吗? 使用设计模式有没有给你带来实际的困扰呢?

上面的例子中,我们提到了使用代理模式来降低父类对子类的影响。那么你知道代理模式的缺陷吗?

欢迎你把自己的经验和看法写在留言区,我们一起来学习、思考、精进!

如果你觉得这篇文章有所帮助,欢迎点击“请朋友读”,把它分享给你的朋友或者同事。

评论