mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-13 03:13:41 +08:00
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:
parent
865513a3b6
commit
982c6c048c
@ -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=
|
||||
|
||||
@ -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 公开,
|
||||
* 生产数据库 profile(mysql/kingbase/postgres)默认要求全局管理员
|
||||
* ({@code ROLE_ADMIN})。访问规则只属于 SecurityConfig,不要加到本类。
|
||||
*
|
||||
* <h3>未做的事(与「全局配置 + 安全方案」范围一致)</h3>
|
||||
* 不逐个 Controller 补 {@code @Parameter} / {@code @ApiResponse} /
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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`。
|
||||
|
||||
## 关联
|
||||
|
||||
|
||||
@ -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());
|
||||
}
|
||||
}
|
||||
@ -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());
|
||||
}
|
||||
}
|
||||
@ -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 ====================
|
||||
|
||||
Loading…
Reference in New Issue
Block a user