Mock 地址安全加固:空间 ID → 16 位随机 mock_token
- 问题:/mock/{自增空间ID} 可枚举且服务绑定所有网卡,局域网可猜他人空间
- workspaces 新增 mock_token(UNIQUE,随机 8 字节 hex;老库自动 ALTER + 回填)
- /mock/{token}/path:解析失败统一 404(不暴露存在性);限流 key 沿用 token
- /api/me 与发布响应返回 token 形态 base;弹窗 URL 改用 token
- 验证:token URL 正常随机生成;旧数字 ID 与任意伪造 token 均 404
This commit is contained in:
1 parent
d9ccccd280
commit
0edd3e674a
9 files changed
+87
-17
No files matched your search
Binary file not shown.
Binary file not shown.
@@ -211,7 +211,11 @@ func (a *API) handlePublishMock(c *gin.Context, user *store.User) {
|
|||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "发布失败:" + err.Error()})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "发布失败:" + err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
c.JSON(http.StatusOK, gin.H{"ok": true, "count": len(req.Endpoints), "base": "/mock/" + strconv.FormatInt(ws.ID, 10)})
|
base := ws.MockToken
|
||||||
|
if base == "" {
|
||||||
|
base = strconv.FormatInt(ws.ID, 10) // 兜底(不应出现)
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"ok": true, "count": len(req.Endpoints), "base": "/mock/" + base})
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---------------- 工作区同步 ---------------- */
|
/* ---------------- 工作区同步 ---------------- */
|
||||||
|
|||||||
@@ -77,7 +77,7 @@ Body 工具栏提供两个辅助按钮:
|
|||||||
|
|
||||||
1. 登录并切换到「我的空间」或团队空间(Mock 按空间隔离);
|
1. 登录并切换到「我的空间」或团队空间(Mock 按空间隔离);
|
||||||
2. HTTP 请求 URL 行点 **Mock 链接** → 选择响应状态码与延迟 → **发布**(一次发布当前空间集合内全部 HTTP 接口);
|
2. HTTP 请求 URL 行点 **Mock 链接** → 选择响应状态码与延迟 → **发布**(一次发布当前空间集合内全部 HTTP 接口);
|
||||||
3. 得到地址:`http://127.0.0.1:8787/mock/{空间ID}{路径}`,例如 `http://127.0.0.1:8787/mock/3/api/users`。
|
3. 得到地址:`http://127.0.0.1:8787/mock/{空间随机地址}{路径}`(每空间一个 16 位随机片段,不可枚举、不暴露归属),例如 `http://127.0.0.1:8787/mock/9f2c7a1b3d5e4f60/api/users`。
|
||||||
|
|
||||||
特点:
|
特点:
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ package mocksrv
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
@@ -25,6 +24,7 @@ type Endpoint struct {
|
|||||||
type storeAPI interface {
|
type storeAPI interface {
|
||||||
ReplaceMockEndpoints(workspaceID int64, list []Endpoint) error
|
ReplaceMockEndpoints(workspaceID int64, list []Endpoint) error
|
||||||
FindMockEndpoint(workspaceID int64, method, path string) (*Endpoint, bool, error)
|
FindMockEndpoint(workspaceID int64, method, path string) (*Endpoint, bool, error)
|
||||||
|
ResolveMockToken(token string) (int64, bool, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---------------- 限流:20 QPS / 60 QPM(按 IP + 空间,滑动窗口) ---------------- */
|
/* ---------------- 限流:20 QPS / 60 QPM(按 IP + 空间,滑动窗口) ---------------- */
|
||||||
@@ -121,7 +121,16 @@ func (s *Server) handle(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
wsID, _ := strconv.ParseInt(wsid, 10, 64)
|
// wsid 实为 mock_token:不可枚举、不暴露空间归属;解析失败统一 404
|
||||||
|
wsID, ok, err := s.store.ResolveMockToken(wsid)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "mock 存储读取失败"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !ok {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "Mock 地址无效或已失效"})
|
||||||
|
return
|
||||||
|
}
|
||||||
ep, found, err := s.store.FindMockEndpoint(wsID, method, path)
|
ep, found, err := s.store.FindMockEndpoint(wsID, method, path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "mock 存储读取失败"})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "mock 存储读取失败"})
|
||||||
|
|||||||
@@ -80,7 +80,8 @@ CREATE TABLE IF NOT EXISTS workspaces (
|
|||||||
team_id INTEGER REFERENCES teams(id) ON DELETE CASCADE,
|
team_id INTEGER REFERENCES teams(id) ON DELETE CASCADE,
|
||||||
owner_id INTEGER NOT NULL REFERENCES users(id),
|
owner_id INTEGER NOT NULL REFERENCES users(id),
|
||||||
data TEXT NOT NULL DEFAULT '',
|
data TEXT NOT NULL DEFAULT '',
|
||||||
updated_at INTEGER NOT NULL
|
updated_at INTEGER NOT NULL,
|
||||||
|
mock_token TEXT UNIQUE
|
||||||
);
|
);
|
||||||
CREATE INDEX IF NOT EXISTS idx_tokens_user ON tokens(user_id);
|
CREATE INDEX IF NOT EXISTS idx_tokens_user ON tokens(user_id);
|
||||||
CREATE INDEX IF NOT EXISTS idx_members_user ON team_members(user_id);
|
CREATE INDEX IF NOT EXISTS idx_members_user ON team_members(user_id);
|
||||||
@@ -99,7 +100,40 @@ CREATE TABLE IF NOT EXISTS mock_endpoints (
|
|||||||
CREATE INDEX IF NOT EXISTS idx_mock_ws ON mock_endpoints(workspace_id);
|
CREATE INDEX IF NOT EXISTS idx_mock_ws ON mock_endpoints(workspace_id);
|
||||||
CREATE INDEX IF NOT EXISTS idx_workspaces_owner ON workspaces(owner_id);
|
CREATE INDEX IF NOT EXISTS idx_workspaces_owner ON workspaces(owner_id);
|
||||||
`)
|
`)
|
||||||
return err
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
// 老库补列 + 回填随机 token(幂等)
|
||||||
|
if _, err := s.db.Exec(`ALTER TABLE workspaces ADD COLUMN mock_token TEXT`); err == nil {
|
||||||
|
_, _ = s.db.Exec(`CREATE UNIQUE INDEX IF NOT EXISTS idx_workspaces_mock_token ON workspaces(mock_token)`)
|
||||||
|
}
|
||||||
|
rows, err := s.db.Query(`SELECT id FROM workspaces WHERE mock_token IS NULL OR mock_token = ''`)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
var ids []int64
|
||||||
|
for rows.Next() {
|
||||||
|
var id int64
|
||||||
|
if err := rows.Scan(&id); err != nil {
|
||||||
|
rows.Close()
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
ids = append(ids, id)
|
||||||
|
}
|
||||||
|
rows.Close()
|
||||||
|
for _, id := range ids {
|
||||||
|
if _, err := s.db.Exec(`UPDATE workspaces SET mock_token = ? WHERE id = ?`, newMockToken(), id); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// newMockToken 16 位随机 hex(URL 片段,不可枚举)
|
||||||
|
func newMockToken() string {
|
||||||
|
raw := make([]byte, 8)
|
||||||
|
_, _ = rand.Read(raw)
|
||||||
|
return hex.EncodeToString(raw)
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---------------- 密码 ---------------- */
|
/* ---------------- 密码 ---------------- */
|
||||||
@@ -227,8 +261,8 @@ func (s *Store) CreateTeam(ownerID int64, name string) (*Team, error) {
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if _, err := s.db.Exec(
|
if _, err := s.db.Exec(
|
||||||
`INSERT INTO workspaces(name, team_id, owner_id, data, updated_at) VALUES(?,?,?,'',?)`,
|
`INSERT INTO workspaces(name, team_id, owner_id, data, updated_at, mock_token) VALUES(?,?,?,'',?,?)`,
|
||||||
name, id, ownerID, now,
|
name, id, ownerID, now, newMockToken(),
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -285,6 +319,7 @@ type Workspace struct {
|
|||||||
OwnerID int64 `json:"ownerId"`
|
OwnerID int64 `json:"ownerId"`
|
||||||
Data string `json:"data"`
|
Data string `json:"data"`
|
||||||
UpdatedAt int64 `json:"updatedAt"`
|
UpdatedAt int64 `json:"updatedAt"`
|
||||||
|
MockToken string `json:"mockToken"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// WorkspacesOfUser:个人空间 + 所在团队的空间(含个人空间自动补齐)
|
// WorkspacesOfUser:个人空间 + 所在团队的空间(含个人空间自动补齐)
|
||||||
@@ -294,8 +329,8 @@ func (s *Store) WorkspacesOfUser(userID int64) ([]Workspace, error) {
|
|||||||
err := s.db.QueryRow(`SELECT 1 FROM workspaces WHERE owner_id = ? AND team_id IS NULL`, userID).Scan(&one)
|
err := s.db.QueryRow(`SELECT 1 FROM workspaces WHERE owner_id = ? AND team_id IS NULL`, userID).Scan(&one)
|
||||||
if errors.Is(err, sql.ErrNoRows) {
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
if _, err := s.db.Exec(
|
if _, err := s.db.Exec(
|
||||||
`INSERT INTO workspaces(name, team_id, owner_id, data, updated_at) VALUES(?,NULL,?,'',?)`,
|
`INSERT INTO workspaces(name, team_id, owner_id, data, updated_at, mock_token) VALUES(?,NULL,?,'',?,?)`,
|
||||||
"我的空间", userID, time.Now().Unix(),
|
"我的空间", userID, time.Now().Unix(), newMockToken(),
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -304,7 +339,7 @@ func (s *Store) WorkspacesOfUser(userID int64) ([]Workspace, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
rows, err := s.db.Query(`
|
rows, err := s.db.Query(`
|
||||||
SELECT w.id, w.name, w.team_id, w.owner_id, IFNULL(w.data,''), w.updated_at
|
SELECT w.id, w.name, w.team_id, w.owner_id, IFNULL(w.data,''), w.updated_at, IFNULL(w.mock_token,'')
|
||||||
FROM workspaces w
|
FROM workspaces w
|
||||||
WHERE w.owner_id = ?
|
WHERE w.owner_id = ?
|
||||||
OR w.team_id IN (SELECT team_id FROM team_members WHERE user_id = ?)
|
OR w.team_id IN (SELECT team_id FROM team_members WHERE user_id = ?)
|
||||||
@@ -316,7 +351,7 @@ ORDER BY w.team_id IS NOT NULL, w.id`, userID, userID)
|
|||||||
var out []Workspace
|
var out []Workspace
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var w Workspace
|
var w Workspace
|
||||||
if err := rows.Scan(&w.ID, &w.Name, &w.TeamID, &w.OwnerID, &w.Data, &w.UpdatedAt); err != nil {
|
if err := rows.Scan(&w.ID, &w.Name, &w.TeamID, &w.OwnerID, &w.Data, &w.UpdatedAt, &w.MockToken); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
out = append(out, w)
|
out = append(out, w)
|
||||||
@@ -341,8 +376,8 @@ WHERE w.id = ? AND (w.owner_id = ?
|
|||||||
func (s *Store) WorkspaceByID(wsID int64) (*Workspace, error) {
|
func (s *Store) WorkspaceByID(wsID int64) (*Workspace, error) {
|
||||||
w := &Workspace{}
|
w := &Workspace{}
|
||||||
err := s.db.QueryRow(
|
err := s.db.QueryRow(
|
||||||
`SELECT id, name, team_id, owner_id, IFNULL(data,''), updated_at FROM workspaces WHERE id = ?`, wsID,
|
`SELECT id, name, team_id, owner_id, IFNULL(data,''), updated_at, IFNULL(mock_token,'') FROM workspaces WHERE id = ?`, wsID,
|
||||||
).Scan(&w.ID, &w.Name, &w.TeamID, &w.OwnerID, &w.Data, &w.UpdatedAt)
|
).Scan(&w.ID, &w.Name, &w.TeamID, &w.OwnerID, &w.Data, &w.UpdatedAt, &w.MockToken)
|
||||||
if errors.Is(err, sql.ErrNoRows) {
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
return nil, ErrNotFound
|
return nil, ErrNotFound
|
||||||
}
|
}
|
||||||
@@ -393,6 +428,19 @@ func (s *Store) FindMockEndpoint(workspaceID int64, method, path string) (*mocks
|
|||||||
return e, true, nil
|
return e, true, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ResolveMockToken:mock token → workspace id(不存在返回 false,不暴露信息)
|
||||||
|
func (s *Store) ResolveMockToken(token string) (int64, bool, error) {
|
||||||
|
var id int64
|
||||||
|
err := s.db.QueryRow(`SELECT id FROM workspaces WHERE mock_token = ?`, token).Scan(&id)
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return 0, false, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return 0, false, err
|
||||||
|
}
|
||||||
|
return id, true, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Store) SaveWorkspace(wsID int64, data string) (int64, error) {
|
func (s *Store) SaveWorkspace(wsID int64, data string) (int64, error) {
|
||||||
now := time.Now().Unix()
|
now := time.Now().Unix()
|
||||||
_, err := s.db.Exec(`UPDATE workspaces SET data = ?, updated_at = ? WHERE id = ?`, data, now, wsID)
|
_, err := s.db.Exec(`UPDATE workspaces SET data = ?, updated_at = ? WHERE id = ?`, data, now, wsID)
|
||||||
|
|||||||
@@ -3,7 +3,15 @@
|
|||||||
*/
|
*/
|
||||||
export type AccountUser = { id: number; name: string; email: string };
|
export type AccountUser = { id: number; name: string; email: string };
|
||||||
export type AccountTeam = { id: number; name: string; role: string; owner: string };
|
export type AccountTeam = { id: number; name: string; role: string; owner: string };
|
||||||
export type AccountWorkspace = { id: number; name: string; teamId: number | null; ownerId: number; updatedAt: number };
|
export type AccountWorkspace = {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
teamId: number | null;
|
||||||
|
ownerId: number;
|
||||||
|
updatedAt: number;
|
||||||
|
/** Mock 服务随机地址片段(不可枚举) */
|
||||||
|
mockToken?: string;
|
||||||
|
};
|
||||||
|
|
||||||
class ApiError extends Error {
|
class ApiError extends Error {
|
||||||
constructor(message: string, public status: number) {
|
constructor(message: string, public status: number) {
|
||||||
|
|||||||
@@ -36,7 +36,8 @@ export function MockLinkDialog({ node }: { node: HttpApiNode }) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const mockUrl = space ? `${location.origin.replace(/:\d+$/, ":8787")}/mock/${space.id}${urlPath}` : "";
|
const mockSeg = space?.mockToken ?? (space ? String(space.id) : "");
|
||||||
|
const mockUrl = mockSeg ? `${location.origin.replace(/:\d+$/, ":8787")}/mock/${mockSeg}${urlPath}` : "";
|
||||||
|
|
||||||
const publish = async () => {
|
const publish = async () => {
|
||||||
if (!account.token || !space) return;
|
if (!account.token || !space) return;
|
||||||
|
|||||||
@@ -133,7 +133,7 @@ type WorkspaceStore = PersistedShape & {
|
|||||||
token: string | null;
|
token: string | null;
|
||||||
user: { id: number; name: string; email: string } | null;
|
user: { id: number; name: string; email: string } | null;
|
||||||
teams: { id: number; name: string; role: string; owner: string }[];
|
teams: { id: number; name: string; role: string; owner: string }[];
|
||||||
spaces: { id: number; name: string; teamId: number | null; updatedAt: number }[];
|
spaces: { id: number; name: string; teamId: number | null; updatedAt: number; mockToken?: string }[];
|
||||||
activeSpaceId: number | null;
|
activeSpaceId: number | null;
|
||||||
syncing: boolean;
|
syncing: boolean;
|
||||||
lastSyncAt: number | null;
|
lastSyncAt: number | null;
|
||||||
|
|||||||
Reference in new issue
Block a user