diff --git a/.env.example b/.env.example
index 3983a0dd..f392a592 100644
--- a/.env.example
+++ b/.env.example
@@ -34,6 +34,11 @@ MATECLAW_CORS_ALLOWED_ORIGINS=
# 留空时回退到当前请求的 host,再退回相对路径。反代后部署建议显式设置。
MATECLAW_PUBLIC_BASE_URL=
+# 是否公开 Swagger UI / OpenAPI 文档(/swagger-ui.html、/v3/api-docs)。
+# 生产数据库 profile(mysql/kingbase/postgres)默认 false —— 匿名无法浏览全部
+# 端点结构,需全局管理员(ROLE_ADMIN)。仅在内网/预发临时调试时设为 true。
+MATECLAW_OPENAPI_EXPOSE_UI=
+
# SearXNG 会话密钥(容器内部用,留空会用开发默认值)。生产部署请设成 32+ 位随机串。
# openssl rand -hex 32
SEARXNG_SECRET=
diff --git a/mateclaw-server/src/main/java/vip/mate/config/OpenApiConfig.java b/mateclaw-server/src/main/java/vip/mate/config/OpenApiConfig.java
index ccc8b196..5bf11f40 100644
--- a/mateclaw-server/src/main/java/vip/mate/config/OpenApiConfig.java
+++ b/mateclaw-server/src/main/java/vip/mate/config/OpenApiConfig.java
@@ -27,11 +27,12 @@ import java.util.List;
* Personal Access Token 以 {@code mc_} 开头(PAT_PREFIX)。因此 Swagger
* UI 的 Authorize 按钮只需填入任意一种 token 即可。
*
- *
注意:Swagger 当前公开可访问
- * {@link SecurityConfig#filterChain} 中 {@code /api/**} 要求认证,但
- * {@code /swagger-ui*}、{@code /v3/api-docs*}、{@code /webjars/**} 落到
- * {@code .anyRequest().permitAll()},即 Swagger UI 当前是公开的。如生产环境
- * 需要收口,应在 SecurityConfig 显式加规则,而不是改本类。
+ * 访问控制(在 SecurityConfig,不在本类)
+ * Swagger UI / OpenAPI 文档路径({@code /swagger-ui*}、{@code /v3/api-docs*}、
+ * {@code /webjars/**})的鉴权由 {@link SecurityConfig#filterChain} 通过
+ * {@code mateclaw.openapi.expose-ui} 开关控制:本地/默认 profile 公开,
+ * 生产数据库 profile(mysql/kingbase/postgres)默认要求全局管理员
+ * ({@code ROLE_ADMIN})。访问规则只属于 SecurityConfig,不要加到本类。
*
* 未做的事(与「全局配置 + 安全方案」范围一致)
* 不逐个 Controller 补 {@code @Parameter} / {@code @ApiResponse} /
diff --git a/mateclaw-server/src/main/java/vip/mate/config/SecurityConfig.java b/mateclaw-server/src/main/java/vip/mate/config/SecurityConfig.java
index 5217b667..f4cd853c 100644
--- a/mateclaw-server/src/main/java/vip/mate/config/SecurityConfig.java
+++ b/mateclaw-server/src/main/java/vip/mate/config/SecurityConfig.java
@@ -1,6 +1,7 @@
package vip.mate.config;
import lombok.RequiredArgsConstructor;
+import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
@@ -28,6 +29,22 @@ public class SecurityConfig {
private final JwtAuthFilter jwtAuthFilter;
+ /**
+ * SpringDoc Swagger UI / OpenAPI document paths. These serve the full REST
+ * surface (every endpoint plus request/response schemas), so they are gated
+ * explicitly instead of relying on the {@code .anyRequest().permitAll()}
+ * fallthrough. Whether they are public or admin-only is driven by
+ * {@code mateclaw.openapi.expose-ui} (see {@link #filterChain}).
+ */
+ private static final String[] OPENAPI_PATHS = {
+ "/swagger-ui.html",
+ "/swagger-ui/**",
+ "/v3/api-docs",
+ "/v3/api-docs/**",
+ "/v3/api-docs.yaml",
+ "/webjars/**"
+ };
+
/**
* 密码编码器独立配置(打破 SecurityConfig → JwtAuthFilter → AuthService → BCryptPasswordEncoder 循环)
*/
@@ -39,8 +56,19 @@ public class SecurityConfig {
}
}
+ /**
+ * Configure the security filter chain.
+ *
+ * @param exposeOpenApiUi when {@code true} the Swagger UI / OpenAPI document
+ * paths are public; when {@code false} they require a global admin
+ * ({@code ROLE_ADMIN}). Defaults to {@code false} (locked down) when the
+ * property is absent — the base {@code application.yml} enables it for
+ * local dev while the production database profiles keep it off.
+ */
@Bean
- public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
+ public SecurityFilterChain filterChain(
+ HttpSecurity http,
+ @Value("${mateclaw.openapi.expose-ui:false}") boolean exposeOpenApiUi) throws Exception {
http
.csrf(AbstractHttpConfigurer::disable)
.headers(headers -> headers
@@ -48,10 +76,10 @@ public class SecurityConfig {
.frameOptions(frame -> frame.sameOrigin())
)
.sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
- .authorizeHttpRequests(auth -> auth
+ .authorizeHttpRequests(auth -> {
// GET /settings/language stays anonymous (first-paint i18n). PUT
// requires login + admin (see @RequireGlobalAdmin on the controller).
- .requestMatchers(HttpMethod.GET, "/api/v1/settings/language").permitAll()
+ auth.requestMatchers(HttpMethod.GET, "/api/v1/settings/language").permitAll()
// 公开 API 接口
.requestMatchers(
"/api/v1/auth/login",
@@ -66,12 +94,20 @@ public class SecurityConfig {
// expire after GeneratedFileCache.TTL (7 days) — delayed access (e.g. an
// IM-delivered link opened later) is intentional, the UUID is the guard.
"/api/v1/files/generated/**"
- ).permitAll()
+ ).permitAll();
+ // Swagger UI / OpenAPI document — explicit rule rather than the
+ // permitAll() fallthrough. Public for local dev, admin-only in
+ // production, driven by mateclaw.openapi.expose-ui.
+ if (exposeOpenApiUi) {
+ auth.requestMatchers(OPENAPI_PATHS).permitAll();
+ } else {
+ auth.requestMatchers(OPENAPI_PATHS).hasRole("ADMIN");
+ }
// 所有其他 API 接口需要认证
- .requestMatchers("/api/**").authenticated()
- // 非 API 请求(前端路由、静态资源、Swagger、H2 Console 等)全部放行
- .anyRequest().permitAll()
- )
+ auth.requestMatchers("/api/**").authenticated()
+ // 非 API 请求(前端路由、静态资源、H2 Console 等)全部放行
+ .anyRequest().permitAll();
+ })
.exceptionHandling(ex -> ex
.authenticationEntryPoint((request, response, authException) -> {
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
diff --git a/mateclaw-server/src/main/resources/application-kingbase.yml b/mateclaw-server/src/main/resources/application-kingbase.yml
index 85192afe..c570cba2 100644
--- a/mateclaw-server/src/main/resources/application-kingbase.yml
+++ b/mateclaw-server/src/main/resources/application-kingbase.yml
@@ -69,3 +69,11 @@ mybatis-plus:
mate:
wiki:
require-allowed-roots: true
+
+# Production hardening: lock down the Swagger UI / OpenAPI document so it is not
+# anonymously browsable. Requires a global admin (ROLE_ADMIN); SecurityConfig
+# enforces it. Set MATECLAW_OPENAPI_EXPOSE_UI=true to re-open it for an
+# internal/staging host.
+mateclaw:
+ openapi:
+ expose-ui: ${MATECLAW_OPENAPI_EXPOSE_UI:false}
diff --git a/mateclaw-server/src/main/resources/application-mysql.yml b/mateclaw-server/src/main/resources/application-mysql.yml
index 945e7c81..efe33c1d 100644
--- a/mateclaw-server/src/main/resources/application-mysql.yml
+++ b/mateclaw-server/src/main/resources/application-mysql.yml
@@ -44,3 +44,11 @@ mate:
allowed-source-roots: ${MATE_WIKI_ALLOWED_SOURCE_ROOTS:}
watcher-enabled: ${MATE_WIKI_WATCHER_ENABLED:false}
watcher-interval-ms: ${MATE_WIKI_WATCHER_INTERVAL_MS:300000}
+
+# Production hardening: lock down the Swagger UI / OpenAPI document so it is not
+# anonymously browsable. Requires a global admin (ROLE_ADMIN); SecurityConfig
+# enforces it. Set MATECLAW_OPENAPI_EXPOSE_UI=true to re-open it for an
+# internal/staging host.
+mateclaw:
+ openapi:
+ expose-ui: ${MATECLAW_OPENAPI_EXPOSE_UI:false}
diff --git a/mateclaw-server/src/main/resources/application-postgres.yml b/mateclaw-server/src/main/resources/application-postgres.yml
index a9e6a829..38a3bc21 100644
--- a/mateclaw-server/src/main/resources/application-postgres.yml
+++ b/mateclaw-server/src/main/resources/application-postgres.yml
@@ -69,3 +69,11 @@ mate:
allowed-source-roots: ${MATE_WIKI_ALLOWED_SOURCE_ROOTS:}
watcher-enabled: ${MATE_WIKI_WATCHER_ENABLED:false}
watcher-interval-ms: ${MATE_WIKI_WATCHER_INTERVAL_MS:300000}
+
+# Production hardening: lock down the Swagger UI / OpenAPI document so it is not
+# anonymously browsable. Requires a global admin (ROLE_ADMIN); SecurityConfig
+# enforces it. Set MATECLAW_OPENAPI_EXPOSE_UI=true to re-open it for an
+# internal/staging host.
+mateclaw:
+ openapi:
+ expose-ui: ${MATECLAW_OPENAPI_EXPOSE_UI:false}
diff --git a/mateclaw-server/src/main/resources/application.yml b/mateclaw-server/src/main/resources/application.yml
index 32a479be..f3b074b1 100644
--- a/mateclaw-server/src/main/resources/application.yml
+++ b/mateclaw-server/src/main/resources/application.yml
@@ -145,6 +145,11 @@ mateclaw:
server-url: ${MATECLAW_OPENAPI_SERVER_URL:}
# description 留空则使用 OpenApiConfig 中的内置默认描述
description: ${MATECLAW_OPENAPI_DESCRIPTION:}
+ # 是否公开 Swagger UI / OpenAPI 文档路径(/swagger-ui*、/v3/api-docs*、/webjars/**)。
+ # true = 任何人可浏览(本地开发 / 内网默认);
+ # false = 需要全局管理员(ROLE_ADMIN)才能访问,由 SecurityConfig 强制。
+ # 生产数据库 profile(mysql/kingbase/postgres)默认覆盖为 false。
+ expose-ui: ${MATECLAW_OPENAPI_EXPOSE_UI:true}
jwt:
secret: ${JWT_SECRET:MateClaw-JWT-Secret-Key-2024-Please-Change-In-Production}
expiration: 86400000
diff --git a/mateclaw-server/src/main/resources/docs/en/openapi.md b/mateclaw-server/src/main/resources/docs/en/openapi.md
index 21422f99..6fdcc5bf 100644
--- a/mateclaw-server/src/main/resources/docs/en/openapi.md
+++ b/mateclaw-server/src/main/resources/docs/en/openapi.md
@@ -55,16 +55,24 @@ mateclaw:
version: ${MATECLAW_OPENAPI_VERSION:1.0}
server-url: ${MATECLAW_OPENAPI_SERVER_URL:} # empty → derived from request host
description: ${MATECLAW_OPENAPI_DESCRIPTION:} # empty → built-in default
+ expose-ui: ${MATECLAW_OPENAPI_EXPOSE_UI:true} # whether the Swagger/OpenAPI paths are public, see the security section below
```
When `server-url` is empty, SpringDoc derives it from the request host so "Try it out" hits the right address; for fixed production URLs (e.g. behind a reverse proxy), set `MATECLAW_OPENAPI_SERVER_URL=https://mate.example.com`.
-## ⚠️ Security note: Swagger is currently public
+## 🔒 Access control: Swagger is locked down by default in production
-In `SecurityConfig.filterChain`, `/api/**` requires authentication, but `/swagger-ui*`, `/v3/api-docs*`, and `/webjars/**` fall through to `.anyRequest().permitAll()` — meaning **Swagger UI is publicly accessible** without login; anyone can browse the full endpoint surface (including request/response schemas).
+Access to the Swagger UI / OpenAPI document paths (`/swagger-ui*`, `/v3/api-docs*`, `/webjars/**`) is controlled by the `mateclaw.openapi.expose-ui` flag and enforced explicitly in `SecurityConfig.filterChain` (no longer relying on the `.anyRequest().permitAll()` fallthrough):
-- Local dev / intranet deployments: usually acceptable.
-- Public production deployments: consider adding an explicit auth rule for the Swagger paths in `SecurityConfig` (e.g. require `@RequireGlobalAdmin`). Don't rely on the network layer alone. When locking it down, edit `SecurityConfig`, not `OpenApiConfig`.
+| `expose-ui` | Behavior | Default profile |
+|---|---|---|
+| `true` | Publicly accessible — anyone can browse the full endpoint surface (incl. request/response schemas) without login | Local / default profile (H2, desktop) |
+| `false` | Requires a global admin (`ROLE_ADMIN`); anonymous → 401, non-admin → 403 | Production database profiles (`mysql` / `kingbase` / `postgres`) |
+
+- Local dev: defaults to `true`, so `http://localhost:18088/swagger-ui.html` is reachable directly.
+- Public production: defaults to `false` (locked down). To temporarily open it on an internal/staging host, set `MATECLAW_OPENAPI_EXPOSE_UI=true`.
+- Note: once locked, opening `/swagger-ui.html` in a browser won't automatically carry the SPA's JWT (the token lives in localStorage, not a cookie), so even an admin cannot open it from the browser directly. To debug, either set `expose-ui=true` temporarily, or fetch `/v3/api-docs` with a client that sends the `Authorization` header.
+- The access rule lives only in `SecurityConfig`, not `OpenApiConfig`.
## See also
diff --git a/mateclaw-server/src/main/resources/docs/zh/openapi.md b/mateclaw-server/src/main/resources/docs/zh/openapi.md
index b076811d..7bc8fa94 100644
--- a/mateclaw-server/src/main/resources/docs/zh/openapi.md
+++ b/mateclaw-server/src/main/resources/docs/zh/openapi.md
@@ -55,16 +55,24 @@ mateclaw:
version: ${MATECLAW_OPENAPI_VERSION:1.0}
server-url: ${MATECLAW_OPENAPI_SERVER_URL:} # 留空则从请求 host 推导
description: ${MATECLAW_OPENAPI_DESCRIPTION:} # 留空则用内置默认描述
+ expose-ui: ${MATECLAW_OPENAPI_EXPOSE_UI:true} # 是否公开 Swagger/OpenAPI 路径,见下方安全章节
```
`server-url` 留空时由 SpringDoc 从请求 host 推导,避免 "Try it out" 打到错误地址;生产若需固定(如反代后),设 `MATECLAW_OPENAPI_SERVER_URL=https://mate.example.com`。
-## ⚠️ 安全提示:Swagger 当前公开可访问
+## 🔒 访问控制:Swagger 生产默认收口
-`SecurityConfig.filterChain` 中 `/api/**` 要求认证,但 `/swagger-ui*`、`/v3/api-docs*`、`/webjars/**` 落到 `.anyRequest().permitAll()` —— 即 **Swagger UI 当前是公开可访问的**,无需登录即可浏览全部端点结构(含请求/响应 schema)。
+Swagger UI / OpenAPI 文档路径(`/swagger-ui*`、`/v3/api-docs*`、`/webjars/**`)的访问由 `mateclaw.openapi.expose-ui` 开关控制,并由 `SecurityConfig.filterChain` 显式强制(不再依赖 `.anyRequest().permitAll()` 兜底):
-- 本地开发 / 内网部署:通常可接受。
-- 公网生产部署:建议在 `SecurityConfig` 显式给 Swagger 路径加鉴权规则(如要求 `@RequireGlobalAdmin`),**不要**仅靠网络层防护。收口时应改 `SecurityConfig`,而非 `OpenApiConfig`。
+| `expose-ui` | 行为 | 默认生效的场景 |
+|---|---|---|
+| `true` | 公开可访问,无需登录即可浏览全部端点结构(含请求/响应 schema) | 本地 / 默认 profile(H2、桌面版) |
+| `false` | 需要全局管理员(`ROLE_ADMIN`);匿名访问返回 401,非管理员返回 403 | 生产数据库 profile(`mysql` / `kingbase` / `postgres`) |
+
+- 本地开发:默认 `true`,`http://localhost:18088/swagger-ui.html` 直接可访问。
+- 公网生产:默认 `false`,已收口。如确需在内网/预发环境临时打开,设 `MATECLAW_OPENAPI_EXPOSE_UI=true`。
+- 注意:锁定后浏览器直接访问 `/swagger-ui.html` 不会自动携带 SPA 的 JWT(token 存于 localStorage 而非 Cookie),因此即使管理员也无法在浏览器里直接打开;如需调试,临时置 `expose-ui=true` 或改用带 `Authorization` 头的客户端拉取 `/v3/api-docs`。
+- 访问规则只在 `SecurityConfig`,不在 `OpenApiConfig`。
## 关联
diff --git a/mateclaw-server/src/test/java/vip/mate/config/OpenApiExposedAccessTest.java b/mateclaw-server/src/test/java/vip/mate/config/OpenApiExposedAccessTest.java
new file mode 100644
index 00000000..5493c311
--- /dev/null
+++ b/mateclaw-server/src/test/java/vip/mate/config/OpenApiExposedAccessTest.java
@@ -0,0 +1,37 @@
+package vip.mate.config;
+
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.boot.test.web.client.TestRestTemplate;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.ResponseEntity;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+/**
+ * Local/dev posture: with {@code mateclaw.openapi.expose-ui=true} (the base
+ * {@code application.yml} default) the Swagger UI / OpenAPI document stays
+ * anonymously reachable so developers can browse and debug without a login.
+ */
+@SpringBootTest(
+ webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT,
+ properties = {
+ "spring.flyway.enabled=true",
+ "spring.flyway.locations=classpath:db/migration/h2",
+ "mateclaw.openapi.expose-ui=true"
+ }
+)
+class OpenApiExposedAccessTest {
+
+ @Autowired
+ private TestRestTemplate rest;
+
+ @Test
+ @DisplayName("Anonymous OpenAPI JSON is reachable (200) when expose-ui=true")
+ void anonymousApiDocsReachable() {
+ ResponseEntity resp = rest.getForEntity("/v3/api-docs", String.class);
+ assertEquals(HttpStatus.OK, resp.getStatusCode());
+ }
+}
diff --git a/mateclaw-server/src/test/java/vip/mate/config/OpenApiLockedDownAccessTest.java b/mateclaw-server/src/test/java/vip/mate/config/OpenApiLockedDownAccessTest.java
new file mode 100644
index 00000000..2ffce683
--- /dev/null
+++ b/mateclaw-server/src/test/java/vip/mate/config/OpenApiLockedDownAccessTest.java
@@ -0,0 +1,59 @@
+package vip.mate.config;
+
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.boot.test.web.client.TestRestTemplate;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.ResponseEntity;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+/**
+ * Production posture: with {@code mateclaw.openapi.expose-ui=false} the Swagger
+ * UI / OpenAPI document paths must NOT be anonymously reachable. They fall under
+ * an explicit {@code hasRole('ADMIN')} rule in {@link SecurityConfig}, so an
+ * unauthenticated request is rejected by the authentication entry point (401)
+ * instead of leaking the full API surface.
+ *
+ * Uses a real embedded servlet container ({@code RANDOM_PORT}) because the app
+ * registers a WebSocket endpoint that requires a servlet {@code ServerContainer},
+ * which the MockMvc-only environment does not provide.
+ */
+@SpringBootTest(
+ webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT,
+ properties = {
+ "spring.flyway.enabled=true",
+ "spring.flyway.locations=classpath:db/migration/h2",
+ "mateclaw.openapi.expose-ui=false"
+ }
+)
+class OpenApiLockedDownAccessTest {
+
+ @Autowired
+ private TestRestTemplate rest;
+
+ @Test
+ @DisplayName("Anonymous OpenAPI JSON is blocked (401) when expose-ui=false")
+ void anonymousApiDocsBlocked() {
+ ResponseEntity resp = rest.getForEntity("/v3/api-docs", String.class);
+ assertEquals(HttpStatus.UNAUTHORIZED, resp.getStatusCode());
+ }
+
+ @Test
+ @DisplayName("Anonymous Swagger UI is blocked (401) when expose-ui=false")
+ void anonymousSwaggerUiBlocked() {
+ ResponseEntity resp = rest.getForEntity("/swagger-ui/index.html", String.class);
+ assertEquals(HttpStatus.UNAUTHORIZED, resp.getStatusCode());
+ }
+
+ @Test
+ @DisplayName("A genuinely public endpoint stays reachable when Swagger is locked")
+ void publicEndpointStillReachable() {
+ // GET /api/v1/settings/language is permitAll (first-paint i18n); proves
+ // the lockdown is scoped to the OpenAPI paths, not a blanket denial.
+ ResponseEntity resp = rest.getForEntity("/api/v1/settings/language", String.class);
+ assertEquals(HttpStatus.OK, resp.getStatusCode());
+ }
+}
diff --git a/mateclaw-server/src/test/java/vip/mate/tool/guard/WorkspacePathGuardShellTest.java b/mateclaw-server/src/test/java/vip/mate/tool/guard/WorkspacePathGuardShellTest.java
index 218d9018..02a58a73 100644
--- a/mateclaw-server/src/test/java/vip/mate/tool/guard/WorkspacePathGuardShellTest.java
+++ b/mateclaw-server/src/test/java/vip/mate/tool/guard/WorkspacePathGuardShellTest.java
@@ -8,6 +8,8 @@ import org.junit.jupiter.api.condition.DisabledOnOs;
import org.junit.jupiter.api.condition.OS;
import vip.mate.tool.builtin.ToolExecutionContext;
+import java.nio.file.Path;
+
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
import static org.junit.jupiter.api.Assertions.assertThrows;
@@ -26,8 +28,16 @@ class WorkspacePathGuardShellTest {
private static final String WORKSPACE = "/tmp/ws-guard-shell-test";
private static final String SKILL_ROOT = "/tmp/ws-guard-skill-root";
+ // The global fallback sandbox root is process-wide mutable static state that
+ // another test (or the app context, in a full-suite run) may have set. Save
+ // and restore it so the "no workspace configured" cases here are deterministic
+ // rather than depending on whatever the previous test left behind.
+ private Path savedDefaultRoot;
+
@BeforeEach
void setup() {
+ savedDefaultRoot = WorkspacePathGuard.getDefaultRoot();
+ WorkspacePathGuard.setDefaultRoot(null);
ToolExecutionContext.set("conv-test", "test-user", WORKSPACE);
}
@@ -35,6 +45,7 @@ class WorkspacePathGuardShellTest {
void teardown() {
ToolExecutionContext.clear();
WorkspacePathGuard.setSkillRoot(null);
+ WorkspacePathGuard.setDefaultRoot(savedDefaultRoot == null ? null : savedDefaultRoot.toString());
}
// ==================== No-op when sandbox absent ====================