ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

Spring Boot系统属性解密:属性源优先级与解密层实践

Spring Boot系统属性解密:属性源优先级与解密层实践

写在最前面:系统属性这层东西,平时谁都不会特意去看它,可一旦里面塞了加密值,问题就会变得很别扭——配置文件里写明文的那套经验全失效了。前段时间我接手一个项目,运维在启动脚本里用-D传了一串加密后的数据库口令,结果 Spring 容器起来之后拿到的还是密文,连接池直接报认证失败。后来折腾了大半天才把整条链路捋顺,索性把过程整理一下,从属性源的优先级、解密层的插入位置,到几个几乎必踩的坑,一次说清楚。这篇内容适合已经写过 Spring Boot、但没系统研究过属性加载链路的朋友,也适合正在给老项目补安全加固的同学照着做。

1. 系统属性在 Spring Boot 属性链路里的真实位置

1.1 一次 setProperty 之后,值究竟被谁读走了

System.setProperty(key, value)写进去的东西,本质上是 JVM 全局的一张Properties表,底层是Hashtable,进程内所有线程共享,生命周期跟 JVM 一致。Spring Boot 在启动早期会把这张表包装成一个PropertySource,名字固定叫systemProperties,塞进MutablePropertySources里。你后面写的@Value("${xxx}")、@ConfigurationProperties、environment.getProperty("xxx"),最终都会顺着这条链一路找下来。

关键在于"包装"这两个字。Spring 拿到的不是你原始那张Properties,而是一个只读视图,getProperty每次调用都会实时去问 JVM。也就是说,你在容器启动之后调System.setProperty,environment.getProperty是能立刻读到新值的。但已经完成绑定的 Bean、已经解析完的占位符不会自动刷新,这一点后面会专门讲。

理解这一层很重要。很多人以为系统属性就是"环境变量的小名",其实两者在 Spring Boot 里是两个独立的属性源,名字分别是systemProperties和systemEnvironment,优先级也不一样,系统属性排在环境变量前面。这个顺序直接决定了你的加密值会不会被别的配置盖掉。

1.2 属性源优先级决定了你的加密值会被谁覆盖

Spring Boot 的属性源是有明确顺序的,靠前的优先。下面这张表是我实际排查时整理的,只列了日常最常撞上的几层:

优先级属性源典型来源
1命令行参数--app.secret.xxx=...
2SPRING_APPLICATION_JSON环境变量里的 JSON
3Servlet 相关参数少见
4JNDI少见
5systemProperties-Dkey=value、System.setProperty
6systemEnvironment操作系统环境变量
7random.*随机值
8外部配置文件jar 同级的application.yml
9jar 内配置文件classpath:/application.yml
10@PropertySource代码里声明的
11默认值SpringApplication.setDefaultProperties

从这个顺序能看出来两件事。第一,系统属性天然压过application.yml,你在配置文件里写同名 key 是盖不住它的,这是好事,方便运维用启动参数覆盖。第二,命令行参数又压过系统属性,所以如果有人用--app.secret.xxx=明文启动,你的加密逻辑就白做了——这一点在做兜底校验时值得加一条判断。

还有个容易忽略的细节:Profile 相关的配置文件(application-prod.yml)排在同名非 Profile 文件前面,但它们整体都排在系统属性之后。所以只要 key 对得上,系统属性永远赢。这也解释了为什么有些团队喜欢把所有敏感配置都通过-D注入,而不是写进配置文件。

1.3 三个必须提前想清楚的时间点

做加解密之前,有三个时间点必须先确认,否则代码写得再漂亮也不生效。

  • 设置时间点:System.setProperty是在SpringApplication.run()之前、之中还是之后调的?之前最稳,Spring 能完整读到;之中取决于具体阶段;之后只能保证environment.getProperty读到,已经绑定的 Bean 拿不到。
  • 解密层插入时间点:解密逻辑必须插在所有读取动作之前。Spring Boot 提供了EnvironmentPostProcessor、ApplicationContextInitializer、BeanFactoryPostProcessor三个常见钩子,它们的执行阶段完全不同,选错了就会出现"配置文件里的能解密、系统属性里的不能"这种诡异现象。
  • 占位符解析时间点:@Value的占位符由PropertySourcesPlaceholderConfigurer在 Bean 实例化阶段解析,晚于环境准备。所以只要在环境准备阶段把属性源包一层,@Value就能拿到解密值。

我见过最典型的翻车方式是:在某个@PostConstruct方法里调System.setProperty塞密钥,然后指望@Value能读到。结果 Bean 初始化顺序稍微一变,读到的就是 null。这类问题排查起来极其费劲,因为不报错,只是值不对。

2. 方案选型:三种落地路径的边界与取舍

2.1 路径一:在设置源头就地解密

最直接的做法是在main方法里、SpringApplication.run之前,把系统属性里的加密值解密后重新setProperty回去。代码大概长这样:

public static void main(String[] args) { decryptSystemProperties(); SpringApplication.run(DemoApplication.class, args); } private static void decryptSystemProperties() { Properties props = System.getProperties(); for (String name : props.stringPropertyNames()) { String raw = props.getProperty(name); if (raw != null && raw.startsWith("enc:v1:")) { System.setProperty(name, AesGcmCipher.decrypt(raw)); } } }

这段代码的优势是简单、无框架依赖、执行时机绝对早。缺点是它会把解密后的明文回写到 JVM 系统属性里,任何能读System.getProperties()的代码(包括某些诊断工具)都能看到明文。另外它只处理系统属性,配置文件里的加密值管不着,如果你的项目两种来源都有,就得维护两套逻辑。

这条路径适合什么场景?我总结下来是:项目规模小、敏感配置全部走启动参数、团队不想引入额外依赖。反过来说,如果配置文件里也有加密值,或者需要按环境切换密钥版本,就别选它了,后期会很难受。

2.2 路径二:用 Jasypt 兜住所有属性源

Jasypt 的 Spring Boot Starter 是这类需求里出场率最高的方案。它的思路是提供一个EncryptablePropertySourceConverter,在容器 refresh 之前把所有PropertySource逐个包一层,读取时遇到ENC(...)包裹的值就解密。配置成本很低,加个依赖、配个密钥就行。

它对系统属性同样有效,因为systemProperties也在被包装的名单里。我实测过-Dapp.secret.db-password=ENC(xxx)这种写法,Spring 读出来是解密后的值。具体流程是:EnableEncryptablePropertiesBeanFactoryPostProcessor在BeanFactory后置处理阶段触发属性源转换,此时Environment已经准备好了,所以@Value和@ConfigurationProperties都能拿到明文。

但 Jasypt 有几个需要提前知道的边界。它的密钥本身要通过jasypt.encryptor.password配置,这个值如果也从系统属性来,就得保证包装之前已经就位;它的默认算法是PBEWITHHMACSHA512ANDAES_256,需要 BouncyCastle 支持,JDK 版本低的项目要额外引依赖;它对值的前缀匹配是ENC(开头、)结尾,如果你的密文里恰好包含这个模式,会被二次解密,产生莫名其妙的错误。

还有一个隐蔽的问题:Jasypt 的包装发生在BeanFactory后置处理阶段,比EnvironmentPostProcessor晚。如果某个EnvironmentPostProcessor在更早的阶段就要读加密属性(比如决定加载哪个配置文件),那时候还是密文。这种情况虽然少见,但一旦碰上就很难定位。

2.3 路径三:自己包装 PropertySource

第三种是自己写一个EnvironmentPostProcessor,在环境准备阶段遍历所有属性源,把需要处理的那几个替换成解密包装器。这种做法可控性最高:能精确控制包装哪些源、能自定义密文前缀、能支持密钥版本、能在解密失败时给出明确日志。

代价是要自己写代码,大概一百多行,包括一个加解密工具类、一个PropertySource包装器、一个EnvironmentPostProcessor实现和注册文件。听着多,其实拆开看每一块都不复杂,后面第三章会给出完整实现。

这条路径适合对安全有明确要求、或者需要和现有密钥管理流程对接的项目。比如你的密钥来自某个配置中心,需要在启动时拉取,那用自定义处理器就比 Jasypt 灵活得多。

2.4 三条路径的横向对比

维度源头就地解密Jasypt Starter自定义 PropertySource
实现成本极低低中
覆盖配置文件否是是
覆盖系统属性是是是
密钥版本支持需自己实现弱强
明文残留会回写系统属性不回写不回写
执行时机最早较晚可调
排查难度低中中

选型上我的建议是:新项目、配置来源统一走启动参数的,选路径一,够用且省事;已经在用 Spring Boot 且只想快速补上的,选 Jasypt;对密钥轮换、审计日志有要求的,老老实实写自定义处理器。三条路径也可以混用,比如配置文件走 Jasypt、系统属性走源头解密,但混用会让排查变复杂,不建议。

3. 手写一个能识别系统属性的解密层

3.1 先写一个靠谱的 AES-GCM 工具类

算法选择上我不推荐再用PBEWithMD5AndDES,这个在 Jasypt 老版本里是默认值,强度已经不够看了。直接上AES/GCM/NoPadding,带认证标签,能同时防篡改。

public final class AesGcmCipher { private static final String TRANSFORMATION = "AES/GCM/NoPadding"; private static final int IV_LENGTH = 12; private static final int TAG_LENGTH_BIT = 128; private static final String PREFIX = "enc:v1:"; private static final SecureRandom RANDOM = new SecureRandom(); private AesGcmCipher() { } public static String encrypt(String plain, SecretKey key) throws Exception { byte[] iv = new byte[IV_LENGTH]; RANDOM.nextBytes(iv); Cipher cipher = Cipher.getInstance(TRANSFORMATION); cipher.init(Cipher.ENCRYPT_MODE, key, new GCMParameterSpec(TAG_LENGTH_BIT, iv)); byte[] cipherText = cipher.doFinal(plain.getBytes(StandardCharsets.UTF_8)); ByteBuffer buffer = ByteBuffer.allocate(iv.length + cipherText.length); buffer.put(iv).put(cipherText); return PREFIX + Base64.getUrlEncoder().withoutPadding() .encodeToString(buffer.array()); } public static String decrypt(String encoded, SecretKey key) throws Exception { if (encoded == null || !encoded.startsWith(PREFIX)) { return encoded; } byte[] all = Base64.getUrlDecoder() .decode(encoded.substring(PREFIX.length())); ByteBuffer buffer = ByteBuffer.wrap(all); byte[] iv = new byte[IV_LENGTH]; buffer.get(iv); byte[] cipherText = new byte[buffer.remaining()]; buffer.get(cipherText); Cipher cipher = Cipher.getInstance(TRANSFORMATION); cipher.init(Cipher.DECRYPT_MODE, key, new GCMParameterSpec(TAG_LENGTH_BIT, iv)); return new String(cipher.doFinal(cipherText), StandardCharsets.UTF_8); } }

几个点值得说明。Base64.getUrlEncoder()用的是 URL 安全字符集,把+和/换成了-和_,这样密文丢进启动脚本、环境变量、URL 查询串都不会被转义。我就吃过这个亏,标准 Base64 里的+在某个 CI 平台的变量传递环节被转成了空格,解密直接报填充异常,查了两小时。

前缀enc:v1:有两个作用:一是避免误伤普通值,二是为密钥轮换预留版本号。以后换密钥就上enc:v2:,解密时按版本选对应密钥,老数据不用一次性全刷。

IV 长度选 12 字节是 GCM 的推荐值,性能最好。认证标签 128 位是标准配置,别为了省几个字节改成 96 位,收益微乎其微。

3.2 包装属性源时的关键选择:Enumerable 不能丢

写包装器的时候有个坑必须提前避开:如果你把一个EnumerablePropertySource包装成普通的PropertySource,getPropertyNames()就返回不了了。后果是@ConfigurationProperties的宽松绑定失效、Environment.getProperty的下标查找失效、actuator 的/env端点看不到这些属性。

所以包装类要按源的类型分两种实现,或者直接写一个继承EnumerablePropertySource的版本:

public class DecryptingPropertySource extends EnumerablePropertySource<PropertySource<?>> { private final SecretResolver resolver; private final Predicate<String> keyFilter; public DecryptingPropertySource(String name, PropertySource<?> source, SecretResolver resolver, Predicate<String> keyFilter) { super(name, source); this.resolver = resolver; this.keyFilter = keyFilter; } @Override public Object getProperty(String name) { Object value = source.getProperty(name); if (value instanceof String str && keyFilter.test(name)) { return resolver.resolve(str); } return value; } @Override public String[] getPropertyNames() { if (source instanceof EnumerablePropertySource<?> enumerable) { return enumerable.getPropertyNames(); } return new String[0]; } @Override public boolean containsProperty(String name) { return source.containsProperty(name); } }

SecretResolver负责真正的解密,内部加一层ConcurrentHashMap缓存,同一个密文只解一次。启动阶段属性可能被读几百次,缓存能省下不少开销。

public class SecretResolver { private final Map<String, SecretKey> keys; private final Map<String, String> cache = new ConcurrentHashMap<>(); public SecretResolver(Map<String, SecretKey> keys) { this.keys = keys; } public String resolve(String raw) { if (raw == null || !raw.startsWith("enc:")) { return raw; } return cache.computeIfAbsent(raw, this::doResolve); } private String doResolve(String raw) { String version = raw.substring(4, raw.indexOf(':', 4)); SecretKey key = keys.get(version); if (key == null) { throw new IllegalStateException("未找到密钥版本: " + version); } try { return AesGcmCipher.decrypt(raw, key); } catch (Exception e) { throw new IllegalStateException("属性解密失败,请检查密钥与密文是否匹配", e); } } }

注意异常信息里不要带明文,也不要带完整密文。带上密钥版本号就够了,生产日志里泄露密文等于泄露了一半信息。

3.3 EnvironmentPostProcessor 的注册和生效时机

处理器本身很简单,核心是遍历属性源、替换、保持顺序:

public class DecryptingEnvironmentPostProcessor implements EnvironmentPostProcessor, Ordered { @Override public void postProcessEnvironment(ConfigurableEnvironment environment, SpringApplication application) { SecretResolver resolver = SecretKeyLoader.load(environment); MutablePropertySources sources = environment.getPropertySources(); List<String> names = new ArrayList<>(); for (PropertySource<?> ps : sources) { names.add(ps.getName()); } Predicate<String> filter = name -> name.startsWith("app.secret.") || name.startsWith("spring.datasource."); for (String name : names) { PropertySource<?> original = sources.get(name); if (original == null || original instanceof DecryptingPropertySource) { continue; } sources.replace(name, new DecryptingPropertySource(name, original, resolver, filter)); } } @Override public int getOrder() { return Ordered.LOWEST_PRECEDENCE; } }

getOrder()返回LOWEST_PRECEDENCE是有讲究的。Spring Boot 2.4 之后配置文件通过ConfigDataEnvironmentPostProcessor加载,它的 order 很靠前。如果你的处理器 order 太小,执行时application.yml还没进属性源列表,就漏掉了。设成最低优先级,能保证所有配置文件都加载完之后再统一包装。

迭代时要先收集名字快照,不能在遍历MutablePropertySources的同时调用replace,会抛ConcurrentModificationException。这个坑我在第一次写的时候踩过,启动直接挂掉。

SecretKeyLoader负责从环境变量或外部密钥文件读取主密钥。这里要强调一点:密钥绝不能和密文放在同一个属性源里。密文在启动参数、密钥在环境变量,这是最低要求;再好一点是密钥从挂载的 secret 文件读,只有特定用户可读。

3.4 Spring Boot 3.x 的注册差异与兼容写法

Spring Boot 2.x 的注册文件是META-INF/spring.factories:

org.springframework.boot.env.EnvironmentPostProcessor=\ com.example.config.DecryptingEnvironmentPostProcessor

Spring Boot 3.x 改成了META-INF/spring/org.springframework.boot.env.EnvironmentPostProcessor.imports,内容直接写类名,一行一个:

com.example.config.DecryptingEnvironmentPostProcessor

如果项目要同时兼容 2.x 和 3.x,两个文件都放,内容各写各的格式。Spring Boot 3 会优先读 imports 文件,找不到再回退到spring.factories,放两份不会冲突。

另外 3.x 对spring.factories的很多扩展点都做了迁移,如果你还依赖其他扩展点,建议一次性检查一遍,别只改了这一处。

3.5 让 @Value、@ConfigurationProperties、Environment 三条读取路径都走通

包装完之后要验证三条路径是不是都拿到明文。

@Value走的是PropertySourcesPlaceholderConfigurer,它读的是Environment的propertySources,包装后的源直接生效,没问题。

@ConfigurationProperties走的是ConfigurationPropertySources,这是一个在环境准备末尾 attach 上去的视图。因为它在 attach 时持有的是MutablePropertySources的引用,遍历是动态的,所以后包装也生效。但要注意 attach 的时机在你的处理器之后——Ordered.LOWEST_PRECEDENCE恰好能保证这一点。

environment.getProperty("key")直接遍历propertySources,同样生效。

有一个例外是@PropertySource注解引入的属性源。它的加载时机在ConfigurationClassPostProcessor阶段,晚于EnvironmentPostProcessor,所以不会被包装到。如果这个源里也有加密值,需要单独处理,比如在BeanFactoryPostProcessor里再包一次,或者干脆把加密值挪到application.yml里。

4. 从加密一个值到项目跑起来的完整实操

4.1 生成 256 位密钥并加密目标值

密钥生成用 KeyGenerator 就行,别自己拼字节数组:

KeyGenerator generator = KeyGenerator.getInstance("AES"); generator.init(256, new SecureRandom()); SecretKey key = generator.generateKey(); String base64Key = Base64.getEncoder().encodeToString(key.getEncoded()); System.out.println("主密钥: " + base64Key);

256 位 AES 在 JDK 8u161 之后默认就支持,不需要额外装策略文件。如果你的运行环境是很老的 JDK 8 小版本,跑的时候会抛InvalidKeyException: Illegal key size,那就得升级 JDK 或者装无限强度策略文件。这个报错信息很明确,一看就知道原因。

拿到密钥之后加密目标值:

SecretKey key = new SecretKeySpec(Base64.getDecoder().decode(base64Key), "AES"); String encrypted = AesGcmCipher.encrypt("P@ssw0rd-2024", key); System.out.println(encrypted);

输出大概长这样:enc:v1:xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx,长度 60 到 80 个字符之间,具体取决于明文长度。

4.2 启动脚本里注入系统属性的几种写法

Linux 下用-D传参:

java -Dapp.secret.db-password='enc:v1:xxxxx' \ -Dspring.profiles.active=prod \ -jar demo.jar

注意值里的特殊字符。虽然用了 URL 安全 Base64,理论上只有字母数字和-_,但前缀里有个冒号,在 YAML 或某些脚本里冒号后面跟空格会被当成分隔符。所以建议整个值用单引号包起来,别图省事。

容器环境里更推荐用环境变量,避免命令行参数被ps看到:

env: - name: APP_SECRET_DB_PASSWORD valueFrom: secretKeyRef: name: app-secret key: db-password

环境变量名APP_SECRET_DB_PASSWORD会被 Spring Boot 的宽松绑定映射成app.secret.db-password,所以你的代码里照常写@Value("${app.secret.db-password}")就行。这个映射规则是:全大写、下划线转点、点转短横线。搞清楚这条规则,很多"环境变量明明设了却读不到"的问题就迎刃而解了。

如果值里必须带加密前缀,环境变量这种方式反而更安全,因为不需要经过 shell 解析。

4.3 写个测试验证解密链路真的生效

测试这块有个坑:@SpringBootTest会缓存ApplicationContext,如果第一个测试类启动时系统属性还没设,后面再设也不会重新加载。所以要么用@DirtiesContext,要么在@BeforeAll里设置并确保测试类顺序。

@SpringBootTest @DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_CLASS) class SecretPropertyTest { static final String KEY = "测试用密钥的Base64"; @BeforeAll static void setUp() throws Exception { SecretKey key = new SecretKeySpec(Base64.getDecoder().decode(KEY), "AES"); System.setProperty("app.secret.db-password", AesGcmCipher.encrypt("P@ssw0rd-2024", key)); System.setProperty("APP_SECRET_KEY", KEY); } @AfterAll static void tearDown() { System.clearProperty("app.secret.db-password"); System.clearProperty("APP_SECRET_KEY"); } @Value("${app.secret.db-password}") String dbPassword; @Autowired Environment environment; @Test void 系统属性应被解密读取() { assertThat(dbPassword).isEqualTo("P@ssw0rd-2024"); assertThat(environment.getProperty("app.secret.db-password")) .isEqualTo("P@ssw0rd-2024"); } }

@AfterAll里清理系统属性这一步别省。系统属性是 JVM 级别的,测试跑完不清理,会影响同一个 JVM 里的其他测试类,症状是"单跑通过、全跑失败",特别难查。

4.4 长度、性能与缓存的实际测算

密文长度可以精确算出来。明文n字节,GCM 加密后是n + 16字节(认证标签),加上 12 字节 IV,总共n + 28字节。Base64 URL 无填充编码后的长度是ceil((n + 28) * 4 / 3)再减去填充,再加上 7 个字符的前缀。

拿一个 12 字符的口令举例:12 + 28 = 40字节,Base64 后约 54 字符,加前缀总共 61 字符。这个长度对启动参数和环境变量来说毫无压力。

性能方面,AES-GCM 在现代 CPU 上有硬件加速指令,单次加解密在微秒级。真正影响启动时间的是属性被读取的次数——一个中等规模的应用启动时,属性读取可能上万次。所以缓存是必须的,用ConcurrentHashMap以密文为 key 缓存明文,命中率接近 100%。我实测过一个有 8 个加密属性的项目,加了缓存之后解密耗时从 40 毫秒降到 3 毫秒以内。

要提醒一句:缓存里存的是明文,内存 dump 能看到。如果对内存安全有极高要求,可以考虑用char[]并在用完后清零,但这会把代码复杂度拉高不少,一般业务系统不必做到这个程度。

5. 踩坑实录:那些配置看着对但就是不生效的情况

5.1 值被配置文件覆盖的排查顺序

最常见的问题是"系统属性设了,但读出来还是配置文件里的值"。排查顺序我一般是这样的:

第一步,看 key 是否完全一致。系统属性里的点号、短横线、大小写都有讲究。app.secret.dbPassword和app.secret.db-password在宽松绑定下是同一个,但在environment.getProperty里是两个不同的 key。用@Value时尤其容易搞混。

第二步,看属性源顺序有没有被人为改过。有些项目启动时会调用environment.getPropertySources().addFirst(...)塞自定义源,把系统属性挤下去了。这种情况在引入了某些配置中心客户端的项目里很常见。

第三步,看是不是加密前缀没匹配上。比如你的密文是ENC(xxx)格式,但处理器只认enc:v1:,那就原样返回了原始字符串,看着像没解密。

第四步,看处理器有没有真的被注册。spring.factories里类名写错一个字母就静默不加载,不报任何错。可以在处理器里打一行 info 日志,启动时确认一下。

5.2 解密异常与乱码的定位方法

解密报错无非几类。AEADBadTagException说明密钥不对或者密文被改过,这是 GCM 的认证失败,属于最明确的一类。IllegalBlockSizeException通常是密文被截断,比如传输过程中丢了字符。IllegalArgumentException: Illegal base64 character说明 Base64 解码失败,基本可以断定是特殊字符被转义了。

乱码问题多数出在编码上。加密时用 UTF-8 取字节,解密时也必须用 UTF-8 还原,中间任何一步用了平台默认编码都可能出问题。特别是在 Windows 上开发、Linux 上部署的场景,默认编码不一样,很容易踩。

还有一种隐蔽情况:属性值本身就是空字符串。System.setProperty(key, "")设进去之后,environment.getProperty返回的是空串而不是 null,某些框架会把空串当成"未配置"处理,走了默认值分支。如果加密后的值恰好解出空串,行为会很怪。建议在解密之后加一句判空,把空串转成 null 或者直接抛异常。

5.3 常见问题速查表

现象可能原因处理方式
读到的是密文原文处理器未注册或 order 太靠前检查注册文件,order 设 LOWEST_PRECEDENCE
读到的是配置文件的值属性源被 addFirst 覆盖检查是否有自定义源插到最前面
启动报 InvalidKeyExceptionJDK 版本低,不支持 256 位升级 JDK 或装无限强度策略
报 AEADBadTagException密钥不匹配或密文损坏核对密钥版本,检查传输过程
报 Illegal base64 character密文中特殊字符被转义改用 URL 安全 Base64
@ConfigurationProperties 绑不上包装时丢了 Enumerable包装类继承 EnumerablePropertySource
测试单跑通过全跑失败系统属性污染@AfterAll 里清理系统属性
环境变量设了但读不到命名映射规则不匹配全大写、下划线分词

5.4 密钥管理和日志里的安全红线

有几个红线是必须守住的。

密钥不能出现在代码仓库里,包括测试代码。测试用的密钥要单独生成,只用于测试环境,而且要明确标注"仅测试使用"。

密钥不能和密文在同一个属性源。如果两部分都在application.yml里,那加密等于没做,任何一个能看配置文件的人都能解密。

日志里不能打印解密后的值。很多框架在 debug 级别会打印属性绑定的过程,如果你的属性名比较敏感(比如带password),要确认这些日志不会上线。

异常堆栈里不能带明文。Cipher.doFinal抛异常时不会带明文,但如果你自己包装异常时把原值拼进 message,那就漏了。前面给的SecretResolver里我只带了密钥版本,就是这个考虑。

还有一点容易被忽略:actuator 的/env和/configprops端点默认会做脱敏,脱敏规则是匹配password、secret、key、token这类关键词。如果你的属性名叫app.datasource.credential,不在这份名单里,就会被原样展示。这个名单可以自定义,但更稳妥的做法是直接限制这些端点的访问权限。

6. 上线前的加固清单

6.1 actuator 端点的脱敏处理

Spring Boot 的脱敏靠SanitizingFunction实现,2.6 之后可以自定义。默认规则只覆盖常见关键词,业务自定义的属性名很容易漏。

@Bean public SanitizingFunction customSanitizer() { return property -> { String name = property.getName().toLowerCase(); if (name.contains("credential") || name.contains("passwd") || name.contains("private")) { return property.withValue("******"); } return property; }; }

这段代码注册到容器后,/env端点里匹配到的值都会显示成星号。注意这只是展示层面的保护,属性值在内存里仍然是明文,别把它当成加密手段。

更彻底的做法是把 actuator 端点限制在内网访问,或者干脆在生产环境只暴露health和info。配置项management.endpoints.web.exposure.include设成health,info就够用了。

6.2 密钥轮换与多版本密钥的共存设计

密钥轮换是安全实践里的常规操作,但在配置加密这个场景下很容易做砸。核心思路是:解密时支持多个版本,加密时只用最新版本。

前面给的enc:v1:前缀就是为此设计的。轮换时,先生成 v2 密钥,把SecretResolver的 keys 映射表里同时放 v1 和 v2,重新加密所有敏感值并替换成enc:v2:前缀,确认没有 v1 的密文残留之后,再把 v1 从映射表里移除。

这个过程可以分多次发布完成,不需要一次性切换。第一版发布只加 v2 密钥的支持,密文还是 v1;第二版发布把密文换成 v2;第三版发布移除 v1 密钥。每次发布都可回滚,风险可控。

要注意的是,如果密钥来自外部服务,轮换时要考虑服务的可用性。密钥服务挂了会导致应用完全起不来,所以本地最好有一份降级密钥,或者把密钥缓存到本地文件。

6.3 测试环境与生产环境的隔离

测试环境的密钥和生产环境必须完全不同,这是基本要求。但实际项目里经常出现测试密钥硬编码在代码里、生产环境复用的情况。

我的做法是把密钥来源做成可配置的,通过app.secret.key-source指定,可选值是env、file、vault。测试环境用env,配合 CI 的变量注入;生产环境用file,挂载只读的密钥文件。这样同一份代码在不同环境走不同路径,不需要改代码。

同时要在启动时加一条校验:如果检测到当前是生产 Profile 但密钥来源是env且密钥长度不足,直接启动失败。宁可起不来,也别带着弱密钥跑起来。

另外,测试环境的数据最好也是脱敏的。有些团队为了图省事,把生产数据导到测试库,密码还是同一套,这种时候加密配置就形同虚设了。

我个人在实际操作中的体会是,配置加密这件事真正的难点不在算法,而在"让所有人都按同一套规则来"。代码里写得再完善,运维在启动脚本里直接写明文,整条链路就破了。所以落地的时候,除了技术方案,最好再配一条启动校验:检测到关键属性没有加密前缀时打一条 WARN 日志,慢慢把习惯纠正过来。踩过几次坑之后我发现,日志比文档管用得多。

返回列表