chore: harden webapp auth and update deps
This commit is contained in:
@@ -1906,14 +1906,17 @@ async def admin_settings_get_route(request: web.Request) -> web.Response:
|
||||
"fields": [],
|
||||
}
|
||||
override = overrides_by_key.get(key)
|
||||
sections[section_id]["fields"].append(
|
||||
{
|
||||
**field,
|
||||
"value": current_value(settings, key),
|
||||
"overridden": bool(override),
|
||||
"updated_at": override.get("updated_at") if override else None,
|
||||
}
|
||||
)
|
||||
value = current_value(settings, key)
|
||||
is_secret = bool(field.get("secret"))
|
||||
response_field = {
|
||||
**field,
|
||||
"value": "" if is_secret else value,
|
||||
"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"])
|
||||
return _ok({"sections": ordered_sections})
|
||||
|
||||
@@ -53,9 +53,7 @@
|
||||
CSRF_COOKIE_NAME,
|
||||
isManuallyLoggedOut as readManualLogoutFlag,
|
||||
markManualLogout as markManualLogoutInStorage,
|
||||
persistToken,
|
||||
readCookie,
|
||||
readStoredToken,
|
||||
} from "./lib/webapp/session.js";
|
||||
import { createTelegramSdk } from "./lib/webapp/telegramSdk.js";
|
||||
import { mockApi as runMockApi } from "./lib/webapp/mockApi.js";
|
||||
@@ -105,7 +103,7 @@
|
||||
let languageClickGuardArmTimer = null;
|
||||
let emailAvatarUrl = "";
|
||||
let avatarHashToken = "";
|
||||
let token = MOCK ? "local-preview" : readStoredToken();
|
||||
let token = MOCK ? "local-preview" : "";
|
||||
let csrfToken = MOCK ? "" : readCookie(CSRF_COOKIE_NAME) || "";
|
||||
let scrollLockApplied = false;
|
||||
let tg = null;
|
||||
@@ -132,7 +130,6 @@
|
||||
const apiClient = createApiClient({
|
||||
apiBase: CFG.apiBase,
|
||||
csrfCookieName: CSRF_COOKIE_NAME,
|
||||
getToken: () => token,
|
||||
getCsrfToken: () => csrfToken,
|
||||
onUnauthorized: () => {
|
||||
clearToken();
|
||||
@@ -621,7 +618,7 @@
|
||||
clearManualLogoutFlag();
|
||||
token = nextToken || "";
|
||||
csrfToken = nextCsrf || readCookie(CSRF_COOKIE_NAME) || "";
|
||||
if (token && !MOCK) persistToken(token);
|
||||
if (!MOCK) clearStoredToken();
|
||||
}
|
||||
|
||||
function clearToken() {
|
||||
|
||||
@@ -65,6 +65,12 @@
|
||||
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) {
|
||||
const groups = new Map();
|
||||
for (const field of section.fields || []) {
|
||||
@@ -124,7 +130,9 @@
|
||||
<div class="admin-setting-meta">
|
||||
<strong>
|
||||
{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)}
|
||||
<AdminBadge variant="success">{at("settings_badge_override", {}, "Override")}</AdminBadge>
|
||||
{/if}
|
||||
@@ -189,7 +197,7 @@
|
||||
<input
|
||||
class="input"
|
||||
type={revealed ? "text" : "password"}
|
||||
placeholder={field.placeholder || "••••••••"}
|
||||
placeholder={secretPlaceholder(field)}
|
||||
autocomplete="off"
|
||||
value={valueFor(field) ?? ""}
|
||||
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 === "/auth/email/request") return { ok: true };
|
||||
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") {
|
||||
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 === "/devices") return clone(DEV_MOCK.data.devices);
|
||||
@@ -267,10 +267,10 @@ export async function mockApi(path, options = {}, context = {}) {
|
||||
return { ok: true };
|
||||
}
|
||||
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") {
|
||||
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") {
|
||||
return {
|
||||
|
||||
@@ -3,7 +3,6 @@ import { readCookie } from "./session.js";
|
||||
export function createApiClient({
|
||||
apiBase = "",
|
||||
csrfCookieName = "rw_webapp_csrf",
|
||||
getToken = () => "",
|
||||
getCsrfToken = () => "",
|
||||
onUnauthorized = () => {},
|
||||
mockApi = null,
|
||||
@@ -14,8 +13,6 @@ export function createApiClient({
|
||||
|
||||
const method = String(options.method || "GET").toUpperCase();
|
||||
const headers = { ...(options.headers || {}) };
|
||||
const token = getToken();
|
||||
if (token) headers.Authorization = `Bearer ${token}`;
|
||||
|
||||
const csrf = getCsrfToken() || readCookie(csrfCookieName) || "";
|
||||
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";
|
||||
|
||||
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(() => ({}));
|
||||
if (response.status === 401) onUnauthorized();
|
||||
return payload;
|
||||
@@ -38,6 +39,7 @@ export function createApiClient({
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
signal: options.signal,
|
||||
credentials: "same-origin",
|
||||
});
|
||||
return response.json();
|
||||
}
|
||||
|
||||
@@ -13,16 +13,6 @@ export function readCookie(name) {
|
||||
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) {
|
||||
if (typeof localStorage === "undefined") return;
|
||||
localStorage.removeItem(storageKey);
|
||||
|
||||
@@ -139,7 +139,7 @@ export function createAccountStore({
|
||||
body: JSON.stringify({ email: s.linkEmailPending, code }),
|
||||
});
|
||||
if (!response?.ok) throw response;
|
||||
if (response?.token) setToken(response.token, response.csrf_token);
|
||||
if (response?.csrf_token) setToken("", response.csrf_token);
|
||||
await loadData();
|
||||
closeLinkEmailDialog();
|
||||
showToast(t("wa_settings_linked"));
|
||||
@@ -158,7 +158,7 @@ export function createAccountStore({
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
if (!response?.ok) throw response;
|
||||
if (response?.token) setToken(response.token, response.csrf_token);
|
||||
if (response?.csrf_token) setToken("", response.csrf_token);
|
||||
await loadData();
|
||||
showToast(t("wa_settings_linked"));
|
||||
} catch (error) {
|
||||
|
||||
@@ -96,8 +96,8 @@ export function createAuthStore({
|
||||
const referralParam = readReferralParam(getTg());
|
||||
if (referralParam) payload.referral_code = referralParam;
|
||||
const response = await publicApi("/auth/email/magic", payload);
|
||||
if (response.ok && response.token) {
|
||||
setToken(response.token, response.csrf_token);
|
||||
if (response.ok && response.csrf_token) {
|
||||
setToken("", response.csrf_token);
|
||||
clearAuthQuery();
|
||||
await loadData();
|
||||
return true;
|
||||
@@ -126,8 +126,8 @@ export function createAuthStore({
|
||||
const referralParam = readReferralParam(getTg());
|
||||
if (referralParam) payload.referral_code = referralParam;
|
||||
const response = await publicApi("/auth/token", payload, { signal: options.signal });
|
||||
if (response.ok && response.token) {
|
||||
setToken(response.token, response.csrf_token);
|
||||
if (response.ok && response.csrf_token) {
|
||||
setToken("", response.csrf_token);
|
||||
clearAuthQuery();
|
||||
setAuthStatus("");
|
||||
await loadData();
|
||||
@@ -202,8 +202,8 @@ export function createAuthStore({
|
||||
const referralParam = readReferralParam(getTg());
|
||||
if (referralParam) payload.referral_code = referralParam;
|
||||
const response = await publicApi("/auth/email/verify", payload);
|
||||
if (!response.ok || !response.token) throw response;
|
||||
setToken(response.token, response.csrf_token);
|
||||
if (!response.ok || !response.csrf_token) throw response;
|
||||
setToken("", response.csrf_token);
|
||||
await loadData();
|
||||
setAuthStatus("");
|
||||
} catch (error) {
|
||||
|
||||
@@ -34,7 +34,7 @@ export function createBillingStore({ billing, loadData, t, showToast, openExtern
|
||||
defaultMethod = ""
|
||||
) {
|
||||
state.update((s) => {
|
||||
let step = s.paymentStep;
|
||||
let step;
|
||||
let plan = s.selectedPlan;
|
||||
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.yookassa_service import YooKassaService
|
||||
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 config.settings import Settings
|
||||
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:
|
||||
return f"{parsed_url.scheme}://{parsed_url.netloc}"
|
||||
|
||||
scheme = request.headers.get("X-Forwarded-Proto") or request.scheme
|
||||
host = request.headers.get("X-Forwarded-Host") or request.headers.get("Host") or request.host
|
||||
headers = request.headers
|
||||
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("/")
|
||||
|
||||
|
||||
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:
|
||||
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",
|
||||
(
|
||||
"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-ancestors https://web.telegram.org https://t.me; "
|
||||
"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:; "
|
||||
"img-src 'self' data: https: http:; "
|
||||
"img-src 'self' data: https:; "
|
||||
"connect-src 'self' https://oauth.telegram.org; "
|
||||
"object-src 'none'; "
|
||||
"base-uri 'self'; "
|
||||
@@ -2860,7 +2883,6 @@ def _build_webapp_auth_response(
|
||||
) -> web.Response:
|
||||
response_payload = dict(payload)
|
||||
response_payload["ok"] = True
|
||||
response_payload["token"] = token
|
||||
csrf_value = csrf_token or secrets.token_hex(32)
|
||||
response_payload["csrf_token"] = csrf_value
|
||||
response = web.json_response(response_payload)
|
||||
|
||||
Reference in New Issue
Block a user