Spaces:
Running
Running
SilverElixir commited on
Commit ·
b33ab22
1
Parent(s): 2dcccd0
Validate Telegram path form and require long proxy secret
Browse files- lumen_telegram_transport.py +2 -2
- proxy/proxy.ts +17 -1
- proxy/proxy_test.ts +17 -5
- tests/bot_test_helpers.py +1 -1
- tests/test_bot_transport.py +7 -2
lumen_telegram_transport.py
CHANGED
|
@@ -151,8 +151,8 @@ def proxy_auth_middlewares(
|
|
| 151 |
if parsed.hostname in {"api.telegram.org", "www.tikwm.com", "tikwm.com"}:
|
| 152 |
continue
|
| 153 |
scopes.append((parsed.hostname, port, parsed.path.rstrip("/")))
|
| 154 |
-
if scopes and (not proxy_secret or any(not 33 <= ord(c) <= 126 for c in proxy_secret)):
|
| 155 |
-
raise ValueError("LUMEN_PROXY_SECRET is required for configured proxies
|
| 156 |
|
| 157 |
async def authenticate(request: aiohttp.ClientRequest, handler):
|
| 158 |
request.headers.popall(PROXY_AUTH_HEADER, None)
|
|
|
|
| 151 |
if parsed.hostname in {"api.telegram.org", "www.tikwm.com", "tikwm.com"}:
|
| 152 |
continue
|
| 153 |
scopes.append((parsed.hostname, port, parsed.path.rstrip("/")))
|
| 154 |
+
if scopes and (not proxy_secret or len(proxy_secret) < 32 or any(not 33 <= ord(c) <= 126 for c in proxy_secret)):
|
| 155 |
+
raise ValueError("LUMEN_PROXY_SECRET is required for configured proxies (min 32 printable ASCII chars without spaces)")
|
| 156 |
|
| 157 |
async def authenticate(request: aiohttp.ClientRequest, handler):
|
| 158 |
request.headers.popall(PROXY_AUTH_HEADER, None)
|
proxy/proxy.ts
CHANGED
|
@@ -54,6 +54,10 @@ export const ALLOWED_HOSTS = new Set([
|
|
| 54 |
// под честно большие файлы, но не «без предела».
|
| 55 |
export const MAX_REQUEST_BODY_BYTES = 100 * 1024 * 1024;
|
| 56 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 57 |
export class BodyTooLargeError extends Error {}
|
| 58 |
|
| 59 |
export function limitStreamBytes(
|
|
@@ -133,9 +137,18 @@ export function resolveTarget(pathname: string, search: string): TargetResolutio
|
|
| 133 |
return { ok: false, status: 403, message: "Host not allowed" };
|
| 134 |
}
|
| 135 |
const path = "/" + parts.slice(3).join("/");
|
|
|
|
|
|
|
|
|
|
| 136 |
return { ok: true, url: `https://${host}${path}${search}` };
|
| 137 |
}
|
| 138 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 139 |
export function buildForwardHeaders(reqHeaders: Headers): Headers {
|
| 140 |
const headers = new Headers(reqHeaders);
|
| 141 |
for (const name of HOP_BY_HOP_REQUEST_HEADERS) headers.delete(name);
|
|
@@ -158,7 +171,10 @@ export async function handleRequest(
|
|
| 158 |
proxySecret: string | undefined = undefined,
|
| 159 |
maxBodyBytes: number = MAX_REQUEST_BODY_BYTES,
|
| 160 |
): Promise<Response> {
|
| 161 |
-
if (
|
|
|
|
|
|
|
|
|
|
| 162 |
return new Response("Proxy authentication unavailable", { status: 503 });
|
| 163 |
}
|
| 164 |
const suppliedSecret = req.headers.get(PROXY_AUTH_HEADER) ?? "";
|
|
|
|
| 54 |
// под честно большие файлы, но не «без предела».
|
| 55 |
export const MAX_REQUEST_BODY_BYTES = 100 * 1024 * 1024;
|
| 56 |
|
| 57 |
+
// Короткий секрет перебирается быстро: нижняя граница длины с обеих сторон
|
| 58 |
+
// (здесь и в lumen_telegram_transport.py).
|
| 59 |
+
export const MIN_PROXY_SECRET_LENGTH = 32;
|
| 60 |
+
|
| 61 |
export class BodyTooLargeError extends Error {}
|
| 62 |
|
| 63 |
export function limitStreamBytes(
|
|
|
|
| 137 |
return { ok: false, status: 403, message: "Host not allowed" };
|
| 138 |
}
|
| 139 |
const path = "/" + parts.slice(3).join("/");
|
| 140 |
+
if (host === "api.telegram.org" && !isTelegramBotPath(path)) {
|
| 141 |
+
return { ok: false, status: 404, message: "Not found — путь Telegram обязан иметь форму /bot<token>/<метод> или /file/bot<token>/<путь>" };
|
| 142 |
+
}
|
| 143 |
return { ok: true, url: `https://${host}${path}${search}` };
|
| 144 |
}
|
| 145 |
|
| 146 |
+
// Telegram Bot API без токена в пути не вызывается: без формы исчезает целый
|
| 147 |
+
// класс чужеродного использования (релей произвольных путей при наличии секрета).
|
| 148 |
+
function isTelegramBotPath(path: string): boolean {
|
| 149 |
+
return /^\/bot[^/]+\/.+/.test(path) || /^\/file\/bot[^/]+\/.+/.test(path);
|
| 150 |
+
}
|
| 151 |
+
|
| 152 |
export function buildForwardHeaders(reqHeaders: Headers): Headers {
|
| 153 |
const headers = new Headers(reqHeaders);
|
| 154 |
for (const name of HOP_BY_HOP_REQUEST_HEADERS) headers.delete(name);
|
|
|
|
| 171 |
proxySecret: string | undefined = undefined,
|
| 172 |
maxBodyBytes: number = MAX_REQUEST_BODY_BYTES,
|
| 173 |
): Promise<Response> {
|
| 174 |
+
if (
|
| 175 |
+
!proxySecret || proxySecret.length < MIN_PROXY_SECRET_LENGTH ||
|
| 176 |
+
!/^[\x21-\x7e]+$/.test(proxySecret)
|
| 177 |
+
) {
|
| 178 |
return new Response("Proxy authentication unavailable", { status: 503 });
|
| 179 |
}
|
| 180 |
const suppliedSecret = req.headers.get(PROXY_AUTH_HEADER) ?? "";
|
proxy/proxy_test.ts
CHANGED
|
@@ -16,7 +16,7 @@ import {
|
|
| 16 |
resolveTarget,
|
| 17 |
} from "./proxy.ts";
|
| 18 |
|
| 19 |
-
const TEST_SECRET = "isolated-test-secret";
|
| 20 |
const AUTH_HEADERS = { [PROXY_AUTH_HEADER]: TEST_SECRET };
|
| 21 |
|
| 22 |
function assert(condition: boolean, message: string): void {
|
|
@@ -89,6 +89,18 @@ Deno.test("resolveTarget отклоняет некорректный форма
|
|
| 89 |
assertEquals(resolveTarget("/wrong-prefix/api.telegram.org/getMe", "").ok, false);
|
| 90 |
});
|
| 91 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 92 |
Deno.test("ALLOWED_HOSTS содержит ровно те хосты, что реально нужны боту", () => {
|
| 93 |
assertEquals(ALLOWED_HOSTS.has("api.telegram.org"), true);
|
| 94 |
assertEquals(ALLOWED_HOSTS.has("www.tikwm.com"), true);
|
|
@@ -186,7 +198,7 @@ Deno.test("handleRequest возвращает 502, если апстрим-fetch
|
|
| 186 |
const fakeFetch: typeof fetch = () => {
|
| 187 |
throw new Error(`network unreachable ${TEST_SECRET}`);
|
| 188 |
};
|
| 189 |
-
const req = new Request("https://proxy.example/fetch/api.telegram.org/getMe", { method: "GET", headers: AUTH_HEADERS });
|
| 190 |
const resp = await handleRequest(req, fakeFetch, TEST_SECRET);
|
| 191 |
assertEquals(resp.status, 502);
|
| 192 |
assertEquals(await resp.text(), "Upstream fetch failed");
|
|
@@ -209,7 +221,7 @@ for (const suppliedSecret of [undefined, "", "incorrect-test-secret"]) {
|
|
| 209 |
});
|
| 210 |
}
|
| 211 |
|
| 212 |
-
for (const configuredSecret of [undefined, "", " ", "invalid secret"]) {
|
| 213 |
Deno.test(`handleRequest fails closed for invalid configuration: ${String(configuredSecret)}`, async () => {
|
| 214 |
let fetchCalled = false;
|
| 215 |
const fakeFetch: typeof fetch = () => {
|
|
@@ -376,7 +388,7 @@ Deno.test("handleRequest требует от апстрима не следов
|
|
| 376 |
capturedRedirect = init?.redirect;
|
| 377 |
return Promise.resolve(new Response('{"ok":true}', { status: 200 }));
|
| 378 |
};
|
| 379 |
-
const req = new Request("https://proxy.example/fetch/api.telegram.org/getMe", { headers: AUTH_HEADERS });
|
| 380 |
await handleRequest(req, fakeFetch, TEST_SECRET);
|
| 381 |
assertEquals(capturedRedirect, "error");
|
| 382 |
});
|
|
@@ -388,7 +400,7 @@ Deno.test("handleRequest терпит кривой content-length (NaN/отри
|
|
| 388 |
return Promise.resolve(new Response('{"ok":true}', { status: 200 }));
|
| 389 |
};
|
| 390 |
for (const declared of ["abc", "-5"]) {
|
| 391 |
-
const req = new Request("https://proxy.example/fetch/api.telegram.org/getMe", {
|
| 392 |
headers: { ...AUTH_HEADERS, "content-length": declared },
|
| 393 |
});
|
| 394 |
assertEquals((await handleRequest(req, fakeFetch, TEST_SECRET)).status, 200);
|
|
|
|
| 16 |
resolveTarget,
|
| 17 |
} from "./proxy.ts";
|
| 18 |
|
| 19 |
+
const TEST_SECRET = "isolated-test-secret-0123456789abcdef";
|
| 20 |
const AUTH_HEADERS = { [PROXY_AUTH_HEADER]: TEST_SECRET };
|
| 21 |
|
| 22 |
function assert(condition: boolean, message: string): void {
|
|
|
|
| 89 |
assertEquals(resolveTarget("/wrong-prefix/api.telegram.org/getMe", "").ok, false);
|
| 90 |
});
|
| 91 |
|
| 92 |
+
Deno.test("resolveTarget требует форму bot-пути для api.telegram.org", () => {
|
| 93 |
+
// Без токена в пути — не релей: произвольные пути при наличии секрета закрыты.
|
| 94 |
+
assertEquals(resolveTarget("/fetch/api.telegram.org/getMe", "").ok, false);
|
| 95 |
+
assertEquals(resolveTarget("/fetch/api.telegram.org/evil", "").ok, false);
|
| 96 |
+
assertEquals(resolveTarget("/fetch/api.telegram.org/bot", "").ok, false);
|
| 97 |
+
// Легитимные формы бота проходят.
|
| 98 |
+
assertEquals(resolveTarget("/fetch/api.telegram.org/bot123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11/getMe", "").ok, true);
|
| 99 |
+
assertEquals(resolveTarget("/fetch/api.telegram.org/file/bot123/photos/f.jpg", "").ok, true);
|
| 100 |
+
// Чужим хостам форма не нужна.
|
| 101 |
+
assertEquals(resolveTarget("/fetch/www.tikwm.com/api/", "").ok, true);
|
| 102 |
+
});
|
| 103 |
+
|
| 104 |
Deno.test("ALLOWED_HOSTS содержит ровно те хосты, что реально нужны боту", () => {
|
| 105 |
assertEquals(ALLOWED_HOSTS.has("api.telegram.org"), true);
|
| 106 |
assertEquals(ALLOWED_HOSTS.has("www.tikwm.com"), true);
|
|
|
|
| 198 |
const fakeFetch: typeof fetch = () => {
|
| 199 |
throw new Error(`network unreachable ${TEST_SECRET}`);
|
| 200 |
};
|
| 201 |
+
const req = new Request("https://proxy.example/fetch/api.telegram.org/bot123/getMe", { method: "GET", headers: AUTH_HEADERS });
|
| 202 |
const resp = await handleRequest(req, fakeFetch, TEST_SECRET);
|
| 203 |
assertEquals(resp.status, 502);
|
| 204 |
assertEquals(await resp.text(), "Upstream fetch failed");
|
|
|
|
| 221 |
});
|
| 222 |
}
|
| 223 |
|
| 224 |
+
for (const configuredSecret of [undefined, "", " ", "invalid secret", "short"]) {
|
| 225 |
Deno.test(`handleRequest fails closed for invalid configuration: ${String(configuredSecret)}`, async () => {
|
| 226 |
let fetchCalled = false;
|
| 227 |
const fakeFetch: typeof fetch = () => {
|
|
|
|
| 388 |
capturedRedirect = init?.redirect;
|
| 389 |
return Promise.resolve(new Response('{"ok":true}', { status: 200 }));
|
| 390 |
};
|
| 391 |
+
const req = new Request("https://proxy.example/fetch/api.telegram.org/bot123/getMe", { headers: AUTH_HEADERS });
|
| 392 |
await handleRequest(req, fakeFetch, TEST_SECRET);
|
| 393 |
assertEquals(capturedRedirect, "error");
|
| 394 |
});
|
|
|
|
| 400 |
return Promise.resolve(new Response('{"ok":true}', { status: 200 }));
|
| 401 |
};
|
| 402 |
for (const declared of ["abc", "-5"]) {
|
| 403 |
+
const req = new Request("https://proxy.example/fetch/api.telegram.org/bot123/getMe", {
|
| 404 |
headers: { ...AUTH_HEADERS, "content-length": declared },
|
| 405 |
});
|
| 406 |
assertEquals((await handleRequest(req, fakeFetch, TEST_SECRET)).status, 200);
|
tests/bot_test_helpers.py
CHANGED
|
@@ -447,7 +447,7 @@ class _FakeProc:
|
|
| 447 |
return self.returncode
|
| 448 |
|
| 449 |
|
| 450 |
-
def _run_proxy_middleware(url, *, secret="proxy-secret-abc", bases=("https://proxy.example/fetch/api.telegram.org",), headers=None):
|
| 451 |
from multidict import CIMultiDict
|
| 452 |
from yarl import URL
|
| 453 |
|
|
|
|
| 447 |
return self.returncode
|
| 448 |
|
| 449 |
|
| 450 |
+
def _run_proxy_middleware(url, *, secret="proxy-secret-abc-0123456789abcdef", bases=("https://proxy.example/fetch/api.telegram.org",), headers=None):
|
| 451 |
from multidict import CIMultiDict
|
| 452 |
from yarl import URL
|
| 453 |
|
tests/test_bot_transport.py
CHANGED
|
@@ -229,7 +229,7 @@ def test_tikwm_proxy_candidates_primary_plus_fallbacks_deduped():
|
|
| 229 |
|
| 230 |
def test_proxy_middleware_sends_secret_only_to_configured_proxy():
|
| 231 |
_, _, seen = _run_proxy_middleware("https://proxy.example/fetch/api.telegram.org/bot123/sendMessage")
|
| 232 |
-
assert seen["sent"].get("X-Lumen-Proxy-Secret") == "proxy-secret-abc"
|
| 233 |
|
| 234 |
|
| 235 |
def test_proxy_middleware_never_sends_secret_to_direct_or_unrelated_hosts():
|
|
@@ -254,6 +254,11 @@ def test_proxy_middleware_strips_stale_secret_and_requires_secret():
|
|
| 254 |
lumen_telegram_transport.proxy_auth_middlewares(
|
| 255 |
proxy_secret="", proxy_base_urls=("https://proxy.example/fetch/api.telegram.org",),
|
| 256 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 257 |
|
| 258 |
|
| 259 |
def test_proxy_middleware_rejects_authenticated_redirects():
|
|
@@ -264,7 +269,7 @@ def test_proxy_middleware_rejects_authenticated_redirects():
|
|
| 264 |
from yarl import URL
|
| 265 |
|
| 266 |
(authenticate,) = lumen_telegram_transport.proxy_auth_middlewares(
|
| 267 |
-
proxy_secret="proxy-secret-abc",
|
| 268 |
proxy_base_urls=("https://proxy.example/fetch/api.telegram.org",),
|
| 269 |
)
|
| 270 |
|
|
|
|
| 229 |
|
| 230 |
def test_proxy_middleware_sends_secret_only_to_configured_proxy():
|
| 231 |
_, _, seen = _run_proxy_middleware("https://proxy.example/fetch/api.telegram.org/bot123/sendMessage")
|
| 232 |
+
assert seen["sent"].get("X-Lumen-Proxy-Secret") == "proxy-secret-abc-0123456789abcdef"
|
| 233 |
|
| 234 |
|
| 235 |
def test_proxy_middleware_never_sends_secret_to_direct_or_unrelated_hosts():
|
|
|
|
| 254 |
lumen_telegram_transport.proxy_auth_middlewares(
|
| 255 |
proxy_secret="", proxy_base_urls=("https://proxy.example/fetch/api.telegram.org",),
|
| 256 |
)
|
| 257 |
+
with pytest.raises(ValueError):
|
| 258 |
+
lumen_telegram_transport.proxy_auth_middlewares(
|
| 259 |
+
proxy_secret="short",
|
| 260 |
+
proxy_base_urls=("https://proxy.example/fetch/api.telegram.org",),
|
| 261 |
+
)
|
| 262 |
|
| 263 |
|
| 264 |
def test_proxy_middleware_rejects_authenticated_redirects():
|
|
|
|
| 269 |
from yarl import URL
|
| 270 |
|
| 271 |
(authenticate,) = lumen_telegram_transport.proxy_auth_middlewares(
|
| 272 |
+
proxy_secret="proxy-secret-abc-0123456789abcdef",
|
| 273 |
proxy_base_urls=("https://proxy.example/fetch/api.telegram.org",),
|
| 274 |
)
|
| 275 |
|