fix(security): harden review findings

Signed-off-by: dongmucat <1127093059@qq.com>
This commit is contained in:
dongmucat 2026-06-11 18:42:31 +08:00
parent 4776550391
commit 40d7de8462
53 changed files with 1094 additions and 62 deletions

View file

@ -93,3 +93,6 @@ SPRING_MAIL_PROPERTIES_MAIL_SMTP_SSL_TRUST=
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.
SKILLHUB_DOWNLOAD_ANON_COOKIE_SECRET=replace-with-random-download-secret-32-bytes

View file

@ -104,6 +104,9 @@ 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.
SKILLHUB_DOWNLOAD_ANON_COOKIE_SECRET=replace-with-random-download-secret-32-bytes
# Scanner LLM configuration (optional, for AI-powered scanning features)
SKILL_SCANNER_LLM_API_KEY=
SKILL_SCANNER_LLM_BASE_URL=

View file

@ -4,10 +4,13 @@ on:
pull_request:
paths:
- 'scripts/**'
- 'Makefile'
- '.github/workflows/security.yml'
- '.github/workflows/pr-scripts.yml'
jobs:
publish-cli-test:
scripts-tests:
name: Script Regression Tests
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
@ -17,3 +20,6 @@ jobs:
with:
node-version: '21'
- run: bash scripts/tests/publish-cli-test.sh
- run: bash scripts/tests/validate-release-config-test.sh
- run: bash scripts/tests/dev-web-host-test.sh
- run: bash scripts/tests/workflow-security-test.sh

83
.github/workflows/security.yml vendored Normal file
View file

@ -0,0 +1,83 @@
name: Security
on:
pull_request:
types:
- opened
- synchronize
- reopened
- ready_for_review
push:
branches:
- main
schedule:
- cron: '23 3 * * 1'
workflow_dispatch:
permissions:
contents: read
jobs:
dependency-review:
name: Dependency Review
if: ${{ github.event_name == 'pull_request' && !github.event.pull_request.draft }}
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: read
steps:
- name: Check out repository
uses: actions/checkout@v4
- name: Review dependency changes
uses: actions/dependency-review-action@v4
codeql:
name: CodeQL (${{ matrix.language }})
if: ${{ github.event_name != 'pull_request' }}
runs-on: ubuntu-latest
permissions:
contents: read
security-events: write
strategy:
fail-fast: false
matrix:
include:
- language: java-kotlin
build-mode: manual
- language: javascript-typescript
build-mode: none
- language: python
build-mode: none
steps:
- name: Check out repository
uses: actions/checkout@v4
- name: Set up Java
if: matrix.language == 'java-kotlin'
uses: actions/setup-java@v4
with:
distribution: temurin
java-version: 21
cache: maven
- name: Ensure Maven wrapper is executable
if: matrix.language == 'java-kotlin'
run: chmod +x server/mvnw
- name: Initialize CodeQL
uses: github/codeql-action/init@v3
with:
languages: ${{ matrix.language }}
build-mode: ${{ matrix.build-mode }}
- name: Build Java for CodeQL
if: matrix.language == 'java-kotlin'
run: cd server && ./mvnw -q -DskipTests package
- name: Analyze
uses: github/codeql-action/analyze@v3
with:
category: /language:${{ matrix.language }}

View file

@ -6,6 +6,7 @@ DEV_WEB_PID := $(DEV_DIR)/web.pid
DEV_SERVER_LOG := $(DEV_DIR)/server.log
DEV_WEB_LOG := $(DEV_DIR)/web.log
DEV_WEB_URL := http://localhost:3000
DEV_WEB_HOST ?= 127.0.0.1
DEV_API_URL := http://localhost:8080
DEV_SCANNER_URL := http://localhost:8000
STAGING_API_URL := http://localhost:8080
@ -48,7 +49,7 @@ dev-all: ## 一键启动本地开发环境(依赖 + scanner + 后端 + 前端
echo "Frontend already running with PID $$(cat $(DEV_WEB_PID))"; \
else \
echo "Starting frontend..."; \
$(DEV_PROCESS) start --pid-file $(DEV_WEB_PID) --log-file $(DEV_WEB_LOG) --cwd web -- pnpm exec vite --host 0.0.0.0 --strictPort >/dev/null; \
$(DEV_PROCESS) start --pid-file $(DEV_WEB_PID) --log-file $(DEV_WEB_LOG) --cwd web -- pnpm exec vite --host $(DEV_WEB_HOST) --strictPort >/dev/null; \
fi
@echo "Waiting for backend on $(DEV_API_URL) ..."
@backend_ready=0; \
@ -126,7 +127,7 @@ dev-all: ## 一键启动本地开发环境(依赖 + scanner + 后端 + 前端
@echo " Frontend: $(DEV_WEB_LOG)"
dev-server: ## 启动后端开发服务器
cd server && /bin/sh -lc '$(DEV_SERVER_PREPARE) && exec $(DEV_SERVER_CMD)'
cd server && /bin/sh -lc '$(DEV_SERVER_PREPARE) && exec env $(DEV_SERVER_SCANNER_ENV) $(DEV_SERVER_CMD)'
dev-server-restart: ## 重启后端开发服务器
@mkdir -p $(DEV_DIR)
@ -237,7 +238,7 @@ web-install-ci: ## 以 CI 方式安装前端依赖
cd web && CI=true pnpm install --frozen-lockfile
dev-web: ## 启动前端开发服务器
cd web && pnpm run dev
cd web && pnpm exec vite --host $(DEV_WEB_HOST)
build-frontend: web-deps ## 构建前端
cd web && pnpm run build

View file

@ -1,6 +1,14 @@
import { mkdir, readdir, readFile, writeFile } from 'node:fs/promises'
import { dirname, isAbsolute, join, relative, resolve } from 'node:path'
import { zipSync, unzipSync } from 'fflate'
import { MAX_PACKAGE_BYTES } from './download'
const MAX_ZIP_ENTRIES = 500
const MAX_SINGLE_FILE_BYTES = 10 * 1024 * 1024
const EOCD_SIGNATURE = 0x06054b50
const CENTRAL_DIRECTORY_SIGNATURE = 0x02014b50
const ZIP64_MARKER_16 = 0xffff
const ZIP64_MARKER_32 = 0xffffffff
/**
* Extract a zip archive buffer into the target directory.
@ -8,7 +16,9 @@ import { zipSync, unzipSync } from 'fflate'
*/
export async function extractZip(buffer: ArrayBuffer, targetDir: string): Promise<void> {
await mkdir(targetDir, { recursive: true })
const files = unzipSync(new Uint8Array(buffer))
const archive = new Uint8Array(buffer)
validateZipCentralDirectory(archive)
const files = unzipSync(archive)
for (const [name, data] of Object.entries(files)) {
const filePath = safeJoin(targetDir, name)
if (name.endsWith('/')) {
@ -20,6 +30,78 @@ export async function extractZip(buffer: ArrayBuffer, targetDir: string): Promis
}
}
function validateZipCentralDirectory(archive: Uint8Array): void {
const view = new DataView(archive.buffer, archive.byteOffset, archive.byteLength)
const eocdOffset = findEndOfCentralDirectory(view)
if (eocdOffset < 0) {
throw new Error('invalid zip central directory')
}
const diskNumber = view.getUint16(eocdOffset + 4, true)
const centralDirectoryDisk = view.getUint16(eocdOffset + 6, true)
const entriesOnDisk = view.getUint16(eocdOffset + 8, true)
const totalEntries = view.getUint16(eocdOffset + 10, true)
const centralDirectorySize = view.getUint32(eocdOffset + 12, true)
const centralDirectoryOffset = view.getUint32(eocdOffset + 16, true)
if (
entriesOnDisk === ZIP64_MARKER_16 ||
totalEntries === ZIP64_MARKER_16 ||
centralDirectorySize === ZIP64_MARKER_32 ||
centralDirectoryOffset === ZIP64_MARKER_32
) {
throw new Error('zip64 archives are not supported')
}
if (diskNumber !== 0 || centralDirectoryDisk !== 0 || entriesOnDisk !== totalEntries) {
throw new Error('multi-disk zip archives are not supported')
}
if (totalEntries > MAX_ZIP_ENTRIES) {
throw new Error('zip entry count exceeds limit')
}
if (centralDirectoryOffset + centralDirectorySize > archive.byteLength) {
throw new Error('invalid zip central directory')
}
let offset = centralDirectoryOffset
let totalUncompressedSize = 0
const decoder = new TextDecoder()
for (let i = 0; i < totalEntries; i++) {
if (offset + 46 > archive.byteLength || view.getUint32(offset, true) !== CENTRAL_DIRECTORY_SIGNATURE) {
throw new Error('invalid zip central directory')
}
const uncompressedSize = view.getUint32(offset + 24, true)
const nameLength = view.getUint16(offset + 28, true)
const extraLength = view.getUint16(offset + 30, true)
const commentLength = view.getUint16(offset + 32, true)
const nameStart = offset + 46
const nameEnd = nameStart + nameLength
const nextOffset = nameEnd + extraLength + commentLength
if (nameEnd > archive.byteLength || nextOffset > archive.byteLength) {
throw new Error('invalid zip central directory')
}
const entryName = decoder.decode(archive.subarray(nameStart, nameEnd))
if (!entryName.endsWith('/') && uncompressedSize > MAX_SINGLE_FILE_BYTES) {
throw new Error('zip entry size exceeds limit')
}
totalUncompressedSize += uncompressedSize
if (totalUncompressedSize > MAX_PACKAGE_BYTES) {
throw new Error('zip total uncompressed size exceeds limit')
}
offset = nextOffset
}
}
function findEndOfCentralDirectory(view: DataView): number {
const minOffset = Math.max(0, view.byteLength - 0xffff - 22)
for (let offset = view.byteLength - 22; offset >= minOffset; offset--) {
if (view.getUint32(offset, true) === EOCD_SIGNATURE) {
return offset
}
}
return -1
}
/**
* Create a zip archive from a directory.
* Returns the archive as a Blob.

View file

@ -0,0 +1,56 @@
import { EXIT } from '../shared/constants'
import { CliError } from '../shared/errors'
export const MAX_PACKAGE_BYTES = 100 * 1024 * 1024
export async function readBoundedResponseBody(response: Response, maxBytes = MAX_PACKAGE_BYTES): Promise<ArrayBuffer> {
const contentLength = response.headers.get('content-length')
if (contentLength !== null) {
const declaredLength = Number(contentLength)
if (Number.isFinite(declaredLength) && declaredLength > maxBytes) {
throw new CliError('download exceeds maximum package size', EXIT.network, {
contentLength: declaredLength,
maxBytes
})
}
}
if (!response.body) {
const buffer = await response.arrayBuffer()
if (buffer.byteLength > maxBytes) {
throw new CliError('download exceeds maximum package size', EXIT.network, {
receivedBytes: buffer.byteLength,
maxBytes
})
}
return buffer
}
const reader = response.body.getReader()
const chunks: Uint8Array[] = []
let receivedBytes = 0
for (;;) {
const { done, value } = await reader.read()
if (done) {
break
}
receivedBytes += value.byteLength
if (receivedBytes > maxBytes) {
await reader.cancel()
throw new CliError('download exceeds maximum package size', EXIT.network, {
receivedBytes,
maxBytes
})
}
chunks.push(value)
}
const result = new Uint8Array(receivedBytes)
let offset = 0
for (const chunk of chunks) {
result.set(chunk, offset)
offset += chunk.byteLength
}
return result.buffer
}

View file

@ -5,6 +5,7 @@ import { InventoryStore } from '../stores/inventory-store'
import { CliError } from '../shared/errors'
import { EXIT } from '../shared/constants'
import { extractZip } from '../platform/archive'
import { readBoundedResponseBody } from '../platform/download'
import { pathExists } from '../platform/paths'
import type { AgentCandidate } from '../agents/types'
@ -23,7 +24,7 @@ export async function installSkill(options: InstallOptions): Promise<{ installed
const client = new SkillHubClient(options.registry, options.token)
const resolved = await client.resolve(options.namespace, options.slug, options.version)
const response = await client.download(options.namespace, options.slug, resolved.version)
const buffer = await response.arrayBuffer()
const buffer = await readBoundedResponseBody(response)
const installed: Array<{ agent: string; dir: string }> = []
const store = new InventoryStore(options.home)

View file

@ -56,4 +56,32 @@ describe('archive helpers', () => {
const entries = await readdir(target)
expect(entries).toEqual([])
})
test('rejects zip archives with more than 500 entries before extraction', async () => {
const target = await mkdtemp(join(tmpdir(), 'skillhub-archive-many-'))
const manyEntries = Object.fromEntries(
Array.from({ length: 501 }, (_, index) => [`file-${index}.txt`, new Uint8Array(0)])
)
const archive = zipSync(manyEntries)
await expect(extractZip(archive.buffer as ArrayBuffer, target)).rejects.toThrow('zip entry count exceeds limit')
})
test('rejects zip entries larger than 10 MiB before extraction', async () => {
const target = await mkdtemp(join(tmpdir(), 'skillhub-archive-large-file-'))
const archive = zipSync({ 'large.bin': new Uint8Array(10 * 1024 * 1024 + 1) })
await expect(extractZip(archive.buffer as ArrayBuffer, target)).rejects.toThrow('zip entry size exceeds limit')
})
test('rejects zip archives larger than 100 MiB after decompression before extraction', async () => {
const target = await mkdtemp(join(tmpdir(), 'skillhub-archive-large-total-'))
const oneMiB = new Uint8Array(1024 * 1024)
const entries = Object.fromEntries(
Array.from({ length: 101 }, (_, index) => [`file-${index}.bin`, oneMiB])
)
const archive = zipSync(entries)
await expect(extractZip(archive.buffer as ArrayBuffer, target)).rejects.toThrow('zip total uncompressed size exceeds limit')
})
})

View file

@ -21,6 +21,13 @@ function installFetch(zipEntries: Record<string, string>): typeof fetch {
Object.entries(zipEntries).map(([name, content]) => [name, new TextEncoder().encode(content)])
))
return installFetchWithDownloadResponse(new Response(
archive.buffer.slice(archive.byteOffset, archive.byteOffset + archive.byteLength) as ArrayBuffer,
{ status: 200 }
))
}
function installFetchWithDownloadResponse(downloadResponse: Response): typeof fetch {
const fakeFetch = async (input: URL | RequestInfo) => {
const path = new URL(String(input)).pathname
if (path.endsWith('/resolve')) {
@ -37,8 +44,7 @@ function installFetch(zipEntries: Record<string, string>): typeof fetch {
})
}
if (path.endsWith('/download')) {
const body = archive.buffer.slice(archive.byteOffset, archive.byteOffset + archive.byteLength) as ArrayBuffer
return new Response(body, { status: 200 })
return downloadResponse.clone()
}
return Response.json({ code: 404 }, { status: 404 })
}
@ -125,4 +131,20 @@ describe('installSkill', () => {
expect(inventory.items[0].targets).toHaveLength(1)
expect(inventory.items[0].targets[0].installDir).toBe(skillDir)
})
test('rejects downloads whose content-length exceeds the package limit', async () => {
globalThis.fetch = installFetchWithDownloadResponse(new Response(new Uint8Array(0), {
status: 200,
headers: { 'Content-Length': String(100 * 1024 * 1024 + 1) }
}))
const rootDir = await mkdtemp(join(tmpdir(), 'skillhub-install-root-'))
await expect(installSkill({
registry: 'http://registry.test',
namespace: 'global',
slug: 'demo',
targets: [{ agent: 'codex', rootDir, scope: 'project', source: 'explicit' }],
force: false
})).rejects.toThrow('download exceeds maximum package size')
})
})

View file

@ -58,6 +58,7 @@ services:
SESSION_COOKIE_SECURE: ${SESSION_COOKIE_SECURE:-false}
SKILLHUB_PUBLIC_BASE_URL: ${SKILLHUB_PUBLIC_BASE_URL:-}
DEVICE_AUTH_VERIFICATION_URI: ${DEVICE_AUTH_VERIFICATION_URI:-}
SKILLHUB_DOWNLOAD_ANON_COOKIE_SECRET: ${SKILLHUB_DOWNLOAD_ANON_COOKIE_SECRET:?required}
SKILLHUB_STORAGE_PROVIDER: ${SKILLHUB_STORAGE_PROVIDER:-s3}
STORAGE_BASE_PATH: /var/lib/skillhub/storage
SKILLHUB_STORAGE_S3_ENDPOINT: ${SKILLHUB_STORAGE_S3_ENDPOINT:-}
@ -99,6 +100,8 @@ services:
condition: service_healthy
redis:
condition: service_healthy
skill-scanner:
condition: service_healthy
healthcheck:
test: ["CMD", "wget", "-qO-", "http://localhost:8080/actuator/health"]
interval: 10s

View file

@ -42,11 +42,17 @@ services:
BOOTSTRAP_ADMIN_EMAIL: admin@skillhub.local
OAUTH2_GITHUB_CLIENT_ID: local-placeholder
OAUTH2_GITHUB_CLIENT_SECRET: local-placeholder
SKILLHUB_DOWNLOAD_ANON_COOKIE_SECRET: staging-download-secret-32-bytes
SKILLHUB_SECURITY_SCANNER_ENABLED: "true"
SKILLHUB_SECURITY_SCANNER_URL: http://skill-scanner:8000
SKILLHUB_SECURITY_SCANNER_MODE: upload
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_healthy
skill-scanner:
condition: service_healthy
healthcheck:
test: ["CMD", "wget", "-qO-", "http://localhost:8080/actuator/health"]
interval: 10s

View file

@ -18,7 +18,7 @@ skillhub:
security:
scanner:
# 基础配置
enabled: ${SKILLHUB_SECURITY_SCANNER_ENABLED:false}
enabled: ${SKILLHUB_SECURITY_SCANNER_ENABLED:true}
base-url: ${SKILLHUB_SECURITY_SCANNER_URL:http://localhost:8000}
mode: ${SKILLHUB_SECURITY_SCANNER_MODE:local}
@ -48,20 +48,20 @@ skillhub:
#### `enabled`
- **类型**Boolean
- **默认值**`false`
- **默认值**`true`
- **环境变量**`SKILLHUB_SECURITY_SCANNER_ENABLED`
- **说明**:是否启用安全扫描功能
- **影响**
- `true`:技能包发布时会触发安全扫描
- `false`跳过安全扫描,直接进入审核流程
- `false`仅允许 `PRIVATE` 技能跳过扫描;`PUBLIC` / `NAMESPACE_ONLY` 发布会失败并提示必须启用 Scanner
**示例**
```yaml
# 开发环境:禁用扫描
# 仅本地私有技能调试:禁用扫描
enabled: false
# 生产环境:启用扫描
# 公共或命名空间可见发布:启用扫描
enabled: true
```
@ -103,8 +103,8 @@ base-url: https://scanner.example.com
**示例**
```yaml
# Docker Compose 环境(共享卷
mode: local
# Docker Compose 环境(Scanner 独立容器,无共享发布目录
mode: upload
# Kubernetes 环境(独立 Pod
mode: upload
@ -298,7 +298,7 @@ services:
environment:
- SKILLHUB_SECURITY_SCANNER_ENABLED=true
- SKILLHUB_SECURITY_SCANNER_URL=http://skill-scanner:8000
- SKILLHUB_SECURITY_SCANNER_MODE=local
- SKILLHUB_SECURITY_SCANNER_MODE=upload
- SKILLHUB_SCANNER_USE_BEHAVIORAL=false
- SKILLHUB_SCANNER_USE_LLM=false
- SKILLHUB_SCANNER_USE_META=true
@ -348,9 +348,9 @@ stringData:
skillhub:
security:
scanner:
enabled: false # 开发时禁用扫描,加快迭代速度
enabled: true # 默认启用PUBLIC/NAMESPACE_ONLY 发布依赖扫描
base-url: http://localhost:8000
mode: local
mode: upload
analyzers:
meta: true # 只启用元数据分析
policy:
@ -366,7 +366,7 @@ skillhub:
scanner:
enabled: true # 测试环境启用扫描
base-url: http://skill-scanner:8000
mode: local
mode: upload
analyzers:
behavioral: true
meta: true

View file

@ -36,8 +36,8 @@ echo "Target: $BASE_URL"
echo
check "Health endpoint" "$BASE_URL/actuator/health" "200"
check "Prometheus metrics" "$BASE_URL/actuator/prometheus" "200"
check "Namespaces API" "$BASE_URL/api/v1/namespaces" "200"
check "Prometheus metrics requires auth" "$BASE_URL/actuator/prometheus" "401"
check "Namespaces API requires auth" "$BASE_URL/api/v1/namespaces" "401"
check "Auth required" "$BASE_URL/api/v1/auth/me" "401"
curl -s -c "$COOKIE_JAR" "$BASE_URL/api/v1/auth/me" >/dev/null
@ -67,6 +67,15 @@ else
FAIL=$((FAIL + 1))
fi
NAMESPACES_AUTH_STATUS="$(curl --max-time 10 -s -o /dev/null -w "%{http_code}" -b "$COOKIE_JAR" "$BASE_URL/api/v1/namespaces" || true)"
if [[ "$NAMESPACES_AUTH_STATUS" == "200" ]]; then
echo "PASS: Namespaces API with session (HTTP $NAMESPACES_AUTH_STATUS)"
PASS=$((PASS + 1))
else
echo "FAIL: Namespaces API with session (got $NAMESPACES_AUTH_STATUS)"
FAIL=$((FAIL + 1))
fi
CHANGE_PASSWORD_STATUS="$(curl --max-time 10 -s -o /dev/null -w "%{http_code}" \
-X POST "$BASE_URL/api/v1/auth/local/change-password" \
-b "$COOKIE_JAR" \

View file

@ -0,0 +1,25 @@
#!/usr/bin/env bash
set -euo pipefail
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
MAKEFILE="$REPO_ROOT/Makefile"
fail() {
echo "FAIL: $*" >&2
exit 1
}
grep -Eq '^DEV_WEB_HOST[[:space:]]*\?=[[:space:]]*127\.0\.0\.1$' "$MAKEFILE" \
|| fail "Makefile must default DEV_WEB_HOST to 127.0.0.1"
grep -Fq 'pnpm exec vite --host $(DEV_WEB_HOST)' "$MAKEFILE" \
|| fail "dev web startup must pass DEV_WEB_HOST to vite"
grep -Fq "cd server && /bin/sh -lc '\$(DEV_SERVER_PREPARE) && exec env \$(DEV_SERVER_SCANNER_ENV) \$(DEV_SERVER_CMD)'" "$MAKEFILE" \
|| fail "dev-server must inject scanner upload environment"
if grep -Fq 'pnpm exec vite --host 0.0.0.0' "$MAKEFILE"; then
fail "dev web startup must not bind Vite to 0.0.0.0 by default"
fi
echo "dev-web-host-test passed"

View file

@ -0,0 +1,83 @@
#!/usr/bin/env bash
set -euo pipefail
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
SCRIPT="$REPO_ROOT/scripts/validate-release-config.sh"
TMP_DIRS=()
cleanup() {
local d
for d in "${TMP_DIRS[@]+"${TMP_DIRS[@]}"}"; do
rm -rf "$d"
done
}
trap cleanup EXIT
new_tmp() {
local d
d="$(mktemp -d)"
TMP_DIRS+=("$d")
echo "$d"
}
fail() {
echo "FAIL: $*" >&2
exit 1
}
write_env() {
local file="$1"
local secret="${2:-}"
local include_secret="${3:-yes}"
cat >"$file" <<EOF
SKILLHUB_PUBLIC_BASE_URL=https://skillhub.example.com
POSTGRES_DB=skillhub
POSTGRES_USER=skillhub
POSTGRES_PASSWORD=strong-postgres-password
SESSION_COOKIE_SECURE=true
BOOTSTRAP_ADMIN_ENABLED=false
SKILLHUB_STORAGE_PROVIDER=s3
SKILLHUB_STORAGE_S3_ENDPOINT=https://storage.example.com
SKILLHUB_STORAGE_S3_BUCKET=skillhub
SKILLHUB_STORAGE_S3_ACCESS_KEY=release-access-key
SKILLHUB_STORAGE_S3_SECRET_KEY=release-secret-key
SKILLHUB_STORAGE_S3_REGION=us-east-1
SKILLHUB_STORAGE_S3_FORCE_PATH_STYLE=false
SKILLHUB_STORAGE_S3_AUTO_CREATE_BUCKET=false
EOF
if [[ "$include_secret" == "yes" ]]; then
printf 'SKILLHUB_DOWNLOAD_ANON_COOKIE_SECRET=%s\n' "$secret" >>"$file"
fi
}
expect_fail() {
local file="$1"
local expected="$2"
local output
if output="$("$SCRIPT" "$file" 2>&1)"; then
fail "expected validation to fail for $file"
fi
if [[ "$output" != *"$expected"* ]]; then
fail "expected output to contain '$expected', got: $output"
fi
}
tmp="$(new_tmp)"
valid_env="$tmp/valid.env"
write_env "$valid_env" "release-download-secret-32-bytes-minimum"
"$SCRIPT" "$valid_env" >/dev/null
missing_env="$tmp/missing.env"
write_env "$missing_env" "" no
expect_fail "$missing_env" "SKILLHUB_DOWNLOAD_ANON_COOKIE_SECRET is required"
placeholder_env="$tmp/placeholder.env"
write_env "$placeholder_env" "change-me-in-production"
expect_fail "$placeholder_env" "SKILLHUB_DOWNLOAD_ANON_COOKIE_SECRET still uses placeholder/default value"
short_env="$tmp/short.env"
write_env "$short_env" "too-short"
expect_fail "$short_env" "SKILLHUB_DOWNLOAD_ANON_COOKIE_SECRET must be at least 32 characters"
echo "validate-release-config-test passed"

View file

@ -0,0 +1,33 @@
#!/usr/bin/env bash
set -euo pipefail
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
SECURITY_WORKFLOW="$REPO_ROOT/.github/workflows/security.yml"
PR_SCRIPTS_WORKFLOW="$REPO_ROOT/.github/workflows/pr-scripts.yml"
fail() {
echo "FAIL: $*" >&2
exit 1
}
[[ -f "$SECURITY_WORKFLOW" ]] || fail ".github/workflows/security.yml is required"
grep -Fq 'actions/dependency-review-action' "$SECURITY_WORKFLOW" \
|| fail "security workflow must run dependency review"
grep -Fq 'github/codeql-action/init' "$SECURITY_WORKFLOW" \
|| fail "security workflow must initialize CodeQL"
grep -Fq 'cd server && ./mvnw -q -DskipTests package' "$SECURITY_WORKFLOW" \
|| fail "security workflow must build Java with the server Maven wrapper"
grep -Fq 'security-events: write' "$SECURITY_WORKFLOW" \
|| fail "security workflow must grant SARIF upload permission"
grep -Fq '.github/workflows/security.yml' "$PR_SCRIPTS_WORKFLOW" \
|| fail "pr-scripts must run when security workflow changes"
grep -Fq 'bash scripts/tests/validate-release-config-test.sh' "$PR_SCRIPTS_WORKFLOW" \
|| fail "pr-scripts must run validate-release-config-test"
grep -Fq 'bash scripts/tests/dev-web-host-test.sh' "$PR_SCRIPTS_WORKFLOW" \
|| fail "pr-scripts must run dev-web-host-test"
grep -Fq 'bash scripts/tests/workflow-security-test.sh' "$PR_SCRIPTS_WORKFLOW" \
|| fail "pr-scripts must run workflow-security-test"
echo "workflow-security-test passed"

View file

@ -97,10 +97,26 @@ validate_port() {
esac
}
validate_min_length() {
var_name="$1"
min_length="$2"
eval "var_value=\${$var_name:-}"
if [ -z "$var_value" ]; then
return 0
fi
if [ "${#var_value}" -lt "$min_length" ]; then
error "$var_name must be at least $min_length characters"
fi
}
require_non_empty SKILLHUB_PUBLIC_BASE_URL
validate_url SKILLHUB_PUBLIC_BASE_URL
validate_no_trailing_slash SKILLHUB_PUBLIC_BASE_URL
require_non_empty SKILLHUB_DOWNLOAD_ANON_COOKIE_SECRET
reject_values SKILLHUB_DOWNLOAD_ANON_COOKIE_SECRET "change-me-in-production" "replace-me" "replace-with-random-download-secret-32-bytes"
validate_min_length SKILLHUB_DOWNLOAD_ANON_COOKIE_SECRET 32
reject_values POSTGRES_PASSWORD "change-this-postgres-password" "skillhub_demo" "skillhub_dev"
reject_values BOOTSTRAP_ADMIN_PASSWORD "replace-this-admin-password" "ChangeMe!2026" "Admin@2026"
if [ "${BOOTSTRAP_ADMIN_ENABLED:-false}" = "true" ]; then

View file

@ -10,7 +10,7 @@ public class DownloadRateLimitProperties {
private String anonymousCookieName = "skillhub_anon_dl";
private Duration anonymousCookieMaxAge = Duration.ofDays(30);
private String anonymousCookieSecret = "change-me-in-production";
private String anonymousCookieSecret;
public String getAnonymousCookieName() {
return anonymousCookieName;

View file

@ -7,7 +7,7 @@ import org.springframework.stereotype.Component;
@ConfigurationProperties(prefix = "skillhub.security.scanner")
public class SkillScannerProperties {
private boolean enabled = false;
private boolean enabled = true;
private String baseUrl = "http://localhost:8000";
private String healthPath = "/health";
private String scanPath = "/scan-upload";

View file

@ -31,6 +31,7 @@ import org.springframework.web.bind.annotation.*;
import java.util.List;
import java.util.Map;
import java.util.Set;
/**
* Namespace portal endpoints for discovery, membership management, and
@ -155,9 +156,13 @@ public class NamespaceController extends BaseApiController {
@GetMapping("/namespaces/{slug}/members")
public ApiResponse<PageResponse<MemberResponse>> listMembers(@PathVariable String slug,
Pageable pageable,
@RequestAttribute("userId") String userId) {
@RequestAttribute("userId") String userId,
@AuthenticationPrincipal PlatformPrincipal principal) {
Set<String> platformRoles = principal != null && principal.platformRoles() != null
? principal.platformRoles()
: Set.of();
return ok("response.success.read",
namespacePortalQueryAppService.listMembers(slug, pageable, userId));
namespacePortalQueryAppService.listMembers(slug, pageable, userId, platformRoles));
}
@GetMapping("/namespaces/{slug}/member-candidates")

View file

@ -1,6 +1,7 @@
package com.iflytek.skillhub.ratelimit;
import com.iflytek.skillhub.config.DownloadRateLimitProperties;
import jakarta.annotation.PostConstruct;
import jakarta.servlet.http.Cookie;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
@ -11,6 +12,7 @@ import java.security.SecureRandom;
import java.time.Duration;
import java.util.Arrays;
import java.util.Base64;
import java.util.Set;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import org.springframework.http.ResponseCookie;
@ -24,6 +26,12 @@ import org.springframework.stereotype.Component;
public class AnonymousDownloadIdentityService {
private static final String COOKIE_VERSION = "v1";
private static final int MIN_SECRET_LENGTH = 32;
private static final Set<String> DISALLOWED_SECRET_VALUES = Set.of(
"change-me-in-production",
"replace-me",
"replace-with-random-download-secret-32-bytes"
);
private static final SecureRandom RANDOM = new SecureRandom();
private final DownloadRateLimitProperties properties;
@ -35,6 +43,21 @@ public class AnonymousDownloadIdentityService {
this.clientIpResolver = clientIpResolver;
}
@PostConstruct
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)) {
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");
}
}
public AnonymousDownloadIdentity resolve(HttpServletRequest request, HttpServletResponse response) {
String ip = clientIpResolver.resolve(request);
String cookieId = extractValidCookieId(request);

View file

@ -8,6 +8,7 @@ import com.iflytek.skillhub.domain.namespace.NamespaceRepository;
import com.iflytek.skillhub.domain.namespace.NamespaceRole;
import com.iflytek.skillhub.domain.namespace.NamespaceService;
import com.iflytek.skillhub.domain.namespace.NamespaceStatus;
import com.iflytek.skillhub.domain.namespace.NamespaceType;
import com.iflytek.skillhub.domain.shared.exception.DomainForbiddenException;
import com.iflytek.skillhub.domain.user.UserAccount;
import com.iflytek.skillhub.domain.user.UserAccountRepository;
@ -18,6 +19,7 @@ import com.iflytek.skillhub.dto.PageResponse;
import java.util.Comparator;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.function.Function;
import java.util.stream.Collectors;
import org.springframework.data.domain.Page;
@ -111,9 +113,16 @@ public class NamespacePortalQueryAppService {
}
@Transactional(readOnly = true)
public PageResponse<MemberResponse> listMembers(String slug, Pageable pageable, String userId) {
public PageResponse<MemberResponse> listMembers(String slug, Pageable pageable, String userId, Set<String> platformRoles) {
Namespace namespace = namespaceService.getNamespaceBySlug(slug);
namespaceService.assertMember(namespace.getId(), userId);
if (namespace.getType() == NamespaceType.GLOBAL) {
Set<String> roles = platformRoles != null ? platformRoles : Set.of();
if (!roles.contains("SUPER_ADMIN") && !roles.contains("USER_ADMIN")) {
throw new DomainForbiddenException("error.namespace.global.members.platformAdmin.required");
}
} else {
namespaceService.assertMember(namespace.getId(), userId);
}
Page<NamespaceMember> members = namespaceMemberService.listMembers(namespace.getId(), pageable);
List<String> memberUserIds = members.getContent().stream()

View file

@ -27,13 +27,18 @@ skillhub:
auth:
mock:
enabled: true
ratelimit:
download:
anonymous-cookie-secret: ${SKILLHUB_DOWNLOAD_ANON_COOKIE_SECRET:local-dev-download-secret-32-bytes}
notification:
cleanup:
read-retention-days: 30
unread-retention-days: 90
security:
scanner:
enabled: ${SKILLHUB_SECURITY_SCANNER_ENABLED:false}
enabled: ${SKILLHUB_SECURITY_SCANNER_ENABLED:true}
base-url: ${SKILLHUB_SECURITY_SCANNER_URL:http://localhost:8000}
mode: ${SKILLHUB_SECURITY_SCANNER_MODE:upload}
stream:
reclaim-enabled: ${SKILLHUB_SCAN_STREAM_RECLAIM_ENABLED:true}
reclaim-min-idle: ${SKILLHUB_SCAN_STREAM_RECLAIM_MIN_IDLE:PT2M}

View file

@ -144,7 +144,7 @@ skillhub:
download:
anonymous-cookie-name: ${SKILLHUB_DOWNLOAD_ANON_COOKIE_NAME:skillhub_anon_dl}
anonymous-cookie-max-age: ${SKILLHUB_DOWNLOAD_ANON_COOKIE_MAX_AGE:P30D}
anonymous-cookie-secret: ${SKILLHUB_DOWNLOAD_ANON_COOKIE_SECRET:change-me-in-production}
anonymous-cookie-secret: ${SKILLHUB_DOWNLOAD_ANON_COOKIE_SECRET:}
publish:
max-file-count: 100
max-single-file-size: 10485760 # 10MB
@ -166,7 +166,7 @@ skillhub:
verification-uri: ${DEVICE_AUTH_VERIFICATION_URI:${skillhub.public.base-url:}/cli/auth}
security:
scanner:
enabled: ${SKILLHUB_SECURITY_SCANNER_ENABLED:false}
enabled: ${SKILLHUB_SECURITY_SCANNER_ENABLED:true}
base-url: ${SKILLHUB_SECURITY_SCANNER_URL:http://localhost:8000}
health-path: /health
scan-path: /scan-upload

View file

@ -68,6 +68,7 @@ error.namespace.slug.exists=Namespace slug ''{0}'' already exists
error.namespace.id.notFound=Namespace not found: {0}
error.namespace.slug.notFound=Namespace not found: {0}
error.namespace.membership.required=Namespace membership required
error.namespace.global.members.platformAdmin.required=Only platform user administrators can list global namespace members
error.namespace.admin.required=Namespace owner or admin role required
error.namespace.owner.required=Namespace owner role required
error.namespace.create.platformAdminRequired=Only SKILL_ADMIN or SUPER_ADMIN can create namespaces
@ -93,6 +94,7 @@ error.skill.publish.package.invalid=Package validation failed: {0}
error.skill.publish.skillMd.notFound=SKILL.md not found
error.skill.publish.precheck.confirmRequired=Pre-publish warnings require confirmation before publishing:\n{0}
error.skill.publish.precheck.failed=Pre-publish validation failed: {0}
error.security.scanner.required=Security scanner must be enabled before publishing public or namespace-visible skills
error.skill.publish.archived=Archived skill must be restored before publishing: {0}
review.withdraw.not_pending=Only pending review submissions can be withdrawn: {0}
review.withdraw.not_submitter=Only the submitter can withdraw this review

View file

@ -68,6 +68,7 @@ error.namespace.slug.exists=命名空间 slug ''{0}'' 已存在
error.namespace.id.notFound=未找到命名空间:{0}
error.namespace.slug.notFound=未找到命名空间:{0}
error.namespace.membership.required=需要先加入该命名空间
error.namespace.global.members.platformAdmin.required=只有平台用户管理员可以查看 global 命名空间成员
error.namespace.admin.required=需要命名空间管理员或所有者权限
error.namespace.owner.required=需要命名空间所有者权限
error.namespace.create.platformAdminRequired=只有 SKILL_ADMIN 或 SUPER_ADMIN 可以创建命名空间
@ -93,6 +94,7 @@ error.skill.publish.package.invalid=技能包校验失败:{0}
error.skill.publish.skillMd.notFound=未找到 SKILL.md
error.skill.publish.precheck.confirmRequired=预发布发现以下风险提醒,确认后仍可继续发布:\n{0}
error.skill.publish.precheck.failed=预发布校验失败:{0}
error.security.scanner.required=发布公开或命名空间可见技能前必须启用安全扫描器
error.skill.publish.archived=该技能已归档,请先恢复后再发布:{0}
review.withdraw.not_pending=只有待审核版本才能撤销审核:{0}
review.withdraw.not_submitter=只有提交人本人可以撤销此次审核

View file

@ -0,0 +1,16 @@
package com.iflytek.skillhub.config;
import static org.assertj.core.api.Assertions.assertThat;
import org.junit.jupiter.api.Test;
class DownloadRateLimitPropertiesTest {
@Test
void anonymousCookieSecretDoesNotDefaultToProductionPlaceholder() {
DownloadRateLimitProperties properties = new DownloadRateLimitProperties();
assertThat(properties.getAnonymousCookieSecret())
.isNull();
}
}

View file

@ -14,22 +14,34 @@ import java.util.List;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
class SkillScannerPropertiesBindingTest {
@Test
void defaultConfig_disablesScannerByDefault() throws IOException {
void defaultConfig_enablesScannerByDefault() throws IOException {
SkillScannerProperties properties = bindProperties(
List.of("application.yml"),
Map.of()
);
assertFalse(properties.isEnabled());
assertTrue(properties.isEnabled());
assertEquals("local", properties.getMode());
assertEquals("http://localhost:8000", properties.getBaseUrl());
}
@Test
void localConfig_usesUploadScannerModeByDefault() throws IOException {
SkillScannerProperties properties = bindProperties(
List.of("application-local.yml", "application.yml"),
Map.of()
);
assertTrue(properties.isEnabled());
assertEquals("upload", properties.getMode());
assertEquals("http://localhost:8000", properties.getBaseUrl());
}
@Test
void environmentVariables_overrideScannerDefaults() throws IOException {
SkillScannerProperties properties = bindProperties(

View file

@ -0,0 +1,58 @@
package com.iflytek.skillhub.controller;
import static org.mockito.BDDMockito.given;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import com.iflytek.skillhub.auth.device.DeviceAuthService;
import com.iflytek.skillhub.auth.device.DeviceCodeResponse;
import com.iflytek.skillhub.auth.device.DeviceTokenResponse;
import com.iflytek.skillhub.domain.namespace.NamespaceMemberRepository;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.http.MediaType;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.web.servlet.MockMvc;
@SpringBootTest
@AutoConfigureMockMvc
@ActiveProfiles("test")
class DeviceAuthControllerTest {
@Autowired
private MockMvc mockMvc;
@MockBean
private DeviceAuthService deviceAuthService;
@MockBean
private NamespaceMemberRepository namespaceMemberRepository;
@Test
void requestDeviceCode_withoutCsrfIsAllowedForCliFlow() throws Exception {
given(deviceAuthService.generateDeviceCode())
.willReturn(new DeviceCodeResponse("device-1", "USER-CODE", "http://localhost/device", 600, 5));
mockMvc.perform(post("/api/v1/auth/device/code"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(0))
.andExpect(jsonPath("$.data.deviceCode").value("device-1"));
}
@Test
void pollToken_withoutCsrfIsAllowedForCliFlow() throws Exception {
given(deviceAuthService.pollToken("device-1"))
.willReturn(DeviceTokenResponse.success("token-1"));
mockMvc.perform(post("/api/v1/auth/device/token")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"deviceCode\":\"device-1\"}"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(0))
.andExpect(jsonPath("$.data.accessToken").value("token-1"));
}
}

View file

@ -17,6 +17,7 @@ import com.iflytek.skillhub.auth.rbac.PlatformPrincipal;
import com.iflytek.skillhub.domain.namespace.NamespaceMemberRepository;
import com.iflytek.skillhub.metrics.SkillHubMetrics;
import com.iflytek.skillhub.security.AuthFailureThrottleService;
import jakarta.servlet.http.Cookie;
import java.util.List;
import java.util.Set;
import org.junit.jupiter.api.Test;
@ -197,6 +198,61 @@ class LocalAuthControllerTest {
.andExpect(jsonPath("$.code").value(0));
}
@Test
void changePassword_withAuthentication_withInvalidCsrf_returnsForbidden() throws Exception {
PlatformPrincipal principal = new PlatformPrincipal(
"usr_3",
"carol",
"carol@example.com",
"",
"local",
Set.of("SUPER_ADMIN")
);
var auth = new UsernamePasswordAuthenticationToken(
principal,
null,
List.of(new SimpleGrantedAuthority("ROLE_SUPER_ADMIN"))
);
mockMvc.perform(post("/api/v1/auth/local/change-password")
.with(authentication(auth))
.with(csrf().useInvalidToken())
.contentType(MediaType.APPLICATION_JSON)
.content("""
{"currentPassword":"old","newPassword":"Newpass123!"}
"""))
.andExpect(status().isForbidden());
}
@Test
void changePassword_withSessionCookieAndBearerHeaderWithoutCsrf_isRejected() throws Exception {
PlatformPrincipal principal = new PlatformPrincipal(
"usr_3",
"carol",
"carol@example.com",
"",
"local",
Set.of("SUPER_ADMIN")
);
var auth = new UsernamePasswordAuthenticationToken(
principal,
null,
List.of(new SimpleGrantedAuthority("ROLE_SUPER_ADMIN"))
);
mockMvc.perform(post("/api/v1/auth/local/change-password")
.with(authentication(auth))
.header("Authorization", "Bearer invalid-token")
.cookie(new Cookie("SESSION", "browser-session"))
.contentType(MediaType.APPLICATION_JSON)
.content("""
{"currentPassword":"old","newPassword":"Newpass123!"}
"""))
.andExpect(status().isUnauthorized());
verify(localAuthService, never()).changePassword("usr_3", "old", "Newpass123!");
}
@Test
void requestPasswordReset_returnsGenericSuccessEnvelope() throws Exception {
mockMvc.perform(post("/api/v1/auth/local/password-reset/request")

View file

@ -174,6 +174,41 @@ class NamespacePortalControllerTest {
.andExpect(jsonPath("$.code").value(403));
}
@Test
void listMembers_globalNamespaceRejectsRegularUsers() throws Exception {
Namespace namespace = namespace(1L, "global", NamespaceStatus.ACTIVE, NamespaceType.GLOBAL);
given(namespaceService.getNamespaceBySlug("global")).willReturn(namespace);
given(namespaceMemberService.listMembers(eq(1L), any()))
.willReturn(new org.springframework.data.domain.PageImpl<>(List.of(), org.springframework.data.domain.PageRequest.of(0, 20), 0));
mockMvc.perform(get("/api/v1/namespaces/global/members")
.with(auth("regular-1"))
.requestAttr("userId", "regular-1"))
.andExpect(status().isForbidden())
.andExpect(jsonPath("$.code").value(403));
}
@Test
void listMembers_globalNamespaceAllowsUserAdminWithoutMembership() throws Exception {
Namespace namespace = namespace(1L, "global", NamespaceStatus.ACTIVE, NamespaceType.GLOBAL);
NamespaceMember member = new NamespaceMember(1L, "user-2", NamespaceRole.MEMBER);
UserAccount user = new UserAccount("user-2", "Alice", "alice@example.com", null);
given(namespaceService.getNamespaceBySlug("global")).willReturn(namespace);
doThrow(new DomainForbiddenException("error.namespace.membership.required"))
.when(namespaceService).assertMember(1L, "user-admin-1");
given(namespaceMemberService.listMembers(eq(1L), any()))
.willReturn(new org.springframework.data.domain.PageImpl<>(List.of(member), org.springframework.data.domain.PageRequest.of(0, 20), 1));
given(userAccountRepository.findByIdIn(List.of("user-2"))).willReturn(List.of(user));
mockMvc.perform(get("/api/v1/namespaces/global/members")
.with(auth("user-admin-1", Set.of("USER_ADMIN")))
.requestAttr("userId", "user-admin-1"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(0))
.andExpect(jsonPath("$.data.items[0].userId").value("user-2"))
.andExpect(jsonPath("$.data.items[0].email").value("alice@example.com"));
}
@Test
void searchMemberCandidates_returnsCandidates() throws Exception {
Namespace namespace = namespace(1L, "team-a", NamespaceStatus.ACTIVE, NamespaceType.TEAM);

View file

@ -95,7 +95,7 @@ class NamespaceWorkflowContractTest {
.willReturn(List.of(new NamespaceCandidateUserResponse("user-admin", "Admin", "admin@example.com", "ACTIVE")));
given(namespacePortalCommandAppService.addMember("team-flow", "user-admin", NamespaceRole.ADMIN, "owner-1"))
.willReturn(adminMemberResponse);
given(namespacePortalQueryAppService.listMembers(eq("team-flow"), any(org.springframework.data.domain.Pageable.class), eq("owner-1")))
given(namespacePortalQueryAppService.listMembers(eq("team-flow"), any(org.springframework.data.domain.Pageable.class), eq("owner-1"), eq(Set.of())))
.willReturn(new PageResponse<>(List.of(adminMemberResponse), 1, 0, 20));
given(namespacePortalCommandAppService.updateMemberRole(eq("team-flow"), eq("user-admin"), any(), eq("owner-1")))
.willReturn(adminMemberResponse);

View file

@ -136,7 +136,7 @@ class SkillStarControllerTest {
}
@Test
void apiWebStarSkillWithoutCsrfShouldAllowSessionAuth() throws Exception {
void apiWebStarSkillWithCsrfShouldAllowSessionAuth() throws Exception {
PlatformPrincipal principal = new PlatformPrincipal(
"user-42",
"tester",
@ -152,7 +152,8 @@ class SkillStarControllerTest {
);
mockMvc.perform(put("/api/web/skills/10/star")
.with(authentication(auth)))
.with(authentication(auth))
.with(csrf()))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(0));

View file

@ -55,6 +55,7 @@ class CliDryRunValidateTest {
mockMvc.perform(multipart("/api/cli/v1/skills/global/publish/validate")
.file(file)
.header("Authorization", "Bearer test-token")
.with(authentication(auth())))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.valid").value(true))
@ -75,6 +76,7 @@ class CliDryRunValidateTest {
mockMvc.perform(multipart("/api/cli/v1/skills/global/publish/validate")
.file(file)
.header("Authorization", "Bearer test-token")
.with(authentication(auth())))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.valid").value(false))
@ -95,6 +97,7 @@ class CliDryRunValidateTest {
mockMvc.perform(multipart("/api/cli/v1/skills/global/publish/validate")
.file(file)
.file(new MockMultipartFile("visibility", "", "text/plain", "PRIVATE".getBytes()))
.header("Authorization", "Bearer test-token")
.with(authentication(auth())))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.valid").value(true));
@ -108,6 +111,7 @@ class CliDryRunValidateTest {
mockMvc.perform(multipart("/api/cli/v1/skills/global/publish/validate")
.file(file)
.file(new MockMultipartFile("visibility", "", "text/plain", "BOGUS".getBytes()))
.header("Authorization", "Bearer test-token")
.with(authentication(auth())))
.andExpect(status().isBadRequest());
}

View file

@ -118,6 +118,7 @@ class CliSkillControllerTest {
mockMvc.perform(org.springframework.test.web.servlet.request.MockMvcRequestBuilders
.delete("/api/cli/v1/skills/global/demo")
.header("Authorization", "Bearer test-token")
.with(authentication(auth)))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.ok").value(true))

View file

@ -59,7 +59,8 @@ class SkillSubscriptionControllerTest {
@Test
void subscribe_skill_returns_envelope() throws Exception {
mockMvc.perform(put("/api/web/skills/10/subscription")
.with(authentication(authenticatedUser())))
.with(authentication(authenticatedUser()))
.with(csrf()))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(0))
.andExpect(jsonPath("$.timestamp").isNotEmpty())
@ -80,7 +81,8 @@ class SkillSubscriptionControllerTest {
@Test
void unsubscribe_skill_returns_envelope() throws Exception {
mockMvc.perform(delete("/api/web/skills/10/subscription")
.with(authentication(authenticatedUser())))
.with(authentication(authenticatedUser()))
.with(csrf()))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(0))
.andExpect(jsonPath("$.timestamp").isNotEmpty())

View file

@ -0,0 +1,20 @@
package com.iflytek.skillhub.ratelimit;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import com.iflytek.skillhub.config.DownloadRateLimitProperties;
import org.junit.jupiter.api.Test;
class AnonymousDownloadIdentityServiceTest {
@Test
void validateAnonymousCookieSecretRejectsReleaseExamplePlaceholder() {
DownloadRateLimitProperties properties = new DownloadRateLimitProperties();
properties.setAnonymousCookieSecret("replace-with-random-download-secret-32-bytes");
AnonymousDownloadIdentityService service = new AnonymousDownloadIdentityService(properties, new ClientIpResolver());
assertThatThrownBy(service::validateAnonymousCookieSecret)
.isInstanceOf(IllegalStateException.class)
.hasMessageContaining("must not use the default placeholder");
}
}

View file

@ -29,6 +29,7 @@ import org.springframework.test.util.ReflectionTestUtils;
import java.util.List;
import java.util.Map;
import java.util.Set;
class NamespacePortalQueryAppServiceTest {
@ -131,7 +132,7 @@ class NamespacePortalQueryAppServiceTest {
when(userAccountRepository.findByIdIn(List.of("user-2")))
.thenReturn(List.of(user));
PageResponse<MemberResponse> result = service.listMembers("team-a", PageRequest.of(0, 20), "owner-1");
PageResponse<MemberResponse> result = service.listMembers("team-a", PageRequest.of(0, 20), "owner-1", Set.of());
assertThat(result.items()).hasSize(1);
MemberResponse mr = result.items().get(0);
@ -153,7 +154,7 @@ class NamespacePortalQueryAppServiceTest {
when(userAccountRepository.findByIdIn(List.of("ghost-user")))
.thenReturn(List.of());
PageResponse<MemberResponse> result = service.listMembers("team-a", PageRequest.of(0, 20), "owner-1");
PageResponse<MemberResponse> result = service.listMembers("team-a", PageRequest.of(0, 20), "owner-1", Set.of());
assertThat(result.items()).hasSize(1);
MemberResponse mr = result.items().get(0);
@ -161,4 +162,17 @@ class NamespacePortalQueryAppServiceTest {
assertThat(mr.displayName()).isNull();
assertThat(mr.email()).isNull();
}
@Test
void listMembers_globalNamespaceRejectsRegularUsersEvenWhenTheyAreGlobalMembers() {
Namespace ns = namespace(1L, "global");
ns.setType(NamespaceType.GLOBAL);
when(namespaceService.getNamespaceBySlug("global")).thenReturn(ns);
when(namespaceMemberService.listMembers(eq(1L), any(PageRequest.class)))
.thenReturn(new PageImpl<>(List.of(), PageRequest.of(0, 20), 0));
assertThatThrownBy(() -> service.listMembers("global", PageRequest.of(0, 20), "user-1", Set.of()))
.isInstanceOf(DomainForbiddenException.class)
.hasMessageContaining("error.namespace.global.members.platformAdmin.required");
}
}

View file

@ -41,6 +41,9 @@ skillhub:
enforce-active-user-check: false
access-policy:
mode: OPEN
ratelimit:
download:
anonymous-cookie-secret: test-download-secret-32-bytes-long
security:
scanner:
enabled: false

View file

@ -9,6 +9,8 @@ import com.iflytek.skillhub.auth.mock.MockAuthFilter;
import com.iflytek.skillhub.auth.policy.RouteSecurityPolicyRegistry;
import com.iflytek.skillhub.auth.token.ApiTokenAuthenticationFilter;
import com.iflytek.skillhub.auth.token.ApiTokenScopeFilter;
import jakarta.servlet.http.Cookie;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.context.annotation.Bean;
@ -103,7 +105,7 @@ public class SecurityConfig {
RequestMatcher csrfIgnoreMatcher = request -> {
String path = request.getRequestURI();
String authorization = request.getHeader("Authorization");
return routeSecurityPolicyRegistry.shouldIgnoreCsrf(path, authorization);
return routeSecurityPolicyRegistry.shouldIgnoreCsrf(request.getMethod(), path, authorization, hasSessionCookie(request));
};
http
@ -183,4 +185,20 @@ public class SecurityConfig {
}
}
}
static boolean hasSessionCookie(HttpServletRequest request) {
if (request.getRequestedSessionId() != null) {
return true;
}
Cookie[] cookies = request.getCookies();
if (cookies == null) {
return false;
}
for (Cookie cookie : cookies) {
if ("SESSION".equals(cookie.getName()) || "JSESSIONID".equals(cookie.getName())) {
return true;
}
}
return false;
}
}

View file

@ -150,14 +150,22 @@ public class RouteSecurityPolicyRegistry {
return ApiTokenAuthorizationDecision.unsupported(path);
}
public boolean shouldIgnoreCsrf(String path, String authorizationHeader) {
if (authorizationHeader != null && authorizationHeader.startsWith("Bearer ")) {
public boolean shouldIgnoreCsrf(String method, String path, String authorizationHeader) {
return shouldIgnoreCsrf(method, path, authorizationHeader, false);
}
public boolean shouldIgnoreCsrf(String method, String path, String authorizationHeader, boolean hasSessionCookie) {
if (!hasSessionCookie && authorizationHeader != null && authorizationHeader.startsWith("Bearer ")) {
return true;
}
if (path == null) {
return false;
}
return path.startsWith("/api/");
if (!"POST".equalsIgnoreCase(method)) {
return false;
}
return "/api/v1/auth/device/code".equals(path)
|| "/api/v1/auth/device/token".equals(path);
}
public boolean shouldProjectRequestContext(String path) {

View file

@ -0,0 +1,43 @@
package com.iflytek.skillhub.auth.config;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import jakarta.servlet.http.Cookie;
import org.junit.jupiter.api.Test;
import org.springframework.mock.web.MockHttpServletRequest;
class SecurityConfigTest {
@Test
void hasSessionCookieDetectsSpringSessionCookie() {
MockHttpServletRequest request = new MockHttpServletRequest();
request.setCookies(new Cookie("SESSION", "session-id"));
assertTrue(SecurityConfig.hasSessionCookie(request));
}
@Test
void hasSessionCookieDetectsRequestedSessionIdFromServletContainer() {
MockHttpServletRequest request = new MockHttpServletRequest();
request.setRequestedSessionId("custom-session-id");
assertTrue(SecurityConfig.hasSessionCookie(request));
}
@Test
void hasSessionCookieIgnoresServerSideSessionWithoutClientSessionId() {
MockHttpServletRequest request = new MockHttpServletRequest();
request.getSession(true);
assertFalse(SecurityConfig.hasSessionCookie(request));
}
@Test
void hasSessionCookieIgnoresNonSessionCookies() {
MockHttpServletRequest request = new MockHttpServletRequest();
request.setCookies(new Cookie("XSRF-TOKEN", "csrf-token"));
assertFalse(SecurityConfig.hasSessionCookie(request));
}
}

View file

@ -113,10 +113,16 @@ class RouteSecurityPolicyRegistryTest {
}
@Test
void shouldIgnoreCsrf_forBearerAndApiPaths() {
assertTrue(registry.shouldIgnoreCsrf("/api/v1/admin/users", null));
assertTrue(registry.shouldIgnoreCsrf("/not-api", "Bearer token"));
assertFalse(registry.shouldIgnoreCsrf("/ui/settings", null));
void shouldIgnoreCsrf_onlyForBearerTokensAndDeviceTokenFlow() {
assertFalse(registry.shouldIgnoreCsrf("POST", "/api/v1/admin/users", null, false));
assertFalse(registry.shouldIgnoreCsrf("POST", "/api/v1/auth/local/change-password", null, false));
assertTrue(registry.shouldIgnoreCsrf("POST", "/not-api", "Bearer token", false));
assertFalse(registry.shouldIgnoreCsrf("POST", "/not-api", "Bearer token", true));
assertTrue(registry.shouldIgnoreCsrf("POST", "/api/v1/auth/device/code", null, false));
assertTrue(registry.shouldIgnoreCsrf("POST", "/api/v1/auth/device/token", null, false));
assertFalse(registry.shouldIgnoreCsrf("GET", "/api/v1/auth/device/code", null, false));
assertFalse(registry.shouldIgnoreCsrf("POST", "/api/v1/auth/device/authorize", null, false));
assertFalse(registry.shouldIgnoreCsrf("POST", "/ui/settings", null, false));
}
@Test

View file

@ -170,6 +170,9 @@ public class SkillPublishService {
errors.add("Publisher is not a member of namespace: " + namespaceSlug);
}
}
if (requiresSecurityScanner(visibility) && !securityScanService.isEnabled()) {
errors.add("error.security.scanner.required");
}
// 3. Package validation
ValidationResult packageValidation = skillPackageValidator.validate(entries);
@ -380,6 +383,9 @@ public class SkillPublishService {
"error.skill.publish.precheck.confirmRequired",
formatValidationMessages(publishWarnings));
}
if (requiresSecurityScanner(visibility) && !securityScanService.isEnabled()) {
throw new DomainBadRequestException("error.security.scanner.required");
}
// 6. Find or create Skill record (with owner isolation)
List<Skill> existingSkills = skillRepository.findByNamespaceIdAndSlug(namespace.getId(), skillSlug);
@ -588,6 +594,10 @@ public class SkillPublishService {
skillVersionRepository.flush();
}
private boolean requiresSecurityScanner(SkillVisibility visibility) {
return visibility == SkillVisibility.PUBLIC || visibility == SkillVisibility.NAMESPACE_ONLY;
}
private String resolveNamespaceSlug(Long namespaceId) {
return namespaceRepository.findById(namespaceId)
.orElseThrow(() -> new DomainBadRequestException("error.namespace.notFound", namespaceId))

View file

@ -101,7 +101,7 @@ class SkillPublishServiceTest {
eventPublisher,
CLOCK
);
lenient().when(securityScanService.isEnabled()).thenReturn(false);
lenient().when(securityScanService.isEnabled()).thenReturn(true);
lenient().when(skillVersionRepository.findBySkillIdAndStatus(anyLong(), eq(SkillVersionStatus.PENDING_REVIEW)))
.thenReturn(List.of());
lenient().when(reviewTaskRepository.save(any(ReviewTask.class))).thenAnswer(invocation -> invocation.getArgument(0));
@ -1284,6 +1284,120 @@ class SkillPublishServiceTest {
assertEquals(SkillVersionStatus.SCAN_FAILED, SkillVersionStatus.valueOf("SCAN_FAILED"));
}
@Test
void testPublishFromEntries_PublicWhenScannerDisabled_ShouldRejectBeforeSideEffects() throws Exception {
String namespaceSlug = "test-ns";
String publisherId = "user-100";
PublishFixture fixture = stubValidPublishInputs(namespaceSlug, publisherId, "test-skill", "test-skill", "1.0.0", true);
when(securityScanService.isEnabled()).thenReturn(false);
DomainBadRequestException exception = assertThrows(DomainBadRequestException.class, () -> service.publishFromEntries(
namespaceSlug,
fixture.entries(),
publisherId,
SkillVisibility.PUBLIC,
Set.of()
));
assertEquals("error.security.scanner.required", exception.messageCode());
verify(skillRepository, never()).findByNamespaceIdAndSlug(anyLong(), anyString());
verify(skillRepository, never()).save(any(Skill.class));
verify(skillVersionRepository, never()).save(any(SkillVersion.class));
verify(objectStorageService, never()).putObject(anyString(), any(), anyLong(), anyString());
verify(reviewTaskRepository, never()).save(any(ReviewTask.class));
}
@Test
void testPublishFromEntries_NamespaceOnlyWhenScannerDisabled_ShouldRejectBeforeSideEffects() throws Exception {
String namespaceSlug = "test-ns";
String publisherId = "user-100";
PublishFixture fixture = stubValidPublishInputs(namespaceSlug, publisherId, "team-skill", "team-skill", "1.0.0", true);
when(securityScanService.isEnabled()).thenReturn(false);
DomainBadRequestException exception = assertThrows(DomainBadRequestException.class, () -> service.publishFromEntries(
namespaceSlug,
fixture.entries(),
publisherId,
SkillVisibility.NAMESPACE_ONLY,
Set.of()
));
assertEquals("error.security.scanner.required", exception.messageCode());
verify(skillRepository, never()).findByNamespaceIdAndSlug(anyLong(), anyString());
verify(skillRepository, never()).save(any(Skill.class));
verify(skillVersionRepository, never()).save(any(SkillVersion.class));
verify(objectStorageService, never()).putObject(anyString(), any(), anyLong(), anyString());
verify(reviewTaskRepository, never()).save(any(ReviewTask.class));
}
@Test
void testPublishFromEntries_PrivateWhenScannerDisabled_ShouldAllowUploadWithoutScan() throws Exception {
String namespaceSlug = "test-ns";
String publisherId = "user-100";
PublishFixture fixture = stubValidPublishInputs(namespaceSlug, publisherId, "private-skill", "private-skill", "1.0.0", true);
when(securityScanService.isEnabled()).thenReturn(false);
SkillPublishService.PublishResult result = service.publishFromEntries(
namespaceSlug,
fixture.entries(),
publisherId,
SkillVisibility.PRIVATE,
Set.of()
);
assertEquals(SkillVersionStatus.UPLOADED, result.version().getStatus());
verify(securityScanService, never()).triggerScan(anyLong(), anyList(), anyString());
verify(reviewTaskRepository, never()).save(any(ReviewTask.class));
}
@Test
void testPublishFromEntries_SuperAdminPublicWhenScannerDisabled_ShouldStillRejectBeforeAutoPublish() throws Exception {
String namespaceSlug = "test-ns";
String publisherId = "admin-user";
PublishFixture fixture = stubValidPublishInputs(namespaceSlug, publisherId, "admin-skill", "admin-skill", "1.0.0", false);
when(securityScanService.isEnabled()).thenReturn(false);
DomainBadRequestException exception = assertThrows(DomainBadRequestException.class, () -> service.publishFromEntries(
namespaceSlug,
fixture.entries(),
publisherId,
SkillVisibility.PUBLIC,
Set.of("SUPER_ADMIN")
));
assertEquals("error.security.scanner.required", exception.messageCode());
verify(skillVersionRepository, never()).save(any(SkillVersion.class));
verify(eventPublisher, never()).publishEvent(any(SkillPublishedEvent.class));
}
@Test
void testValidateOnly_PublicWhenScannerDisabled_ShouldReturnScannerRequiredError() throws Exception {
String namespaceSlug = "test-ns";
String publisherId = "user-100";
List<PackageEntry> entries = skillEntries("test-skill", "1.0.0");
Namespace namespace = new Namespace(namespaceSlug, "Test NS", "user-1");
setId(namespace, 1L);
NamespaceMember member = mock(NamespaceMember.class);
SkillMetadata metadata = new SkillMetadata("test-skill", "Test", "1.0.0", "Body", Map.of());
when(securityScanService.isEnabled()).thenReturn(false);
when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace));
when(namespaceMemberRepository.findByNamespaceIdAndUserId(any(), eq(publisherId))).thenReturn(Optional.of(member));
when(skillPackageValidator.validate(entries)).thenReturn(ValidationResult.pass());
when(skillMetadataParser.parse(anyString())).thenReturn(metadata);
when(prePublishValidator.validate(any())).thenReturn(ValidationResult.pass());
SkillPublishService.DryRunResult result = service.validateOnly(
namespaceSlug,
entries,
publisherId,
SkillVisibility.PUBLIC,
Set.of()
);
assertFalse(result.valid());
assertTrue(result.errors().contains("error.security.scanner.required"));
}
@Test
void testPublishFromEntries_WhenScannerEnabled_ShouldCreateReviewTaskAndTriggerScan() throws Exception {
String namespaceSlug = "test-ns";
@ -1377,6 +1491,56 @@ class SkillPublishServiceTest {
verify(reviewTaskRepository, never()).save(any(ReviewTask.class));
}
private record PublishFixture(List<PackageEntry> entries) {
}
private PublishFixture stubValidPublishInputs(
String namespaceSlug,
String publisherId,
String skillName,
String skillSlug,
String version,
boolean stubMembership) throws Exception {
List<PackageEntry> entries = skillEntries(skillName, version);
Namespace namespace = new Namespace(namespaceSlug, "Test NS", "user-1");
setId(namespace, 1L);
SkillMetadata metadata = new SkillMetadata(skillName, "Test", version, "Body", Map.of());
Skill skill = new Skill(namespace.getId(), skillSlug, publisherId, SkillVisibility.PUBLIC);
setId(skill, 1L);
when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace));
if (stubMembership) {
NamespaceMember member = mock(NamespaceMember.class);
when(namespaceMemberRepository.findByNamespaceIdAndUserId(any(), eq(publisherId))).thenReturn(Optional.of(member));
}
when(skillPackageValidator.validate(entries)).thenReturn(ValidationResult.pass());
when(skillMetadataParser.parse(anyString())).thenReturn(metadata);
when(prePublishValidator.validate(any())).thenReturn(ValidationResult.pass());
lenient().when(skillRepository.findByNamespaceIdAndSlug(namespace.getId(), skillSlug)).thenReturn(List.of(skill));
lenient().when(skillRepository.findByNamespaceIdAndSlugAndOwnerId(namespace.getId(), skillSlug, publisherId)).thenReturn(Optional.of(skill));
lenient().when(skillVersionRepository.findBySkillIdAndVersion(skill.getId(), version)).thenReturn(Optional.empty());
lenient().when(skillVersionRepository.save(any(SkillVersion.class))).thenAnswer(invocation -> {
SkillVersion saved = invocation.getArgument(0);
if (saved.getId() == null) {
setId(saved, 10L);
}
return saved;
});
lenient().when(skillRepository.save(any(Skill.class))).thenReturn(skill);
return new PublishFixture(entries);
}
private List<PackageEntry> skillEntries(String skillName, String version) {
String skillMdContent = "---\nname: " + skillName + "\ndescription: Test\nversion: " + version + "\n---\nBody";
PackageEntry skillMd = new PackageEntry(
"SKILL.md",
skillMdContent.getBytes(StandardCharsets.UTF_8),
skillMdContent.getBytes(StandardCharsets.UTF_8).length,
"text/markdown");
PackageEntry readme = new PackageEntry("README.md", "content".getBytes(StandardCharsets.UTF_8), 7, "text/markdown");
return List.of(skillMd, readme);
}
private void setId(Object entity, Long id) throws Exception {
Field idField = entity.getClass().getDeclaredField("id");
idField.setAccessible(true);

27
web/e2e/helpers/csrf.ts Normal file
View file

@ -0,0 +1,27 @@
import type { Page } from '@playwright/test'
const csrfCookieName = 'XSRF-TOKEN'
const csrfHeaderName = 'X-XSRF-TOKEN'
const requestTimeoutMs = process.env.CI ? 12_000 : 8_000
async function readCsrfToken(page: Page): Promise<string | null> {
const cookie = (await page.context().cookies()).find((item) => item.name === csrfCookieName)
return cookie?.value?.trim() || null
}
export async function csrfHeaders(page: Page, headers?: Record<string, string>): Promise<Record<string, string>> {
let token = await readCsrfToken(page)
if (!token) {
await page.context().request.get('/api/v1/auth/providers', { timeout: requestTimeoutMs })
token = await readCsrfToken(page)
}
if (!token) {
throw new Error('Missing XSRF-TOKEN cookie after auth provider warm-up')
}
return {
...headers,
[csrfHeaderName]: token,
}
}

View file

@ -1,4 +1,5 @@
import { expect, type Page, type TestInfo } from '@playwright/test'
import { csrfHeaders } from './csrf'
const password = 'Passw0rd!123'
const cachedUserByWorker = new Map<number, string>()
@ -50,15 +51,17 @@ function isRetryableStatus(status: number): boolean {
}
async function loginWithRetry(
request: Page['request'],
page: Page,
username: string,
currentPassword = password,
retries = process.env.CI ? 10 : 6,
): Promise<boolean> {
const request = page.context().request
for (let i = 0; i < retries; i += 1) {
try {
const login = await request.post('/api/v1/auth/local/login', {
data: { username, password: currentPassword },
headers: await csrfHeaders(page),
timeout: requestTimeoutMs,
})
@ -187,13 +190,13 @@ async function registerSessionOnce(page: Page, testInfo?: TestInfo, options?: Re
}
// Prefer the known-good cached account to avoid repeated failed-logins on a fixed username.
if (cached && await loginWithRetry(request, cached)) {
if (cached && await loginWithRetry(page, cached)) {
await cacheSession(page, worker, cached)
return { username: cached, password }
}
// Support environments where a deterministic worker account already exists.
if (!cached && await loginWithRetry(request, username, password, process.env.CI ? 4 : 3)) {
if (!cached && await loginWithRetry(page, username, password, process.env.CI ? 4 : 3)) {
cachedUserByWorker.set(worker, username)
await cacheSession(page, worker, username)
return { username, password }
@ -206,6 +209,7 @@ async function registerSessionOnce(page: Page, testInfo?: TestInfo, options?: Re
password,
email: `${username}@example.test`,
},
headers: await csrfHeaders(page),
timeout: requestTimeoutMs,
})
@ -215,7 +219,7 @@ async function registerSessionOnce(page: Page, testInfo?: TestInfo, options?: Re
return { username, password }
}
if (register.status() === 409 && await loginWithRetry(request, username, password, process.env.CI ? 8 : 6)) {
if (register.status() === 409 && await loginWithRetry(page, username, password, process.env.CI ? 8 : 6)) {
cachedUserByWorker.set(worker, username)
await cacheSession(page, worker, username)
return { username, password }
@ -236,6 +240,7 @@ async function registerSessionOnce(page: Page, testInfo?: TestInfo, options?: Re
password,
email: `${uniqueUsername}@example.test`,
},
headers: await csrfHeaders(page),
timeout: requestTimeoutMs,
})
@ -269,7 +274,7 @@ async function registerSessionOnce(page: Page, testInfo?: TestInfo, options?: Re
// Final fallback for environments where registration is temporarily unavailable.
const fallbackCandidates = [cached, username].filter((candidate): candidate is string => Boolean(candidate))
for (const candidate of fallbackCandidates) {
if (await loginWithRetry(request, candidate, password, process.env.CI ? 12 : 8)) {
if (await loginWithRetry(page, candidate, password, process.env.CI ? 12 : 8)) {
cachedUserByWorker.set(worker, candidate)
await cacheSession(page, worker, candidate)
return { username: candidate, password }
@ -295,6 +300,7 @@ async function createFreshSessionOnce(page: Page, testInfo?: TestInfo) {
password,
email: `${uniqueUsername}@example.test`,
},
headers: await csrfHeaders(page),
timeout: requestTimeoutMs,
})
@ -358,8 +364,6 @@ export async function createFreshSession(page: Page, testInfo?: TestInfo) {
}
export async function loginWithCredentials(page: Page, credentials: TestCredentials, _testInfo?: TestInfo) {
const request = page.context().request
await primeAuthProviders(page)
const restored = await restoreCachedSessionForAccount(page, credentials.username)
@ -368,7 +372,7 @@ export async function loginWithCredentials(page: Page, credentials: TestCredenti
}
const loggedIn = await loginWithRetry(
request,
page,
credentials.username,
credentials.password,
process.env.CI ? 12 : 8,

View file

@ -3,6 +3,7 @@ import { tmpdir } from 'node:os'
import { execFileSync } from 'node:child_process'
import path from 'node:path'
import type { APIRequestContext, Page, TestInfo } from '@playwright/test'
import { csrfHeaders } from './csrf'
type CleanupTask = () => Promise<void>
@ -237,12 +238,14 @@ export class E2eTestDataBuilder {
displayName,
description: `E2E namespace ${slug}`,
},
headers: await csrfHeaders(this.page),
}),
)
this.cleanupTasks.push(async () => {
await this.request.post(`/api/web/namespaces/${encodeURIComponent(created.slug)}/archive`, {
data: { reason: 'e2e cleanup' },
headers: await csrfHeaders(this.page),
})
})
@ -270,13 +273,17 @@ export class E2eTestDataBuilder {
if (namespace.status === 'FROZEN' && namespace.canUnfreeze) {
return parseEnvelope<SeededNamespace>(
await this.request.post(`/api/web/namespaces/${encodeURIComponent(namespace.slug)}/unfreeze`),
await this.request.post(`/api/web/namespaces/${encodeURIComponent(namespace.slug)}/unfreeze`, {
headers: await csrfHeaders(this.page),
}),
)
}
if (namespace.status === 'ARCHIVED' && namespace.canRestore) {
return parseEnvelope<SeededNamespace>(
await this.request.post(`/api/web/namespaces/${encodeURIComponent(namespace.slug)}/restore`),
await this.request.post(`/api/web/namespaces/${encodeURIComponent(namespace.slug)}/restore`, {
headers: await csrfHeaders(this.page),
}),
)
}
@ -483,11 +490,12 @@ export class E2eTestDataBuilder {
async approveReview(reviewTaskId: number, comment = 'Approved by Playwright E2E'): Promise<void> {
let lastError: unknown
for (let attempt = 0; attempt < 30; attempt += 1) {
for (let attempt = 0; attempt < 60; attempt += 1) {
try {
await parseEnvelope<ReviewTaskSummary>(
await this.request.post(`/api/web/reviews/${reviewTaskId}/approve`, {
data: { comment },
headers: await csrfHeaders(this.page),
}),
)
return
@ -498,7 +506,7 @@ export class E2eTestDataBuilder {
if (!isScanInProgress) {
throw error
}
await new Promise((resolve) => setTimeout(resolve, 500))
await new Promise((resolve) => setTimeout(resolve, 1_000))
}
}
throw lastError instanceof Error ? lastError : new Error('approveReview timed out')
@ -515,6 +523,7 @@ export class E2eTestDataBuilder {
await parseEnvelope<{ userId: string; role: string }>(
await this.request.post(`/api/web/namespaces/${encodeURIComponent(slug)}/members`, {
data: { userId, role },
headers: await csrfHeaders(this.page),
}),
)
}
@ -533,11 +542,14 @@ export class E2eTestDataBuilder {
},
visibility: 'PUBLIC',
},
headers: await csrfHeaders(this.page),
}),
)
this.cleanupTasks.push(async () => {
await this.request.delete(`/api/web/skills/${encodeURIComponent(result.namespace)}/${encodeURIComponent(result.slug)}`)
await this.request.delete(`/api/web/skills/${encodeURIComponent(result.namespace)}/${encodeURIComponent(result.slug)}`, {
headers: await csrfHeaders(this.page),
})
})
return result

View file

@ -18,6 +18,8 @@ function escapeRegExp(value: string) {
let seeded: PreparedSearchSeed | undefined
test.describe('Public Skill Detail Anonymous Access (Real API)', () => {
test.describe.configure({ timeout: 150_000 })
test.beforeAll(async ({ browser }, testInfo) => {
seeded = await prepareSearchSeed(browser, testInfo, { count: 1 })
})

View file

@ -16,6 +16,8 @@ function adminCredentials() {
}
test.describe('Skill Subscription (Real API)', () => {
test.describe.configure({ timeout: 150_000 })
test.beforeEach(async ({ page }, testInfo) => {
await setEnglishLocale(page)
await registerSession(page, testInfo)

View file

@ -1,5 +1,6 @@
import { expect, test } from '@playwright/test'
import { setEnglishLocale } from './helpers/auth-fixtures'
import { csrfHeaders } from './helpers/csrf'
import { loginWithCredentials, registerSession } from './helpers/session'
import { E2eTestDataBuilder } from './helpers/test-data-builder'
@ -16,6 +17,8 @@ function adminCredentials() {
}
test.describe('Skill Version Compare (Real API)', () => {
test.describe.configure({ timeout: 150_000 })
test.beforeEach(async ({ page }, testInfo) => {
await setEnglishLocale(page)
await registerSession(page, testInfo)
@ -50,6 +53,7 @@ test.describe('Skill Version Compare (Real API)', () => {
targetVersion: '1.1.0',
confirmWarnings: true,
},
headers: await csrfHeaders(page),
}
)
expect(rereleaseResponse.ok()).toBe(true)

View file

@ -9938,6 +9938,8 @@ export interface operations {
page?: number;
size?: number;
filter?: string;
q?: string;
namespace?: string;
};
header?: never;
path?: never;
@ -9962,6 +9964,8 @@ export interface operations {
page?: number;
size?: number;
filter?: string;
q?: string;
namespace?: string;
};
header?: never;
path?: never;