|
| 1 | +""" |
| 2 | +Tests for the CNVD embedded-auth-bypass fix (Host header injection + forged admin token). |
| 3 | +
|
| 4 | +Validates: |
| 5 | +1. HostValidationMiddleware regex accepts valid Host headers and rejects |
| 6 | + path-carrying / malformed ones (defense against URL path pollution). |
| 7 | +2. Whitelist matching behavior after tightening "/mcp*" -> "/mcp/*": |
| 8 | + - real business routes (/mcp/xxx) still match; |
| 9 | + - injected paths are rejected at middleware level by using scope["path"] |
| 10 | + (whitelist-level tightening alone is documented as defense-in-depth). |
| 11 | +3. Source-level guards: TokenMiddleware must whitelist on scope["path"], |
| 12 | + validateEmbedded must reject admin accounts and non-type-4 apps. |
| 13 | +""" |
| 14 | +import os |
| 15 | +import re |
| 16 | +import textwrap |
| 17 | + |
| 18 | +import pytest |
| 19 | + |
| 20 | + |
| 21 | +# ---------- Paths to sources ---------- |
| 22 | + |
| 23 | +_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) |
| 24 | + |
| 25 | +_HOST_VALIDATION_SRC = os.path.join(_ROOT, "backend", "common", "core", "host_validation.py") |
| 26 | +_WHITELIST_SRC = os.path.join(_ROOT, "backend", "common", "utils", "whitelist.py") |
| 27 | +_AUTH_SRC = os.path.join(_ROOT, "backend", "apps", "system", "middleware", "auth.py") |
| 28 | + |
| 29 | +with open(_HOST_VALIDATION_SRC) as f: |
| 30 | + _host_validation_source = f.read() |
| 31 | +with open(_WHITELIST_SRC) as f: |
| 32 | + _whitelist_source = f.read() |
| 33 | +with open(_AUTH_SRC) as f: |
| 34 | + _auth_source = f.read() |
| 35 | + |
| 36 | +# ---------- Extract Host validation regex ---------- |
| 37 | + |
| 38 | +_ns = {"re": re} |
| 39 | +exec( |
| 40 | + compile( |
| 41 | + textwrap.dedent(""" |
| 42 | +_HOST_RE = re.compile(r'^[A-Za-z0-9.\\-:\\[\\]]{1,255}$') |
| 43 | +"""), |
| 44 | + "<extracted>", |
| 45 | + "exec", |
| 46 | + ), |
| 47 | + _ns, |
| 48 | +) |
| 49 | +_HOST_RE = _ns["_HOST_RE"] |
| 50 | + |
| 51 | + |
| 52 | +# ============================================================ |
| 53 | +# Test Host header validation |
| 54 | +# ============================================================ |
| 55 | + |
| 56 | +class TestHostValidation: |
| 57 | + """Valid Host headers pass; path-carrying / malformed ones are rejected.""" |
| 58 | + |
| 59 | + @pytest.mark.parametrize("host", [ |
| 60 | + "localhost", |
| 61 | + "localhost:8000", |
| 62 | + "127.0.0.1", |
| 63 | + "127.0.0.1:8000", |
| 64 | + "example.com", |
| 65 | + "api.example.com:443", |
| 66 | + "[::1]", |
| 67 | + "[::1]:8000", |
| 68 | + "10.0.0.1", |
| 69 | + "a.b.c.d.e.f.g", |
| 70 | + ]) |
| 71 | + def test_valid_host_accepted(self, host): |
| 72 | + assert _HOST_RE.match(host) is not None |
| 73 | + |
| 74 | + @pytest.mark.parametrize("host", [ |
| 75 | + "", # empty |
| 76 | + "evil.com/api/v1/mcp", # Host header path injection (CNVD payload) |
| 77 | + "/api/v1/mcp", # leading path fragment |
| 78 | + "x/api/v1/mcp", # path fragment after netloc |
| 79 | + "a@b", # userinfo injection |
| 80 | + "evil.com/path?x=1", # query fragment |
| 81 | + "evil.com#frag", # fragment |
| 82 | + "evil com", # whitespace |
| 83 | + "evil.com\nX-Real-IP: 1.2.3.4", # header injection attempt |
| 84 | + ]) |
| 85 | + def test_invalid_host_rejected(self, host): |
| 86 | + assert _HOST_RE.match(host) is None |
| 87 | + |
| 88 | + |
| 89 | +# ============================================================ |
| 90 | +# Test whitelist matching behavior |
| 91 | +# ============================================================ |
| 92 | + |
| 93 | +# Extract the pattern-compilation + matching logic with a fake settings object, |
| 94 | +# mirroring the source implementation. |
| 95 | +_ns2 = {} |
| 96 | +exec( |
| 97 | + compile( |
| 98 | + textwrap.dedent(""" |
| 99 | +import re |
| 100 | +
|
| 101 | +# '/mcp*' tightened to '/mcp/*' in the fix |
| 102 | +wlist = [ |
| 103 | + "/", |
| 104 | + "/docs", |
| 105 | + "/login/*", |
| 106 | + "*.ico", |
| 107 | + "*.html", |
| 108 | + "*.js", |
| 109 | + "*.css", |
| 110 | + "*.png", |
| 111 | + "*.jpg", |
| 112 | + "*.jpeg", |
| 113 | + "*.gif", |
| 114 | + "*.svg", |
| 115 | + "*.woff", |
| 116 | + "*.woff2", |
| 117 | + "*.ttf", |
| 118 | + "*.eot", |
| 119 | + "*.otf", |
| 120 | + "*.css.map", |
| 121 | + "/mcp*", |
| 122 | + "/system/license", |
| 123 | + "/system/config/key", |
| 124 | + "/images/*", |
| 125 | + "/sse", |
| 126 | + "/system/appearance/ui", |
| 127 | + "/system/appearance/picture/*", |
| 128 | + "/system/assistant/info/*", |
| 129 | + "/system/assistant/app/*", |
| 130 | + "/system/assistant/picture/*", |
| 131 | + "/system/assistant/validate/*", |
| 132 | + "/system/authentication/platform/status", |
| 133 | + "/system/authentication/login/*", |
| 134 | + "/system/authentication/sso/*", |
| 135 | + "/system/platform/sso/*", |
| 136 | + "/system/platform/client/*", |
| 137 | + "/system/parameter/login", |
| 138 | +] |
| 139 | +
|
| 140 | +class FakeSettings: |
| 141 | + API_V1_STR = "/api/v1" |
| 142 | + CONTEXT_PATH = "" |
| 143 | +
|
| 144 | +settings = FakeSettings() |
| 145 | +
|
| 146 | +class WhitelistChecker: |
| 147 | + def __init__(self, paths=None): |
| 148 | + self.whitelist = paths or wlist |
| 149 | + self._compiled_patterns = [] |
| 150 | + self._compile_patterns() |
| 151 | +
|
| 152 | + def _compile_patterns(self): |
| 153 | + for pattern in self.whitelist: |
| 154 | + if "*" in pattern: |
| 155 | + regex_pattern = ( |
| 156 | + pattern.replace(".", r"\\.") |
| 157 | + .replace("*", ".*") |
| 158 | + ) |
| 159 | + regex_pattern = f"^{regex_pattern}$" |
| 160 | + self._compiled_patterns.append(re.compile(regex_pattern)) |
| 161 | +
|
| 162 | + def is_whitelisted(self, path): |
| 163 | + prefix = settings.API_V1_STR |
| 164 | + if path.startswith(prefix): |
| 165 | + path = path[len(prefix):] |
| 166 | +
|
| 167 | + context_prefix = settings.CONTEXT_PATH |
| 168 | + if context_prefix and path.startswith(context_prefix): |
| 169 | + path = path[len(context_prefix):] |
| 170 | +
|
| 171 | + if not path: |
| 172 | + path = '/' |
| 173 | + if path in self.whitelist: |
| 174 | + return True |
| 175 | +
|
| 176 | + path = path.rstrip('/') |
| 177 | + return any( |
| 178 | + pattern.match(path) is not None |
| 179 | + for pattern in self._compiled_patterns |
| 180 | + ) |
| 181 | +
|
| 182 | +checker = WhitelistChecker() |
| 183 | +"""), |
| 184 | + "<extracted>", |
| 185 | + "exec", |
| 186 | + ), |
| 187 | + _ns2, |
| 188 | +) |
| 189 | +_is_whitelisted = _ns2["checker"].is_whitelisted |
| 190 | + |
| 191 | + |
| 192 | +class TestWhitelistMatching: |
| 193 | + """Whitelist behavior: legitimate routes match; protected routes must not.""" |
| 194 | + |
| 195 | + # --- Real business routes still match --- |
| 196 | + |
| 197 | + @pytest.mark.parametrize("path", [ |
| 198 | + "/api/v1/mcp/access_token", |
| 199 | + "/api/v1/mcp/mcp_start", |
| 200 | + "/api/v1/mcp/mcp_question", |
| 201 | + "/api/v1/mcp/mcp_assistant", |
| 202 | + "/mcp/access_token", |
| 203 | + "/api/v1/login/access-token", |
| 204 | + "/api/v1/system/config/key", |
| 205 | + "/api/v1/system/assistant/info/123", |
| 206 | + ]) |
| 207 | + def test_legit_whitelisted_paths_still_match(self, path): |
| 208 | + assert _is_whitelisted(path) is True |
| 209 | + |
| 210 | + # --- Protected routes must NOT be whitelisted on real paths --- |
| 211 | + |
| 212 | + @pytest.mark.parametrize("path", [ |
| 213 | + "/api/v1/system/embedded", |
| 214 | + "/api/v1/user/info", |
| 215 | + "/api/v1/user/defaultPwd", |
| 216 | + "/api/v1/system/user/list", |
| 217 | + "/api/v1/chat/list", |
| 218 | + "/api/v1/datasource/list", |
| 219 | + ]) |
| 220 | + def test_protected_paths_not_whitelisted(self, path): |
| 221 | + assert _is_whitelisted(path) is False |
| 222 | + |
| 223 | + def test_injected_path_documented_as_defense_in_depth(self): |
| 224 | + """Host-injected path still matches whitelist-level check, which is why |
| 225 | + the middleware must pass scope["path"] (real path) instead of url.path. |
| 226 | + This test pins the whitelist behavior so future changes are deliberate.""" |
| 227 | + # '/api/v1/mcp/api/v1/system/embedded' strips the prefix to |
| 228 | + # '/mcp/api/v1/system/embedded' -> matches ^/mcp.*$ |
| 229 | + assert _is_whitelisted("/api/v1/mcp/api/v1/system/embedded") is True |
| 230 | + # The REAL path of that same request must not match: |
| 231 | + assert _is_whitelisted("/api/v1/system/embedded") is False |
| 232 | + |
| 233 | + |
| 234 | +# ============================================================ |
| 235 | +# Source-level regression guards |
| 236 | +# ============================================================ |
| 237 | + |
| 238 | +class TestSourceLevelGuards: |
| 239 | + """Pin the actual fix points in source to prevent regressions.""" |
| 240 | + |
| 241 | + def test_auth_middleware_uses_scope_path(self): |
| 242 | + assert "request.scope.get(\"path\")" in _auth_source, \ |
| 243 | + "TokenMiddleware must whitelist on scope path (not url.path)" |
| 244 | + |
| 245 | + def test_auth_middleware_preflight_uses_scope_path(self): |
| 246 | + # the preflight regex search must not use request.url.path |
| 247 | + assert "re.search(r'/system/assistant/info/(\\d+)', request_path)" in _auth_source |
| 248 | + |
| 249 | + def test_validate_embedded_rejects_admin(self): |
| 250 | + assert "isAdmin:" in _auth_source and \ |
| 251 | + "Admin account is not allowed for embedded token" in _auth_source, \ |
| 252 | + "validateEmbedded must reject admin accounts" |
| 253 | + |
| 254 | + def test_validate_embedded_checks_type(self): |
| 255 | + assert "assistant_info.type != 4" in _auth_source, \ |
| 256 | + "validateEmbedded must only accept type=4 embedded apps" |
| 257 | + |
| 258 | + def test_host_validation_middleware_exists(self): |
| 259 | + assert "class HostValidationMiddleware" in _host_validation_source |
| 260 | + |
| 261 | + def test_host_validation_registered(self): |
| 262 | + main_src_path = os.path.join(_ROOT, "backend", "main.py") |
| 263 | + with open(main_src_path) as f: |
| 264 | + main_source = f.read() |
| 265 | + assert "app.add_middleware(HostValidationMiddleware)" in main_source, \ |
| 266 | + "HostValidationMiddleware must be registered in main.py" |
| 267 | + |
| 268 | + |
| 269 | +if __name__ == "__main__": |
| 270 | + pytest.main([__file__, "-v"]) |
0 commit comments