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