From ae93dcadb6237ce074c6c9ad17bb80ec7f69741c Mon Sep 17 00:00:00 2001 From: dongmucat <1127093059@qq.com> Date: Thu, 9 Jul 2026 15:11:45 +0800 Subject: [PATCH] fix(deploy): handle release anon cookie placeholder Issue: ISSUE-69 GitHub: iflytek/skillhub#574 Signed-off-by: dongmucat <1127093059@qq.com> --- .env.release.draft | 5 ++- .env.release.example | 5 ++- README.md | 4 ++ README_zh.md | 4 +- docs/09-deployment.md | 6 +++ .../deployment/single-machine.md | 4 +- .../deployment/single-machine.md | 5 ++- scripts/runtime.sh | 2 +- .../AnonymousDownloadIdentityService.java | 42 ++++++++++++++++--- .../AnonymousDownloadIdentityServiceTest.java | 30 ++++++++++++- 10 files changed, 95 insertions(+), 12 deletions(-) diff --git a/.env.release.draft b/.env.release.draft index c8f0872b..79fe7ca8 100644 --- a/.env.release.draft +++ b/.env.release.draft @@ -94,5 +94,8 @@ SKILLHUB_AUTH_PASSWORD_RESET_CODE_EXPIRY=PT10M SKILLHUB_AUTH_PASSWORD_RESET_FROM_ADDRESS=noreply@example.com SKILLHUB_AUTH_PASSWORD_RESET_FROM_NAME=SkillHub -# Required for signing anonymous download rate-limit cookies. Use a unique random value per deployment. +# Required for signing anonymous download rate-limit cookies. +# Use a unique persistent 32+ character random value per deployment. +# Leaving the release template placeholder is only acceptable for quick smoke tests; the server +# will generate a runtime-only secret that is not stable across restarts or replicas. SKILLHUB_DOWNLOAD_ANON_COOKIE_SECRET=replace-with-random-download-secret-32-bytes diff --git a/.env.release.example b/.env.release.example index 2d30c7bc..bac073b4 100644 --- a/.env.release.example +++ b/.env.release.example @@ -104,8 +104,11 @@ SKILLHUB_AUTH_PASSWORD_RESET_FROM_NAME=SkillHub # Security scanner is enabled by default. Set to false to disable scanning. SKILLHUB_SECURITY_SCANNER_ENABLED=true -# Required for signing anonymous download rate-limit cookies. Use a unique random value per deployment. +# Required for signing anonymous download rate-limit cookies. # runtime.sh generates and persists one automatically when this placeholder is still present. +# Direct docker compose quickstarts can boot with this placeholder; the server replaces it with +# a runtime-only random secret. For production, replace it with a persistent 32+ character value +# and keep it private so cookies remain stable across restarts and replicas. SKILLHUB_DOWNLOAD_ANON_COOKIE_SECRET=replace-with-random-download-secret-32-bytes # Scanner LLM configuration (optional, for AI-powered scanning features) diff --git a/README.md b/README.md index d47ef5cc..4e4d8847 100644 --- a/README.md +++ b/README.md @@ -81,6 +81,7 @@ curl -fsSL https://imageless.oss-cn-beijing.aliyuncs.com/runtime.sh | sh -s -- u ``` The default command pulls the `latest` stable release images. Use `--version edge` if you want the newest build from `main`. +`runtime.sh` also generates and persists `SKILLHUB_DOWNLOAD_ANON_COOKIE_SECRET` when the release template still contains the placeholder. **Configure public URL (recommended for production):** @@ -245,6 +246,8 @@ curl -fsSL https://imageless.oss-cn-beijing.aliyuncs.com/runtime.sh | sh -s -- u cp .env.release.example .env.release ``` +For quick smoke tests, the release placeholder for `SKILLHUB_DOWNLOAD_ANON_COOKIE_SECRET` can boot because the server replaces it with a runtime-only random secret. Before production validation, set a persistent 32+ character value, for example the output of `openssl rand -hex 32`, so cookies remain stable across restarts and replicas. + Recommended image tags: - `SKILLHUB_VERSION=latest` for the latest stable release (default) @@ -284,6 +287,7 @@ Recommended production baseline: - set `SKILLHUB_PUBLIC_BASE_URL` to the final HTTPS entrypoint - keep PostgreSQL / Redis bound to `127.0.0.1` +- replace `SKILLHUB_DOWNLOAD_ANON_COOKIE_SECRET` with a persistent 32+ character random value (`validate-release-config.sh` rejects the release placeholder) - use external S3 / OSS via `SKILLHUB_STORAGE_S3_*` - change `BOOTSTRAP_ADMIN_PASSWORD` to a strong password (`validate-release-config.sh` rejects the default `ChangeMe!2026`) - rotate or disable the bootstrap admin after initial setup diff --git a/README_zh.md b/README_zh.md index 80e65b75..93e0e335 100644 --- a/README_zh.md +++ b/README_zh.md @@ -53,6 +53,7 @@ curl -fsSL https://imageless.oss-cn-beijing.aliyuncs.com/runtime.sh | sh -s -- u ``` 默认命令会拉取 `latest` 稳定版镜像;如果你想跟随 `main` 的最新构建,请显式传 `--version edge`。 +当发布模板仍保留占位值时,`runtime.sh` 会自动生成并持久化 `SKILLHUB_DOWNLOAD_ANON_COOKIE_SECRET`。 **配置公网访问地址(生产环境推荐):** @@ -99,7 +100,8 @@ curl -fsSL https://imageless.oss-cn-beijing.aliyuncs.com/runtime.sh | sh -s -- u 通过 `runtime.sh` 或 `compose.release.yml` 部署时,发布模板同样默认开启管理员, 使用相同的默认账号密码(`admin` / `ChangeMe!2026`),零配置即可登录。 -**生产环境请务必修改密码**——`validate-release-config.sh` 会拒绝默认值 +如果直接使用 `compose.release.yml` 做快速验证,匿名下载 Cookie Secret 占位值会由服务端替换为仅本次运行有效的随机值。 +**生产环境请务必修改密码,并将 `SKILLHUB_DOWNLOAD_ANON_COOKIE_SECRET` 替换为持久的 32 字符以上随机值**——`validate-release-config.sh` 会拒绝默认值和占位值。 ### 停止服务 diff --git a/docs/09-deployment.md b/docs/09-deployment.md index fe631d80..3b29be55 100644 --- a/docs/09-deployment.md +++ b/docs/09-deployment.md @@ -102,6 +102,11 @@ make validate-release-config docker compose --env-file .env.release -f compose.release.yml up -d ``` +快速冒烟验证时,可以暂时保留 `.env.release.example` 中的 +`SKILLHUB_DOWNLOAD_ANON_COOKIE_SECRET` 占位值;服务端会生成仅本次运行有效的随机签名密钥, +不会使用占位值本身作为 Cookie HMAC 密钥。生产环境必须在执行 `make validate-release-config` +前将该变量替换为持久的 32 字符以上随机值(例如 `openssl rand -hex 32` 的输出),否则校验会失败。 + 默认访问地址: - Web UI: `SKILLHUB_PUBLIC_BASE_URL` @@ -117,6 +122,7 @@ docker compose --env-file .env.release -f compose.release.yml up -d - `.env.release.example` - 运行时变量模板 - 包含镜像名、镜像版本、端口、数据库凭证、外部 OSS、站点公网地址和首登管理员参数 + - `SKILLHUB_DOWNLOAD_ANON_COOKIE_SECRET` 默认占位值只用于快速验证;生产环境必须替换为持久随机值 - `scripts/validate-release-config.sh` - 在启动前校验 `.env.release` - 可提前拦截占位值、URL 格式错误、缺失的 OSS 凭据、危险的明文默认值 diff --git a/document/docs/02-administration/deployment/single-machine.md b/document/docs/02-administration/deployment/single-machine.md index 8673a8fd..45ba0805 100644 --- a/document/docs/02-administration/deployment/single-machine.md +++ b/document/docs/02-administration/deployment/single-machine.md @@ -26,7 +26,7 @@ cd skillhub cp .env.release.example .env.release # 3. 编辑配置 -# 修改 .env.release 中的配置项,特别是密码和公网地址 +# 修改 .env.release 中的配置项,特别是密码、公网地址和 SKILLHUB_DOWNLOAD_ANON_COOKIE_SECRET # 4. 验证配置 make validate-release-config @@ -35,6 +35,8 @@ make validate-release-config docker compose --env-file .env.release -f compose.release.yml up -d ``` +快速验证时可以暂时保留 `SKILLHUB_DOWNLOAD_ANON_COOKIE_SECRET` 的发布模板占位值,服务端会生成仅本次运行有效的随机签名密钥。生产环境必须替换为持久的 32 字符以上随机值(例如 `openssl rand -hex 32` 的输出),否则 `make validate-release-config` 会拒绝该配置。 + ## 配置说明 详见 [配置说明](./configuration) 文档。 diff --git a/document/i18n/en/docusaurus-plugin-content-docs/current/02-administration/deployment/single-machine.md b/document/i18n/en/docusaurus-plugin-content-docs/current/02-administration/deployment/single-machine.md index 711156b4..ba2e094c 100644 --- a/document/i18n/en/docusaurus-plugin-content-docs/current/02-administration/deployment/single-machine.md +++ b/document/i18n/en/docusaurus-plugin-content-docs/current/02-administration/deployment/single-machine.md @@ -26,7 +26,8 @@ cd skillhub cp .env.release.example .env.release # 3. Edit configuration -# Modify configuration items in .env.release, especially passwords and public URLs +# Modify configuration items in .env.release, especially passwords, public URLs, +# and SKILLHUB_DOWNLOAD_ANON_COOKIE_SECRET # 4. Validate configuration make validate-release-config @@ -35,6 +36,8 @@ make validate-release-config docker compose --env-file .env.release -f compose.release.yml up -d ``` +For quick smoke tests, you may temporarily leave the release-template placeholder for `SKILLHUB_DOWNLOAD_ANON_COOKIE_SECRET`; the server replaces it with a runtime-only random signing secret. For production, replace it with a persistent 32+ character random value, for example the output of `openssl rand -hex 32`, or `make validate-release-config` will reject the configuration. + ## Configuration See [Configuration](./configuration) documentation for details. diff --git a/scripts/runtime.sh b/scripts/runtime.sh index fec6375e..88597e14 100755 --- a/scripts/runtime.sh +++ b/scripts/runtime.sh @@ -234,7 +234,7 @@ ensure_anonymous_download_secret() { fi set_env_value "SKILLHUB_DOWNLOAD_ANON_COOKIE_SECRET" "$(generate_secret)" - echo "Generated SKILLHUB_DOWNLOAD_ANON_COOKIE_SECRET in $ENV_FILE" + echo "Generated SKILLHUB_DOWNLOAD_ANON_COOKIE_SECRET in $ENV_FILE; keep this file private." } wait_for_postgres_ready() { diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/ratelimit/AnonymousDownloadIdentityService.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/ratelimit/AnonymousDownloadIdentityService.java index a166c666..03e6acc3 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/ratelimit/AnonymousDownloadIdentityService.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/ratelimit/AnonymousDownloadIdentityService.java @@ -15,6 +15,8 @@ import java.util.Base64; import java.util.Set; import javax.crypto.Mac; import javax.crypto.spec.SecretKeySpec; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import org.springframework.http.ResponseCookie; import org.springframework.stereotype.Component; @@ -25,17 +27,19 @@ import org.springframework.stereotype.Component; @Component public class AnonymousDownloadIdentityService { + private static final Logger log = LoggerFactory.getLogger(AnonymousDownloadIdentityService.class); private static final String COOKIE_VERSION = "v1"; private static final int MIN_SECRET_LENGTH = 32; + private static final String RELEASE_EXAMPLE_SECRET_PLACEHOLDER = "replace-with-random-download-secret-32-bytes"; private static final Set DISALLOWED_SECRET_VALUES = Set.of( "change-me-in-production", - "replace-me", - "replace-with-random-download-secret-32-bytes" + "replace-me" ); private static final SecureRandom RANDOM = new SecureRandom(); private final DownloadRateLimitProperties properties; private final ClientIpResolver clientIpResolver; + private volatile String anonymousCookieSecret; public AnonymousDownloadIdentityService(DownloadRateLimitProperties properties, ClientIpResolver clientIpResolver) { @@ -44,18 +48,26 @@ public class AnonymousDownloadIdentityService { } @PostConstruct - void validateAnonymousCookieSecret() { + synchronized void validateAnonymousCookieSecret() { String secret = properties.getAnonymousCookieSecret(); if (secret == null || secret.isBlank()) { throw new IllegalStateException("SKILLHUB_DOWNLOAD_ANON_COOKIE_SECRET is required"); } String trimmedSecret = secret.trim(); - if (DISALLOWED_SECRET_VALUES.contains(trimmedSecret)) { + if (RELEASE_EXAMPLE_SECRET_PLACEHOLDER.equals(trimmedSecret)) { + anonymousCookieSecret = generateRuntimeSecret(); + log.warn("SKILLHUB_DOWNLOAD_ANON_COOKIE_SECRET uses the release template placeholder; " + + "generated a runtime-only anonymous download cookie secret. " + + "Set a persistent 32+ character value for production."); + return; + } + if (isDisallowedSecret(trimmedSecret)) { throw new IllegalStateException("SKILLHUB_DOWNLOAD_ANON_COOKIE_SECRET must not use the default placeholder"); } if (trimmedSecret.length() < MIN_SECRET_LENGTH) { throw new IllegalStateException("SKILLHUB_DOWNLOAD_ANON_COOKIE_SECRET must be at least 32 characters"); } + anonymousCookieSecret = trimmedSecret; } public AnonymousDownloadIdentity resolve(HttpServletRequest request, HttpServletResponse response) { @@ -126,13 +138,33 @@ public class AnonymousDownloadIdentityService { private byte[] sign(String value) { try { Mac mac = Mac.getInstance("HmacSHA256"); - mac.init(new SecretKeySpec(properties.getAnonymousCookieSecret().getBytes(StandardCharsets.UTF_8), "HmacSHA256")); + mac.init(new SecretKeySpec(signingSecret().getBytes(StandardCharsets.UTF_8), "HmacSHA256")); return mac.doFinal(value.getBytes(StandardCharsets.UTF_8)); } catch (GeneralSecurityException ex) { throw new IllegalStateException("Failed to sign anonymous download cookie", ex); } } + private boolean isDisallowedSecret(String secret) { + return DISALLOWED_SECRET_VALUES.contains(secret) + || secret.startsWith("TODO") + || secret.startsWith("todo") + || secret.startsWith("replace"); + } + + private String signingSecret() { + if (anonymousCookieSecret == null) { + validateAnonymousCookieSecret(); + } + return anonymousCookieSecret; + } + + private String generateRuntimeSecret() { + byte[] bytes = new byte[32]; + RANDOM.nextBytes(bytes); + return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes); + } + private String generateId() { byte[] bytes = new byte[16]; RANDOM.nextBytes(bytes); diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/ratelimit/AnonymousDownloadIdentityServiceTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/ratelimit/AnonymousDownloadIdentityServiceTest.java index 39603ebd..ad5f2db2 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/ratelimit/AnonymousDownloadIdentityServiceTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/ratelimit/AnonymousDownloadIdentityServiceTest.java @@ -1,20 +1,48 @@ package com.iflytek.skillhub.ratelimit; +import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; import com.iflytek.skillhub.config.DownloadRateLimitProperties; import org.junit.jupiter.api.Test; +import org.springframework.test.util.ReflectionTestUtils; class AnonymousDownloadIdentityServiceTest { @Test - void validateAnonymousCookieSecretRejectsReleaseExamplePlaceholder() { + void validateAnonymousCookieSecretGeneratesRuntimeSecretForReleaseExamplePlaceholder() { DownloadRateLimitProperties properties = new DownloadRateLimitProperties(); properties.setAnonymousCookieSecret("replace-with-random-download-secret-32-bytes"); AnonymousDownloadIdentityService service = new AnonymousDownloadIdentityService(properties, new ClientIpResolver()); + service.validateAnonymousCookieSecret(); + + String effectiveSecret = (String) ReflectionTestUtils.getField(service, "anonymousCookieSecret"); + assertThat(effectiveSecret) + .isNotNull() + .isNotEqualTo("replace-with-random-download-secret-32-bytes") + .hasSizeGreaterThanOrEqualTo(32); + } + + @Test + void validateAnonymousCookieSecretRejectsLegacyPlaceholders() { + DownloadRateLimitProperties properties = new DownloadRateLimitProperties(); + properties.setAnonymousCookieSecret("change-me-in-production"); + AnonymousDownloadIdentityService service = new AnonymousDownloadIdentityService(properties, new ClientIpResolver()); + assertThatThrownBy(service::validateAnonymousCookieSecret) .isInstanceOf(IllegalStateException.class) .hasMessageContaining("must not use the default placeholder"); } + + @Test + void validateAnonymousCookieSecretRejectsShortCustomSecret() { + DownloadRateLimitProperties properties = new DownloadRateLimitProperties(); + properties.setAnonymousCookieSecret("too-short"); + AnonymousDownloadIdentityService service = new AnonymousDownloadIdentityService(properties, new ClientIpResolver()); + + assertThatThrownBy(service::validateAnonymousCookieSecret) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("must be at least 32 characters"); + } }