feat(security): gate Swagger/OpenAPI UI behind mateclaw.openapi.expose-ui flag

- explicit SecurityConfig authorization for /swagger-ui*, /v3/api-docs*, /webjars/**
- public for local/default profile; admin-only (ROLE_ADMIN) by default in production DB profiles
- override via MATECLAW_OPENAPI_EXPOSE_UI; add RANDOM_PORT integration tests and docs
This commit is contained in:
matevip 2026-06-24 10:42:46 +08:00
parent 865513a3b6
commit 982c6c048c
12 changed files with 215 additions and 21 deletions

View File

@ -34,6 +34,11 @@ MATECLAW_CORS_ALLOWED_ORIGINS=
# 留空时回退到当前请求的 host再退回相对路径。反代后部署建议显式设置。
MATECLAW_PUBLIC_BASE_URL=
# 是否公开 Swagger UI / OpenAPI 文档(/swagger-ui.html、/v3/api-docs
# 生产数据库 profilemysql/kingbase/postgres默认 false —— 匿名无法浏览全部
# 端点结构需全局管理员ROLE_ADMIN。仅在内网/预发临时调试时设为 true。
MATECLAW_OPENAPI_EXPOSE_UI=
# SearXNG 会话密钥(容器内部用,留空会用开发默认值)。生产部署请设成 32+ 位随机串。
# openssl rand -hex 32
SEARXNG_SECRET=

View File

@ -27,11 +27,12 @@ import java.util.List;
* Personal Access Token {@code mc_} 开头PAT_PREFIX因此 Swagger
* UI Authorize 按钮只需填入任意一种 token 即可
*
* <h3>注意Swagger 当前公开可访问</h3>
* {@link SecurityConfig#filterChain} {@code /api/**} 要求认证
* {@code /swagger-ui*}{@code /v3/api-docs*}{@code /webjars/**} 落到
* {@code .anyRequest().permitAll()} Swagger UI 当前是公开的如生产环境
* 需要收口应在 SecurityConfig 显式加规则而不是改本类
* <h3>访问控制 SecurityConfig不在本类</h3>
* Swagger UI / OpenAPI 文档路径{@code /swagger-ui*}{@code /v3/api-docs*}
* {@code /webjars/**}的鉴权由 {@link SecurityConfig#filterChain} 通过
* {@code mateclaw.openapi.expose-ui} 开关控制本地/默认 profile 公开
* 生产数据库 profilemysql/kingbase/postgres默认要求全局管理员
* {@code ROLE_ADMIN}访问规则只属于 SecurityConfig不要加到本类
*
* <h3>未做的事全局配置 + 安全方案范围一致</h3>
* 不逐个 Controller {@code @Parameter} / {@code @ApiResponse} /

View File

@ -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 请求前端路由静态资源SwaggerH2 Console 全部放行
.anyRequest().permitAll()
)
auth.requestMatchers("/api/**").authenticated()
// API 请求前端路由静态资源H2 Console 全部放行
.anyRequest().permitAll();
})
.exceptionHandling(ex -> ex
.authenticationEntryPoint((request, response, authException) -> {
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);

View File

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

View File

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

View File

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

View File

@ -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 强制。
# 生产数据库 profilemysql/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

View File

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

View File

@ -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 | 本地 / 默认 profileH2、桌面版 |
| `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 的 JWTtoken 存于 localStorage 而非 Cookie因此即使管理员也无法在浏览器里直接打开如需调试临时置 `expose-ui=true` 或改用带 `Authorization` 头的客户端拉取 `/v3/api-docs`
- 访问规则只在 `SecurityConfig`,不在 `OpenApiConfig`
## 关联

View File

@ -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<String> resp = rest.getForEntity("/v3/api-docs", String.class);
assertEquals(HttpStatus.OK, resp.getStatusCode());
}
}

View File

@ -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.
*
* <p>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<String> 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<String> 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<String> resp = rest.getForEntity("/api/v1/settings/language", String.class);
assertEquals(HttpStatus.OK, resp.getStatusCode());
}
}

View File

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