SilverElixir commited on
Commit
b33ab22
·
1 Parent(s): 2dcccd0

Validate Telegram path form and require long proxy secret

Browse files
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 and must be printable ASCII without spaces")
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 (!proxySecret || !/^[\x21-\x7e]+$/.test(proxySecret)) {
 
 
 
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