fix(confs): eliminate TOCTOU race in ConfigStore path resolution
- Split _resolve() into _validate_name() (pure string checks, lock-free
fast-fail) and _resolve_locked() (filesystem checks, must be called
under lock)
- Perform all filesystem operations (realpath → isfile → stat → open)
inside self._lock in get(), eliminating the window between path
validation and file open where a symlink swap could escape the
configs directory
- Apply same fix to reload(): validate name outside lock, resolve path
inside lock to prevent TOCTOU during targeted reload
- Hold lock during list() directory traversal so the `cached` flag
(which reads self._cache) is consistent with directory state; wrap
os.stat() in try/except to tolerate files disappearing mid-traversal
- Update docstring and README:
- Protocol description corrected to pymsg RpcServer/TCP
- Python client examples updated from legacy MsgClient to RpcClient
- Security section now documents TOCTOU mitigation and Slowloris
protection (added by earlier pymsg fix)
This commit is contained in:
1 parent
6c587a22d8
commit
9efb96ea66
2 files changed
+63
-34
No files matched your search
+13
-9
@@ -154,20 +154,22 @@ python3 confs.py --addr 127.0.0.1 --port 8010 --workers 4 --dir configs/
|
|||||||
### 调用示例(Python)
|
### 调用示例(Python)
|
||||||
|
|
||||||
```python
|
```python
|
||||||
import json
|
from pymsg import RpcClient
|
||||||
from pymsg import MsgClient
|
|
||||||
|
|
||||||
c = MsgClient("127.0.0.1", 8010, "my-service")
|
c = RpcClient("127.0.0.1", 8010, "my-service")
|
||||||
|
|
||||||
# 取配置全文
|
# 取配置全文(dict 直接传入,RpcClient 自动序列化/反序列化)
|
||||||
resp = json.loads(c.call(json.dumps({"_route": "get", "name": "example.ini"}), timeout=5))
|
resp = c.call("get", {"name": "example.ini"}, timeout=5)
|
||||||
content = resp["data"]["content"]
|
content = resp["data"]["content"]
|
||||||
|
|
||||||
# 按键取值
|
# 按键取值
|
||||||
resp = json.loads(c.call(json.dumps({
|
resp = c.call("get", {"name": "example.ini", "key": "server.port"}, timeout=5)
|
||||||
"_route": "get", "name": "example.ini", "key": "Server.port"
|
|
||||||
}), timeout=5))
|
|
||||||
port = resp["data"]["value"]
|
port = resp["data"]["value"]
|
||||||
|
|
||||||
|
# 列出所有配置
|
||||||
|
resp = c.call("list", {}, timeout=5)
|
||||||
|
for cfg in resp["data"]["configs"]:
|
||||||
|
print(cfg["name"], cfg["size"])
|
||||||
```
|
```
|
||||||
|
|
||||||
## 缓存机制
|
## 缓存机制
|
||||||
@@ -180,8 +182,10 @@ port = resp["data"]["value"]
|
|||||||
## 安全机制
|
## 安全机制
|
||||||
|
|
||||||
- **扩展名白名单**:仅允许 `.ini/.conf/.cfg/.json/.yaml/.yml/.toml/.txt`,其余一律拒绝
|
- **扩展名白名单**:仅允许 `.ini/.conf/.cfg/.json/.yaml/.yml/.toml/.txt`,其余一律拒绝
|
||||||
- **路径遍历防护**:配置名必须等于 `os.path.basename(name)`,且 realpath 必须位于 `configs/` 目录前缀之下
|
- **路径遍历防护**:配置名必须等于 `os.path.basename(name)`,且 realpath 必须位于 `configs/` 目录前缀之下;符号链接会被 `os.path.realpath()` 解析后校验
|
||||||
|
- **TOCTOU 防护**:所有文件系统操作(`realpath` → `stat` → `open`)均在持锁状态下原子完成,不存在"先校验后打开"的时间窗口,防止符号链接替换攻击
|
||||||
- **文件大小限制**:单配置文件上限 1 MiB(`MAX_CONFIG_SIZE`),超过则拒绝读取并返回错误,防止超大文件导致内存溢出
|
- **文件大小限制**:单配置文件上限 1 MiB(`MAX_CONFIG_SIZE`),超过则拒绝读取并返回错误,防止超大文件导致内存溢出
|
||||||
|
- **Slowloris 防护**:pymsg `MsgServer` 默认设置 10 秒接收超时,慢速发送的半开连接会被自动断开
|
||||||
- **仅监听本机**:默认 `127.0.0.1`,不对外暴露,仅供内部服务调用
|
- **仅监听本机**:默认 `127.0.0.1`,不对外暴露,仅供内部服务调用
|
||||||
- **无鉴权**:当前无身份校验,依赖"仅本机 + 内部网络"的信任边界;如需承载敏感配置(如 API 密钥),应额外评估鉴权方案
|
- **无鉴权**:当前无身份校验,依赖"仅本机 + 内部网络"的信任边界;如需承载敏感配置(如 API 密钥),应额外评估鉴权方案
|
||||||
|
|
||||||
|
|||||||
+50
-25
@@ -1,16 +1,17 @@
|
|||||||
"""
|
"""
|
||||||
confs — 统一配置服务(pymsg/TCP,默认 127.0.0.1:8010)
|
confs — 统一配置服务(pymsg RpcServer/TCP,默认 127.0.0.1:8010)
|
||||||
|
|
||||||
集中存放各微服务的配置文件,通过 pymsg 协议分发给内部服务,
|
集中存放各微服务的配置文件,通过 pymsg 高层 RPC 协议分发给内部服务,
|
||||||
替代各服务本地散落的 ini/json/yaml 配置文件,实现配置的统一管理与下发。
|
替代各服务本地散落的 ini/json/yaml 配置文件,实现配置的统一管理与下发。
|
||||||
|
|
||||||
pymsg 路由::
|
客户端使用 RpcClient 直接传 dict,无需手动拼 _route / json.dumps / json.loads::
|
||||||
|
|
||||||
{"_route": "get", "name": "example.ini"} → 取配置文件全文
|
c = RpcClient("127.0.0.1", 8010, "my-service")
|
||||||
{"_route": "get", "name": "example.ini", "key": "Server.port"} → 取单个键(ini/json)
|
c.call("get", {"name": "example.ini"})
|
||||||
{"_route": "list"} → 列出全部配置
|
c.call("get", {"name": "example.ini", "key": "server.port"})
|
||||||
{"_route": "reload", "name": "example.ini"} → 重载指定配置(缺省清空全部缓存)
|
c.call("list", {})
|
||||||
{"_route": "health"} → 健康检查
|
c.call("reload", {"name": "example.ini"})
|
||||||
|
c.call("health", {})
|
||||||
|
|
||||||
响应格式(与 db / smtp / captcha 一致)::
|
响应格式(与 db / smtp / captcha 一致)::
|
||||||
|
|
||||||
@@ -18,7 +19,8 @@ pymsg 路由::
|
|||||||
{"success": false, "error": "..."}
|
{"success": false, "error": "..."}
|
||||||
|
|
||||||
配置文件存放于 confs/configs/ 目录(可用 --dir 覆盖),内存缓存 +
|
配置文件存放于 confs/configs/ 目录(可用 --dir 覆盖),内存缓存 +
|
||||||
文件 mtime 变化自动重载;配置名校验防路径遍历。仅监听 127.0.0.1,不对外暴露。
|
文件 mtime 变化自动重载;配置名校验防路径遍历,所有文件系统操作在
|
||||||
|
持锁状态下原子完成以防 TOCTOU 竞态。仅监听 127.0.0.1,不对外暴露。
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import configparser
|
import configparser
|
||||||
@@ -86,13 +88,25 @@ class ConfigStore:
|
|||||||
self._cache = {} # name -> {"content": str, "mtime": float, "size": int}
|
self._cache = {} # name -> {"content": str, "mtime": float, "size": int}
|
||||||
self._lock = threading.Lock()
|
self._lock = threading.Lock()
|
||||||
|
|
||||||
def _resolve(self, name: str) -> str:
|
@staticmethod
|
||||||
"""校验配置名并返回绝对路径(防路径遍历 + 扩展名白名单)。"""
|
def _validate_name(name: str) -> None:
|
||||||
|
"""纯字符串校验配置名(basename + 扩展名白名单),不访问文件系统。
|
||||||
|
|
||||||
|
用于锁外快速拒绝明显非法的请求;真实路径解析和文件存在性检查
|
||||||
|
必须在持锁后通过 _resolve_locked() 完成,防止 TOCTOU 竞态。
|
||||||
|
"""
|
||||||
if not name or name != os.path.basename(name):
|
if not name or name != os.path.basename(name):
|
||||||
raise ConfigNameInvalid(f"非法配置名: {name!r}")
|
raise ConfigNameInvalid(f"非法配置名: {name!r}")
|
||||||
ext = os.path.splitext(name)[1].lower()
|
ext = os.path.splitext(name)[1].lower()
|
||||||
if ext not in ALLOWED_EXT:
|
if ext not in ALLOWED_EXT:
|
||||||
raise ConfigNameInvalid(f"不支持的配置类型: {ext or '(无扩展名)'}")
|
raise ConfigNameInvalid(f"不支持的配置类型: {ext or '(无扩展名)'}")
|
||||||
|
|
||||||
|
def _resolve_locked(self, name: str) -> str:
|
||||||
|
"""持锁状态下解析配置名到绝对路径,并校验路径安全性与文件存在性。
|
||||||
|
|
||||||
|
必须在 self._lock 持有期间调用;通过 os.path.realpath() 即时解析符号链接,
|
||||||
|
确保从校验到打开文件之间不存在替换窗口,防御 TOCTOU 攻击。
|
||||||
|
"""
|
||||||
path = os.path.realpath(os.path.join(self.root, name))
|
path = os.path.realpath(os.path.join(self.root, name))
|
||||||
if not path.startswith(self.root + os.sep):
|
if not path.startswith(self.root + os.sep):
|
||||||
raise ConfigNameInvalid(f"非法配置名: {name!r}")
|
raise ConfigNameInvalid(f"非法配置名: {name!r}")
|
||||||
@@ -101,9 +115,14 @@ class ConfigStore:
|
|||||||
return path
|
return path
|
||||||
|
|
||||||
def get(self, name: str) -> dict:
|
def get(self, name: str) -> dict:
|
||||||
"""取配置内容(带缓存;文件 mtime 变化时自动重新读取)。"""
|
"""取配置内容(带缓存;文件 mtime 变化时自动重新读取)。
|
||||||
path = self._resolve(name)
|
|
||||||
|
所有文件系统操作(realpath/stat/open)均在锁内完成,
|
||||||
|
防止路径校验与文件读取之间的 TOCTOU 竞态。
|
||||||
|
"""
|
||||||
|
self._validate_name(name)
|
||||||
with self._lock:
|
with self._lock:
|
||||||
|
path = self._resolve_locked(name)
|
||||||
try:
|
try:
|
||||||
st = os.stat(path)
|
st = os.stat(path)
|
||||||
mtime = st.st_mtime
|
mtime = st.st_mtime
|
||||||
@@ -130,18 +149,22 @@ class ConfigStore:
|
|||||||
return entry
|
return entry
|
||||||
|
|
||||||
def list(self) -> list:
|
def list(self) -> list:
|
||||||
"""列出配置目录下全部允许类型的配置文件。"""
|
"""列出配置目录下全部允许类型的配置文件(持锁遍历,保证 _cache 访问一致)。"""
|
||||||
out = []
|
out = []
|
||||||
for name in sorted(os.listdir(self.root)):
|
with self._lock:
|
||||||
if os.path.splitext(name)[1].lower() not in ALLOWED_EXT:
|
for name in sorted(os.listdir(self.root)):
|
||||||
continue
|
if os.path.splitext(name)[1].lower() not in ALLOWED_EXT:
|
||||||
path = os.path.join(self.root, name)
|
continue
|
||||||
if not os.path.isfile(path):
|
path = os.path.join(self.root, name)
|
||||||
continue
|
if not os.path.isfile(path):
|
||||||
st = os.stat(path)
|
continue
|
||||||
out.append({"name": name, "size": st.st_size,
|
try:
|
||||||
"mtime": _iso(st.st_mtime),
|
st = os.stat(path)
|
||||||
"cached": name in self._cache})
|
except OSError:
|
||||||
|
continue
|
||||||
|
out.append({"name": name, "size": st.st_size,
|
||||||
|
"mtime": _iso(st.st_mtime),
|
||||||
|
"cached": name in self._cache})
|
||||||
return out
|
return out
|
||||||
|
|
||||||
def reload(self, name: "str | None" = None) -> list:
|
def reload(self, name: "str | None" = None) -> list:
|
||||||
@@ -151,8 +174,10 @@ class ConfigStore:
|
|||||||
受影响(重载/移出缓存)的配置名列表。
|
受影响(重载/移出缓存)的配置名列表。
|
||||||
"""
|
"""
|
||||||
if name is not None:
|
if name is not None:
|
||||||
self._resolve(name) # 校验,文件不存在则抛 ConfigNotFound
|
self._validate_name(name)
|
||||||
with self._lock:
|
with self._lock:
|
||||||
|
# 在锁内重新解析路径,防止 TOCTOU
|
||||||
|
self._resolve_locked(name)
|
||||||
self._cache.pop(name, None)
|
self._cache.pop(name, None)
|
||||||
self.get(name)
|
self.get(name)
|
||||||
return [name]
|
return [name]
|
||||||
|
|||||||
Reference in new issue
Block a user