chore: harden webapp auth and update deps

This commit is contained in:
3252a8
2026-05-13 15:05:30 +03:00
parent 2b7d1f0be8
commit 2169fd8b50
24 changed files with 576 additions and 754 deletions
+5
View File
@@ -8,8 +8,13 @@ README.md
.env.* .env.*
!.env.example !.env.example
scratch_*.py scratch_*.py
scratch/
.claude/
*.local.* *.local.*
node_modules/ node_modules/
docker-compose-dev.yml
data/*
!data/tariffs.example.json
# CI # CI
+2
View File
@@ -147,6 +147,8 @@ STARS_PRICE_12_MONTHS=0
TRAFFIC_PACKAGES=10:199,50:799 # Format: "<GB>:<price>", comma-separated TRAFFIC_PACKAGES=10:199,50:799 # Format: "<GB>:<price>", comma-separated
STARS_TRAFFIC_PACKAGES=10:2500 # Optional: traffic packages priced in Stars STARS_TRAFFIC_PACKAGES=10:2500 # Optional: traffic packages priced in Stars
TARIFFS_CONFIG_PATH=data/tariffs.json # Optional Tariffs 2.0 JSON config. If missing, legacy .env pricing is used. TARIFFS_CONFIG_PATH=data/tariffs.json # Optional Tariffs 2.0 JSON config. If missing, legacy .env pricing is used.
NEWT_ID= # Optional: local docker-compose-dev.yml Newt tunnel id
NEWT_SECRET= # Optional: local docker-compose-dev.yml Newt tunnel secret
TARIFF_TRAFFIC_WARNING_LEVELS=85,90,95 # Tariffs 2.0 traffic warning levels, percent used TARIFF_TRAFFIC_WARNING_LEVELS=85,90,95 # Tariffs 2.0 traffic warning levels, percent used
# Subscription Notifications # Subscription Notifications
+8
View File
@@ -80,6 +80,14 @@ docker compose logs -f remnawave-minishop
Для каталога тарифов используется `TARIFFS_CONFIG_PATH` со значением по умолчанию `data/tariffs.json`. Пример формата лежит в [data/tariffs.example.json](data/tariffs.example.json), подробности - в [docs/tariffs.md](docs/tariffs.md). Для каталога тарифов используется `TARIFFS_CONFIG_PATH` со значением по умолчанию `data/tariffs.json`. Пример формата лежит в [data/tariffs.example.json](data/tariffs.example.json), подробности - в [docs/tariffs.md](docs/tariffs.md).
Если в Docker Compose включаете bind mount `./data:/app/data`, заранее создайте каталог и отдайте его пользователю контейнера. Это нужно для сохранения `data/tariffs.json`, кеша логотипа Web App и animated emoji:
```bash
mkdir -p data/webapp-logo data/webapp-emoji
chown -R 10001:10001 data
chmod -R u+rwX data
```
## Полезные команды ## Полезные команды
```bash ```bash
+7 -4
View File
@@ -1906,14 +1906,17 @@ async def admin_settings_get_route(request: web.Request) -> web.Response:
"fields": [], "fields": [],
} }
override = overrides_by_key.get(key) override = overrides_by_key.get(key)
sections[section_id]["fields"].append( value = current_value(settings, key)
{ is_secret = bool(field.get("secret"))
response_field = {
**field, **field,
"value": current_value(settings, key), "value": "" if is_secret else value,
"overridden": bool(override), "overridden": bool(override),
"updated_at": override.get("updated_at") if override else None, "updated_at": override.get("updated_at") if override else None,
} }
) if is_secret:
response_field["has_value"] = bool(value)
sections[section_id]["fields"].append(response_field)
ordered_sections = sorted(sections.values(), key=lambda s: s["order"]) ordered_sections = sorted(sections.values(), key=lambda s: s["order"])
return _ok({"sections": ordered_sections}) return _ok({"sections": ordered_sections})
+2 -5
View File
@@ -53,9 +53,7 @@
CSRF_COOKIE_NAME, CSRF_COOKIE_NAME,
isManuallyLoggedOut as readManualLogoutFlag, isManuallyLoggedOut as readManualLogoutFlag,
markManualLogout as markManualLogoutInStorage, markManualLogout as markManualLogoutInStorage,
persistToken,
readCookie, readCookie,
readStoredToken,
} from "./lib/webapp/session.js"; } from "./lib/webapp/session.js";
import { createTelegramSdk } from "./lib/webapp/telegramSdk.js"; import { createTelegramSdk } from "./lib/webapp/telegramSdk.js";
import { mockApi as runMockApi } from "./lib/webapp/mockApi.js"; import { mockApi as runMockApi } from "./lib/webapp/mockApi.js";
@@ -105,7 +103,7 @@
let languageClickGuardArmTimer = null; let languageClickGuardArmTimer = null;
let emailAvatarUrl = ""; let emailAvatarUrl = "";
let avatarHashToken = ""; let avatarHashToken = "";
let token = MOCK ? "local-preview" : readStoredToken(); let token = MOCK ? "local-preview" : "";
let csrfToken = MOCK ? "" : readCookie(CSRF_COOKIE_NAME) || ""; let csrfToken = MOCK ? "" : readCookie(CSRF_COOKIE_NAME) || "";
let scrollLockApplied = false; let scrollLockApplied = false;
let tg = null; let tg = null;
@@ -132,7 +130,6 @@
const apiClient = createApiClient({ const apiClient = createApiClient({
apiBase: CFG.apiBase, apiBase: CFG.apiBase,
csrfCookieName: CSRF_COOKIE_NAME, csrfCookieName: CSRF_COOKIE_NAME,
getToken: () => token,
getCsrfToken: () => csrfToken, getCsrfToken: () => csrfToken,
onUnauthorized: () => { onUnauthorized: () => {
clearToken(); clearToken();
@@ -621,7 +618,7 @@
clearManualLogoutFlag(); clearManualLogoutFlag();
token = nextToken || ""; token = nextToken || "";
csrfToken = nextCsrf || readCookie(CSRF_COOKIE_NAME) || ""; csrfToken = nextCsrf || readCookie(CSRF_COOKIE_NAME) || "";
if (token && !MOCK) persistToken(token); if (!MOCK) clearStoredToken();
} }
function clearToken() { function clearToken() {
@@ -65,6 +65,12 @@
revealedSecrets = next; revealedSecrets = next;
} }
function secretPlaceholder(field) {
if (settingsDirty[field.key]?.deleted) return field.placeholder || "••••••••";
if (field.has_value) return at("settings_secret_configured", {}, "Secret is set");
return field.placeholder || at("settings_secret_empty", {}, "Not set");
}
function groupSectionFields(section) { function groupSectionFields(section) {
const groups = new Map(); const groups = new Map();
for (const field of section.fields || []) { for (const field of section.fields || []) {
@@ -124,7 +130,9 @@
<div class="admin-setting-meta"> <div class="admin-setting-meta">
<strong> <strong>
{fieldLabelText(field)} {fieldLabelText(field)}
{#if field.secret}
<AdminBadge variant="warning">{at("settings_badge_secret", {}, "Secret")}</AdminBadge> <AdminBadge variant="warning">{at("settings_badge_secret", {}, "Secret")}</AdminBadge>
{/if}
{#if isOverridden(field)} {#if isOverridden(field)}
<AdminBadge variant="success">{at("settings_badge_override", {}, "Override")}</AdminBadge> <AdminBadge variant="success">{at("settings_badge_override", {}, "Override")}</AdminBadge>
{/if} {/if}
@@ -189,7 +197,7 @@
<input <input
class="input" class="input"
type={revealed ? "text" : "password"} type={revealed ? "text" : "password"}
placeholder={field.placeholder || "••••••••"} placeholder={secretPlaceholder(field)}
autocomplete="off" autocomplete="off"
value={valueFor(field) ?? ""} value={valueFor(field) ?? ""}
on:input={(e) => settingsStore.markDirty(field.key, e.currentTarget.value)} on:input={(e) => settingsStore.markDirty(field.key, e.currentTarget.value)}
@@ -201,10 +201,10 @@ export async function mockApi(path, options = {}, context = {}) {
if (path === "/me") return clone(DEV_MOCK.data); if (path === "/me") return clone(DEV_MOCK.data);
if (path === "/auth/email/request") return { ok: true }; if (path === "/auth/email/request") return { ok: true };
if (path === "/auth/email/verify" || path === "/auth/email/magic") { if (path === "/auth/email/verify" || path === "/auth/email/magic") {
return { ok: true, token: "local-preview", csrf_token: "local-preview-csrf" }; return { ok: true, csrf_token: "local-preview-csrf" };
} }
if (path === "/auth/token") { if (path === "/auth/token") {
return { ok: true, token: "local-preview", csrf_token: "local-preview-csrf" }; return { ok: true, csrf_token: "local-preview-csrf" };
} }
if (path === "/promo/apply") return { ok: true, end_date_text: "31.05.2026" }; if (path === "/promo/apply") return { ok: true, end_date_text: "31.05.2026" };
if (path === "/devices") return clone(DEV_MOCK.data.devices); if (path === "/devices") return clone(DEV_MOCK.data.devices);
@@ -267,10 +267,10 @@ export async function mockApi(path, options = {}, context = {}) {
return { ok: true }; return { ok: true };
} }
if (path === "/account/email/verify" && String(options.method || "").toUpperCase() === "POST") { if (path === "/account/email/verify" && String(options.method || "").toUpperCase() === "POST") {
return { ok: true, token: "local-preview", csrf_token: "local-preview-csrf" }; return { ok: true, csrf_token: "local-preview-csrf" };
} }
if (path === "/account/telegram/link" && String(options.method || "").toUpperCase() === "POST") { if (path === "/account/telegram/link" && String(options.method || "").toUpperCase() === "POST") {
return { ok: true, token: "local-preview", csrf_token: "local-preview-csrf" }; return { ok: true, csrf_token: "local-preview-csrf" };
} }
if (path === "/payments" && String(options.method || "").toUpperCase() === "POST") { if (path === "/payments" && String(options.method || "").toUpperCase() === "POST") {
return { return {
@@ -3,7 +3,6 @@ import { readCookie } from "./session.js";
export function createApiClient({ export function createApiClient({
apiBase = "", apiBase = "",
csrfCookieName = "rw_webapp_csrf", csrfCookieName = "rw_webapp_csrf",
getToken = () => "",
getCsrfToken = () => "", getCsrfToken = () => "",
onUnauthorized = () => {}, onUnauthorized = () => {},
mockApi = null, mockApi = null,
@@ -14,8 +13,6 @@ export function createApiClient({
const method = String(options.method || "GET").toUpperCase(); const method = String(options.method || "GET").toUpperCase();
const headers = { ...(options.headers || {}) }; const headers = { ...(options.headers || {}) };
const token = getToken();
if (token) headers.Authorization = `Bearer ${token}`;
const csrf = getCsrfToken() || readCookie(csrfCookieName) || ""; const csrf = getCsrfToken() || readCookie(csrfCookieName) || "";
if (csrf && ["POST", "PUT", "PATCH", "DELETE"].includes(method)) { if (csrf && ["POST", "PUT", "PATCH", "DELETE"].includes(method)) {
@@ -23,7 +20,11 @@ export function createApiClient({
} }
if (options.body && !headers["Content-Type"]) headers["Content-Type"] = "application/json"; if (options.body && !headers["Content-Type"]) headers["Content-Type"] = "application/json";
const response = await fetch(`${apiBase}${path}`, { ...options, headers }); const response = await fetch(`${apiBase}${path}`, {
...options,
headers,
credentials: "same-origin",
});
const payload = await response.json().catch(() => ({})); const payload = await response.json().catch(() => ({}));
if (response.status === 401) onUnauthorized(); if (response.status === 401) onUnauthorized();
return payload; return payload;
@@ -38,6 +39,7 @@ export function createApiClient({
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload), body: JSON.stringify(payload),
signal: options.signal, signal: options.signal,
credentials: "same-origin",
}); });
return response.json(); return response.json();
} }
@@ -13,16 +13,6 @@ export function readCookie(name) {
return cookie ? decodeURIComponent(cookie.slice(prefix.length)) : ""; return cookie ? decodeURIComponent(cookie.slice(prefix.length)) : "";
} }
export function readStoredToken(storageKey = TOKEN_STORAGE_KEY) {
if (typeof localStorage === "undefined") return "";
return localStorage.getItem(storageKey) || "";
}
export function persistToken(token, storageKey = TOKEN_STORAGE_KEY) {
if (!token || typeof localStorage === "undefined") return;
localStorage.setItem(storageKey, token);
}
export function clearStoredToken(storageKey = TOKEN_STORAGE_KEY) { export function clearStoredToken(storageKey = TOKEN_STORAGE_KEY) {
if (typeof localStorage === "undefined") return; if (typeof localStorage === "undefined") return;
localStorage.removeItem(storageKey); localStorage.removeItem(storageKey);
@@ -139,7 +139,7 @@ export function createAccountStore({
body: JSON.stringify({ email: s.linkEmailPending, code }), body: JSON.stringify({ email: s.linkEmailPending, code }),
}); });
if (!response?.ok) throw response; if (!response?.ok) throw response;
if (response?.token) setToken(response.token, response.csrf_token); if (response?.csrf_token) setToken("", response.csrf_token);
await loadData(); await loadData();
closeLinkEmailDialog(); closeLinkEmailDialog();
showToast(t("wa_settings_linked")); showToast(t("wa_settings_linked"));
@@ -158,7 +158,7 @@ export function createAccountStore({
body: JSON.stringify(payload), body: JSON.stringify(payload),
}); });
if (!response?.ok) throw response; if (!response?.ok) throw response;
if (response?.token) setToken(response.token, response.csrf_token); if (response?.csrf_token) setToken("", response.csrf_token);
await loadData(); await loadData();
showToast(t("wa_settings_linked")); showToast(t("wa_settings_linked"));
} catch (error) { } catch (error) {
@@ -96,8 +96,8 @@ export function createAuthStore({
const referralParam = readReferralParam(getTg()); const referralParam = readReferralParam(getTg());
if (referralParam) payload.referral_code = referralParam; if (referralParam) payload.referral_code = referralParam;
const response = await publicApi("/auth/email/magic", payload); const response = await publicApi("/auth/email/magic", payload);
if (response.ok && response.token) { if (response.ok && response.csrf_token) {
setToken(response.token, response.csrf_token); setToken("", response.csrf_token);
clearAuthQuery(); clearAuthQuery();
await loadData(); await loadData();
return true; return true;
@@ -126,8 +126,8 @@ export function createAuthStore({
const referralParam = readReferralParam(getTg()); const referralParam = readReferralParam(getTg());
if (referralParam) payload.referral_code = referralParam; if (referralParam) payload.referral_code = referralParam;
const response = await publicApi("/auth/token", payload, { signal: options.signal }); const response = await publicApi("/auth/token", payload, { signal: options.signal });
if (response.ok && response.token) { if (response.ok && response.csrf_token) {
setToken(response.token, response.csrf_token); setToken("", response.csrf_token);
clearAuthQuery(); clearAuthQuery();
setAuthStatus(""); setAuthStatus("");
await loadData(); await loadData();
@@ -202,8 +202,8 @@ export function createAuthStore({
const referralParam = readReferralParam(getTg()); const referralParam = readReferralParam(getTg());
if (referralParam) payload.referral_code = referralParam; if (referralParam) payload.referral_code = referralParam;
const response = await publicApi("/auth/email/verify", payload); const response = await publicApi("/auth/email/verify", payload);
if (!response.ok || !response.token) throw response; if (!response.ok || !response.csrf_token) throw response;
setToken(response.token, response.csrf_token); setToken("", response.csrf_token);
await loadData(); await loadData();
setAuthStatus(""); setAuthStatus("");
} catch (error) { } catch (error) {
@@ -34,7 +34,7 @@ export function createBillingStore({ billing, loadData, t, showToast, openExtern
defaultMethod = "" defaultMethod = ""
) { ) {
state.update((s) => { state.update((s) => {
let step = s.paymentStep; let step;
let plan = s.selectedPlan; let plan = s.selectedPlan;
let tariffKey = s.selectedTariffKey; let tariffKey = s.selectedTariffKey;
+28 -6
View File
@@ -51,7 +51,7 @@ from bot.services.severpay_service import SeverPayService
from bot.services.subscription_service import SubscriptionService from bot.services.subscription_service import SubscriptionService
from bot.services.yookassa_service import YooKassaService from bot.services.yookassa_service import YooKassaService
from bot.utils.config_link import prepare_config_links from bot.utils.config_link import prepare_config_links
from bot.utils.request_security import request_client_ip from bot.utils.request_security import parse_ip_entries, request_client_ip
from bot.utils.text_sanitizer import sanitize_display_name, sanitize_username from bot.utils.text_sanitizer import sanitize_display_name, sanitize_username
from config.settings import Settings from config.settings import Settings
from db.dal import payment_dal, subscription_dal, user_dal from db.dal import payment_dal, subscription_dal, user_dal
@@ -353,11 +353,34 @@ def _public_webapp_base_url(settings: Settings, request: web.Request) -> str:
if parsed_url.scheme and parsed_url.netloc: if parsed_url.scheme and parsed_url.netloc:
return f"{parsed_url.scheme}://{parsed_url.netloc}" return f"{parsed_url.scheme}://{parsed_url.netloc}"
scheme = request.headers.get("X-Forwarded-Proto") or request.scheme headers = request.headers
host = request.headers.get("X-Forwarded-Host") or request.headers.get("Host") or request.host if _request_remote_is_trusted_proxy(settings, request):
scheme = _first_header_value(headers.get("X-Forwarded-Proto")) or request.scheme
host = (
_first_header_value(headers.get("X-Forwarded-Host"))
or headers.get("Host")
or request.host
)
else:
scheme = request.scheme
host = headers.get("Host") or request.host
return f"{scheme}://{host}".rstrip("/") return f"{scheme}://{host}".rstrip("/")
def _first_header_value(value: Optional[str]) -> str:
if not value:
return ""
return value.split(",", 1)[0].strip()
def _request_remote_is_trusted_proxy(settings: Settings, request: web.Request) -> bool:
try:
remote_ip = ipaddress.ip_address(str(request.remote or "").strip())
except ValueError:
return False
return any(remote_ip in network for network in parse_ip_entries(settings.trusted_proxies))
def _telegram_oauth_callback_url(settings: Settings, request: web.Request) -> str: def _telegram_oauth_callback_url(settings: Settings, request: web.Request) -> str:
return f"{_public_webapp_base_url(settings, request)}/auth/telegram/callback" return f"{_public_webapp_base_url(settings, request)}/auth/telegram/callback"
@@ -838,12 +861,12 @@ async def _security_headers_middleware(request: web.Request, handler):
"Content-Security-Policy", "Content-Security-Policy",
( (
"default-src 'self'; " "default-src 'self'; "
f"script-src 'self' 'nonce-{nonce}' 'unsafe-eval' https://telegram.org; " f"script-src 'self' 'nonce-{nonce}' https://telegram.org; "
"frame-src https://oauth.telegram.org; " "frame-src https://oauth.telegram.org; "
"frame-ancestors https://web.telegram.org https://t.me; " "frame-ancestors https://web.telegram.org https://t.me; "
"style-src 'self' 'unsafe-inline' https://fonts.googleapis.com https://cdn.jsdelivr.net; " "style-src 'self' 'unsafe-inline' https://fonts.googleapis.com https://cdn.jsdelivr.net; "
"font-src 'self' https://fonts.gstatic.com https://cdn.jsdelivr.net data:; " "font-src 'self' https://fonts.gstatic.com https://cdn.jsdelivr.net data:; "
"img-src 'self' data: https: http:; " "img-src 'self' data: https:; "
"connect-src 'self' https://oauth.telegram.org; " "connect-src 'self' https://oauth.telegram.org; "
"object-src 'none'; " "object-src 'none'; "
"base-uri 'self'; " "base-uri 'self'; "
@@ -2860,7 +2883,6 @@ def _build_webapp_auth_response(
) -> web.Response: ) -> web.Response:
response_payload = dict(payload) response_payload = dict(payload)
response_payload["ok"] = True response_payload["ok"] = True
response_payload["token"] = token
csrf_value = csrf_token or secrets.token_hex(32) csrf_value = csrf_token or secrets.token_hex(32)
response_payload["csrf_token"] = csrf_value response_payload["csrf_token"] = csrf_value
response = web.json_response(response_payload) response = web.json_response(response_payload)
+12 -1
View File
@@ -1,5 +1,6 @@
import logging import logging
from sqlalchemy.engine import make_url
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from sqlalchemy.orm import sessionmaker from sqlalchemy.orm import sessionmaker
@@ -11,11 +12,21 @@ from .migrator import run_database_migrations
async_engine = None async_engine = None
def redacted_database_url(database_url: str) -> str:
try:
return make_url(database_url).render_as_string(hide_password=True)
except Exception:
return "<invalid database url>"
def init_db_connection(settings: Settings) -> sessionmaker: def init_db_connection(settings: Settings) -> sessionmaker:
global async_engine global async_engine
if async_engine is None: if async_engine is None:
logging.info(f"Attempting to create SQLAlchemy engine with URL: {settings.DATABASE_URL}") logging.info(
"Attempting to create SQLAlchemy engine with URL: %s",
redacted_database_url(settings.DATABASE_URL),
)
async_engine = create_async_engine( async_engine = create_async_engine(
settings.DATABASE_URL, settings.DATABASE_URL,
echo=False, echo=False,
+1 -1
View File
@@ -19,7 +19,7 @@ services:
# volumes: # volumes:
# - ./data:/app/data # - ./data:/app/data
healthcheck: healthcheck:
test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:8080/healthz >/dev/null || exit 1"] test: ["CMD-SHELL", "python -c \"import urllib.request; urllib.request.urlopen('http://127.0.0.1:8080/healthz', timeout=3).read()\""]
interval: 30s interval: 30s
timeout: 5s timeout: 5s
retries: 5 retries: 5
+9 -1
View File
@@ -79,7 +79,15 @@ nano .env
Если файл из `TARIFFS_CONFIG_PATH` существует, бот использует каталог тарифов. Если файла нет, применяется конфигурация из переменных `.env`. Если файл из `TARIFFS_CONFIG_PATH` существует, бот использует каталог тарифов. Если файла нет, применяется конфигурация из переменных `.env`.
В `docker-compose-dev.yml` каталог `./data` монтируется в контейнер как `/app/data`, чтобы Web App админка могла сохранять `data/tariffs.json`. После изменения compose-файла пересоздайте контейнер: В `docker-compose-dev.yml` каталог `./data` монтируется в контейнер как `/app/data`, чтобы Web App админка могла сохранять `data/tariffs.json`. Тот же каталог используется для кеша логотипа Web App (`data/webapp-logo`) и animated emoji (`data/webapp-emoji`). Если этот bind mount включен на Ubuntu-сервере, создайте подкаталоги и отдайте `data` UID `10001`, под которым работает приложение внутри контейнера:
```bash
mkdir -p data/webapp-logo data/webapp-emoji
chown -R 10001:10001 data
chmod -R u+rwX data
```
После изменения compose-файла или прав пересоздайте контейнер:
```bash ```bash
docker compose -f docker-compose-dev.yml up -d --build --force-recreate docker compose -f docker-compose-dev.yml up -d --build --force-recreate
+21
View File
@@ -19,6 +19,27 @@ IMAGE_TAG=3.1.0 docker compose -f docker-compose-remote-server.yml up -d
`docker-compose-remote-server.yml` можно использовать как шаблон и заменить `image:` на нужный образ. По умолчанию используется `ghcr.io/3252a8/remnawave-minishop:latest`. `docker-compose-remote-server.yml` можно использовать как шаблон и заменить `image:` на нужный образ. По умолчанию используется `ghcr.io/3252a8/remnawave-minishop:latest`.
### Права на `./data`
Если в Compose включен bind mount `./data:/app/data` или `./data:/app/data:rw`, каталог на хосте должен быть доступен на запись пользователю контейнера. Контейнер запускает приложение от `appuser` с UID `10001`; запуск Docker от `root` на Ubuntu не делает этот каталог writable внутри контейнера, если на хосте он принадлежит `root:root` с обычными правами `755`.
Перед запуском или после добавления mount выполните на сервере из каталога проекта:
```bash
mkdir -p data/webapp-logo data/webapp-emoji
chown -R 10001:10001 data
chmod -R u+rwX data
docker compose up -d --force-recreate remnawave-minishop
```
Проверка прав:
```bash
docker compose exec remnawave-minishop sh -lc 'id; ls -ldn /app/data /app/data/webapp-emoji; touch /app/data/webapp-emoji/test && rm /app/data/webapp-emoji/test'
```
Если проверочный `touch` проходит без `Permission denied`, Web App сможет сохранять каталог тарифов, кеш `WEBAPP_LOGO_URL` в `/app/data/webapp-logo` и кеш animated emoji в `/app/data/webapp-emoji`.
## Обновление версии ## Обновление версии
Образ приложения: `ghcr.io/3252a8/remnawave-minishop`. Тег задаётся переменной окружения **`IMAGE_TAG`** (в Compose подставляется как `${IMAGE_TAG:-latest}`). Для продакшена разумно закрепить **конкретный тег релиза** вместо `latest`, чтобы обновляться осознанно и иметь откат. Образ приложения: `ghcr.io/3252a8/remnawave-minishop`. Тег задаётся переменной окружения **`IMAGE_TAG`** (в Compose подставляется как `${IMAGE_TAG:-latest}`). Для продакшена разумно закрепить **конкретный тег релиза** вместо `latest`, чтобы обновляться осознанно и иметь откат.
+6
View File
@@ -24,6 +24,12 @@ export default [
"no-empty": "warn", "no-empty": "warn",
}, },
}, },
{
files: ["bot/app/web/frontend/**/*.svelte"],
rules: {
"no-useless-assignment": "off",
},
},
{ {
files: ["bot/app/web/frontend/**/*.{js,svelte}"], files: ["bot/app/web/frontend/**/*.{js,svelte}"],
languageOptions: { languageOptions: {
+2
View File
@@ -1050,6 +1050,8 @@
"admin_tariff_squads": "Squads", "admin_tariff_squads": "Squads",
"admin_tariff_premium": "Premium", "admin_tariff_premium": "Premium",
"admin_settings_badge_secret": "Secret", "admin_settings_badge_secret": "Secret",
"admin_settings_secret_configured": "Secret is set",
"admin_settings_secret_empty": "Not set",
"admin_settings_badge_override": "Override", "admin_settings_badge_override": "Override",
"admin_status_limited": "Limited", "admin_status_limited": "Limited",
"admin_status_expired": "Expired", "admin_status_expired": "Expired",
+2
View File
@@ -1050,6 +1050,8 @@
"admin_tariff_squads": "Squads", "admin_tariff_squads": "Squads",
"admin_tariff_premium": "Premium", "admin_tariff_premium": "Premium",
"admin_settings_badge_secret": "Secret", "admin_settings_badge_secret": "Secret",
"admin_settings_secret_configured": "Секрет задан",
"admin_settings_secret_empty": "Не задан",
"admin_settings_badge_override": "Override", "admin_settings_badge_override": "Override",
"admin_status_limited": "Ограничен", "admin_status_limited": "Ограничен",
"admin_status_expired": "Истёк", "admin_status_expired": "Истёк",
+280 -684
View File
File diff suppressed because it is too large Load Diff
+9 -9
View File
@@ -21,27 +21,27 @@
"fix": "npm run format && npm run lint:fix" "fix": "npm run format && npm run lint:fix"
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.39.2", "@eslint/js": "^10.0.1",
"@internationalized/date": "^3.12.1", "@internationalized/date": "^3.12.1",
"@sveltejs/vite-plugin-svelte": "^7.0.0", "@sveltejs/vite-plugin-svelte": "^7.1.2",
"@tailwindcss/cli": "4.2.4", "@tailwindcss/cli": "4.3.0",
"@tailwindcss/vite": "^4.3.0", "@tailwindcss/vite": "^4.3.0",
"bits-ui": "^2.18.0", "bits-ui": "^2.18.1",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"esbuild": "^0.28.0", "esbuild": "^0.28.0",
"eslint": "^9.39.2", "eslint": "^10.3.0",
"eslint-config-prettier": "^10.1.8", "eslint-config-prettier": "^10.1.8",
"eslint-plugin-svelte": "^3.14.0", "eslint-plugin-svelte": "^3.14.0",
"globals": "^16.5.0", "globals": "^17.6.0",
"lucide-svelte": "^1.0.1", "lucide-svelte": "^1.0.1",
"prettier": "^3.7.4", "prettier": "^3.7.4",
"prettier-plugin-svelte": "^3.4.0", "prettier-plugin-svelte": "^3.4.0",
"svelte": "^5.55.5", "svelte": "^5.55.5",
"svelte-eslint-parser": "^1.4.1", "svelte-eslint-parser": "^1.4.1",
"tailwind-merge": "^3.5.0", "tailwind-merge": "^3.6.0",
"tailwindcss": "4.2.4", "tailwindcss": "4.3.0",
"uplot": "^1.6.32", "uplot": "^1.6.32",
"vite": "^8.0.10" "vite": "^8.0.12"
} }
} }
+8 -8
View File
@@ -1,12 +1,12 @@
aiogram==3.24.0 aiogram==3.28.2
python-dotenv==1.2.1 python-dotenv==1.2.2
aiohttp==3.13.3 aiohttp==3.13.5
pydantic==2.12.5 pydantic==2.13.4
yookassa==3.9.0 yookassa==3.10.1
httpx>=0.27.0 httpx>=0.27.0
pydantic_settings==2.12.0 pydantic_settings==2.14.1
email-validator==2.3.0 email-validator==2.3.0
sqlalchemy[asyncio]==2.0.45 sqlalchemy[asyncio]==2.0.49
asyncpg==0.31.0 asyncpg==0.31.0
aiocryptopay==0.4.8 aiocryptopay==0.4.8
PyJWT[crypto]==2.10.1 PyJWT[crypto]==2.12.1
+141 -2
View File
@@ -1,13 +1,14 @@
import hashlib import hashlib
import hmac import hmac
import json
import unittest import unittest
from types import SimpleNamespace from types import SimpleNamespace
from unittest.mock import AsyncMock from unittest.mock import AsyncMock, patch
from urllib.parse import parse_qs, urlsplit from urllib.parse import parse_qs, urlsplit
from aiohttp import web from aiohttp import web
from bot.app.web import subscription_webapp from bot.app.web import admin_api, subscription_webapp
from bot.app.web.webapp_auth import ( from bot.app.web.webapp_auth import (
create_telegram_oauth_nonce, create_telegram_oauth_nonce,
create_webapp_session_token, create_webapp_session_token,
@@ -17,6 +18,8 @@ from bot.handlers.user.payment import yookassa_webhook_route
from bot.services.crypto_pay_service import CryptoPayService from bot.services.crypto_pay_service import CryptoPayService
from bot.services.freekassa_service import FreeKassaService from bot.services.freekassa_service import FreeKassaService
from bot.utils.request_security import request_client_ip from bot.utils.request_security import request_client_ip
from config.settings import Settings
from db.database_setup import redacted_database_url
class RequestSecurityTests(unittest.IsolatedAsyncioTestCase): class RequestSecurityTests(unittest.IsolatedAsyncioTestCase):
@@ -131,6 +134,28 @@ class CryptoPayServiceTests(unittest.TestCase):
class WebAppSecurityTests(unittest.IsolatedAsyncioTestCase): class WebAppSecurityTests(unittest.IsolatedAsyncioTestCase):
def test_auth_response_sets_cookies_and_does_not_return_session_token(self):
settings = SimpleNamespace(
WEBAPP_SESSION_SECRET="session-secret",
WEBAPP_SESSION_TTL_SECONDS=3600,
)
response = subscription_webapp._build_webapp_auth_response(
settings,
{"user_id": 321},
token="raw-session-token",
csrf_token="csrf-token",
)
payload = json.loads(response.text)
self.assertTrue(payload["ok"])
self.assertEqual(payload["csrf_token"], "csrf-token")
self.assertNotIn("token", payload)
self.assertEqual(response.cookies["rw_webapp_session"].value, "raw-session-token")
self.assertTrue(response.cookies["rw_webapp_session"]["httponly"])
self.assertEqual(response.cookies["rw_webapp_csrf"].value, "csrf-token")
self.assertFalse(response.cookies["rw_webapp_csrf"]["httponly"])
def test_require_user_id_falls_back_to_cookie_session(self): def test_require_user_id_falls_back_to_cookie_session(self):
settings = SimpleNamespace( settings = SimpleNamespace(
WEBAPP_SESSION_SECRET="session-secret", WEBAPP_SESSION_SECRET="session-secret",
@@ -297,6 +322,120 @@ class WebAppSecurityTests(unittest.IsolatedAsyncioTestCase):
["write", "phone"], ["write", "phone"],
) )
def test_public_webapp_base_url_ignores_forwarded_headers_from_untrusted_remote(self):
settings = SimpleNamespace(
SUBSCRIPTION_MINI_APP_URL="",
trusted_proxies=["127.0.0.1"],
)
request = SimpleNamespace(
remote="203.0.113.10",
scheme="http",
host="internal.local",
headers={
"Host": "internal.local",
"X-Forwarded-Proto": "https",
"X-Forwarded-Host": "evil.example.com",
},
)
self.assertEqual(
subscription_webapp._public_webapp_base_url(settings, request),
"http://internal.local",
)
def test_public_webapp_base_url_accepts_forwarded_headers_from_trusted_proxy(self):
settings = SimpleNamespace(
SUBSCRIPTION_MINI_APP_URL="",
trusted_proxies=["127.0.0.1"],
)
request = SimpleNamespace(
remote="127.0.0.1",
scheme="http",
host="internal.local",
headers={
"Host": "internal.local",
"X-Forwarded-Proto": "https",
"X-Forwarded-Host": "app.example.com",
},
)
self.assertEqual(
subscription_webapp._public_webapp_base_url(settings, request),
"https://app.example.com",
)
async def test_security_headers_csp_excludes_unsafe_eval_and_plain_http_images(self):
request = {"settings": SimpleNamespace()}
handler = AsyncMock(return_value=web.Response(text="ok"))
response = await subscription_webapp._security_headers_middleware(request, handler)
csp = response.headers["Content-Security-Policy"]
self.assertNotIn("'unsafe-eval'", csp)
self.assertIn("img-src 'self' data: https:;", csp)
self.assertNotIn("img-src 'self' data: https: http:;", csp)
class AdminSettingsSecurityTests(unittest.IsolatedAsyncioTestCase):
async def test_admin_settings_masks_secret_values_and_exposes_has_value(self):
class AsyncSessionFactory:
def __call__(self):
return self
async def __aenter__(self):
return object()
async def __aexit__(self, exc_type, exc, tb):
return False
settings = Settings(
_env_file=None,
BOT_TOKEN="token",
POSTGRES_USER="app_user",
POSTGRES_PASSWORD="app_password",
YOOKASSA_SECRET_KEY="super-secret",
SHOP_NAME="Visible shop",
)
request = SimpleNamespace(
app={"settings": settings, "async_session_factory": AsyncSessionFactory()},
headers={},
cookies={},
admin_telegram_id=1,
)
request.get = lambda key, default=None: getattr(request, key, default)
with (
patch.object(admin_api, "_require_admin_user_id", return_value=1),
patch.object(
admin_api.app_settings_dal,
"get_overrides_with_meta",
AsyncMock(return_value=[]),
),
):
response = await admin_api.admin_settings_get_route(request)
payload = json.loads(response.text)
secret_field = next(
field
for section in payload["sections"]
for field in section["fields"]
if field["key"] == "YOOKASSA_SECRET_KEY"
)
self.assertEqual(secret_field["value"], "")
self.assertTrue(secret_field["has_value"])
self.assertNotIn("super-secret", response.text)
class DatabaseLoggingSecurityTests(unittest.TestCase):
def test_database_url_redaction_hides_password(self):
raw_url = "postgresql+asyncpg://user:raw-password@db.example.com:5432/app"
redacted = redacted_database_url(raw_url)
self.assertIn("user:***@", redacted)
self.assertNotIn("raw-password", redacted)
def asyncio_run(coro): def asyncio_run(coro):
import asyncio import asyncio