chore: harden webapp auth and update deps
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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"))
|
||||||
**field,
|
response_field = {
|
||||||
"value": current_value(settings, key),
|
**field,
|
||||||
"overridden": bool(override),
|
"value": "" if is_secret else value,
|
||||||
"updated_at": override.get("updated_at") if override else None,
|
"overridden": bool(override),
|
||||||
}
|
"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})
|
||||||
|
|||||||
@@ -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)}
|
||||||
<AdminBadge variant="warning">{at("settings_badge_secret", {}, "Secret")}</AdminBadge>
|
{#if field.secret}
|
||||||
|
<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;
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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`, чтобы обновляться осознанно и иметь откат.
|
||||||
|
|||||||
@@ -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: {
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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": "Истёк",
|
||||||
|
|||||||
Generated
+280
-684
File diff suppressed because it is too large
Load Diff
+9
-9
@@ -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
@@ -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
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user