mirror of
https://github.com/iflytek/skillhub.git
synced 2026-08-27 11:14:59 +00:00
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:
parent
e32f05b375
commit
ae93dcadb6
10 changed files with 95 additions and 12 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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` 会拒绝默认值和占位值。
|
||||
|
||||
### 停止服务
|
||||
|
||||
|
|
|
|||
|
|
@ -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 凭据、危险的明文默认值
|
||||
|
|
|
|||
|
|
@ -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) 文档。
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue