fix(deploy): handle release anon cookie placeholder

Issue: ISSUE-69

GitHub: iflytek/skillhub#574
Signed-off-by: dongmucat <1127093059@qq.com>
This commit is contained in:
dongmucat 2026-07-09 15:11:45 +08:00
parent e32f05b375
commit ae93dcadb6
10 changed files with 95 additions and 12 deletions

View file

@ -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

View file

@ -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)

View file

@ -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

View file

@ -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` 会拒绝默认值和占位值。
### 停止服务

View file

@ -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 凭据、危险的明文默认值

View file

@ -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) 文档。

View file

@ -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.

View file

@ -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() {

View file

@ -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<String> 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);

View file

@ -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");
}
}