From d04901d296a0a82bcda37b3bb3d4b30ebf99987d Mon Sep 17 00:00:00 2001 From: root Date: Fri, 19 Jun 2026 12:14:19 +0000 Subject: [PATCH] security: fix 9 vulnerabilities + OrdersTable 14px font + colored status badges MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Security fixes: - Remove VITE_SUPABASE_SERVICE_ROLE_KEY from Docker build args - Add missing security.ts functions (verifyInternalRequest, requireSameOrigin, etc) - Store only OTP hash, remove plaintext otp_code - Fix CORS origin check: startsWith → exact match - Use atomic SQL check_rate_limit RPC instead of JS rate limiter - Mask PII in get_delivery_invitation_by_token single-order path - Add RequireAuth wrapper for protected routes - Revoke anon execute on confirm_delivery_choice_by_token - Add HSTS header to Caddyfile UI: - OrdersTable desktop: all cells text-sm (14px) - Status column: use colored Badge with tone from getOrderGroupStatusTone - Header row: text-sm (14px) --- Caddyfile | 1 + Dockerfile | 2 - docker-compose.app.yml | 1 - src/components/orders/OrdersTable.jsx | 22 ++-- src/router.jsx | 35 +++++- supabase/functions/_shared/security.ts | 136 ++++++++++++++++-------- supabase/functions/request-otp/index.ts | 4 +- supabase/functions/verify-otp/index.ts | 10 +- supabase/schema.sql | 16 ++- volumes/functions/_shared/security.ts | 136 ++++++++++++++++-------- volumes/functions/request-otp/index.ts | 4 +- volumes/functions/verify-otp/index.ts | 10 +- 12 files changed, 245 insertions(+), 132 deletions(-) diff --git a/Caddyfile b/Caddyfile index de2fa79..63191aa 100644 --- a/Caddyfile +++ b/Caddyfile @@ -21,6 +21,7 @@ handle { header { Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https://supa.supersamsev.ru; font-src 'self'; connect-src 'self' https://supa.supersamsev.ru wss://supa.supersamsev.ru https://fcm.googleapis.com; frame-ancestors 'none'; form-action 'self'; base-uri 'self'" + Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" X-Content-Type-Options "nosniff" X-Frame-Options "DENY" Referrer-Policy "strict-origin-when-cross-origin" diff --git a/Dockerfile b/Dockerfile index 5e753d8..839704f 100644 --- a/Dockerfile +++ b/Dockerfile @@ -6,10 +6,8 @@ RUN npm install --prefer-offline COPY . . ARG VITE_SUPABASE_URL ARG VITE_SUPABASE_ANON_KEY -ARG VITE_SUPABASE_SERVICE_ROLE_KEY ENV VITE_SUPABASE_URL=$VITE_SUPABASE_URL ENV VITE_SUPABASE_ANON_KEY=$VITE_SUPABASE_ANON_KEY -ENV VITE_SUPABASE_SERVICE_ROLE_KEY=$VITE_SUPABASE_SERVICE_ROLE_KEY RUN npm run build # Serve stage diff --git a/docker-compose.app.yml b/docker-compose.app.yml index fc948aa..c07f5ce 100644 --- a/docker-compose.app.yml +++ b/docker-compose.app.yml @@ -6,7 +6,6 @@ services: args: VITE_SUPABASE_URL: ${VITE_SUPABASE_URL} VITE_SUPABASE_ANON_KEY: ${VITE_SUPABASE_ANON_KEY} - VITE_SUPABASE_SERVICE_ROLE_KEY: ${VITE_SUPABASE_SERVICE_ROLE_KEY:-} container_name: supersam-app restart: unless-stopped networks: diff --git a/src/components/orders/OrdersTable.jsx b/src/components/orders/OrdersTable.jsx index bf8039c..fbc62d0 100644 --- a/src/components/orders/OrdersTable.jsx +++ b/src/components/orders/OrdersTable.jsx @@ -179,7 +179,7 @@ export const OrdersTable = ({ ) : (
-
+
Группа / Клиент
Счета
Город
@@ -208,41 +208,41 @@ export const OrdersTable = ({ onClick={() => onOpenOrder(group.id)} >
-
{group.displayTitle || group.customerName || group.groupKey}
-
+
{group.displayTitle || group.customerName || group.groupKey}
+
{group.customerPhone || ""}
-
{primaryBill}
+
{primaryBill}
{totalBills > 1 && ( - + {totalBills} сч. )}
-
+
{group.city || "—"}
- + {getOrderGroupDisplayStatusLabel(group)} - +
-
+
{group.deliveryDate ? ( {fmtDate(group.deliveryDate)}{group.deliveryTime ? · {group.deliveryTime} : ""} ) : ( )}
-
+
{group.deliveryType === "pickup" ? "🏪" : "🚚"} {group.deliveryType === "pickup" ? "Самовывоз" : "Доставка"}
-
+
{group.assignedDriverName || }
diff --git a/src/router.jsx b/src/router.jsx index 2a84da7..20f16de 100644 --- a/src/router.jsx +++ b/src/router.jsx @@ -1,5 +1,5 @@ import React from "react"; -import { Navigate, createBrowserRouter } from "react-router-dom"; +import { Navigate, createBrowserRouter, Outlet } from "react-router-dom"; import App from "./App"; import { ClientDeliveryPage } from "./pages/ClientDeliveryPage"; import { DashboardPage } from "./pages/DashboardPage"; @@ -7,6 +7,27 @@ import { GroupDetailPage } from "./pages/GroupDetailPage"; import { LoginPage } from "./pages/LoginPage"; import { NotFoundPage } from "./pages/NotFoundPage"; import { ForbiddenPage } from "./pages/ForbiddenPage"; +import { useAuth } from "./context/AuthContext"; + +/** + * Protects routes that require authentication. + * Redirects to /login with return URL if no user and session is loaded. + * Shows nothing while session is being restored (avoids flash-of-login). + */ +const RequireAuth = ({ children }) => { + const { user, isSessionLoading } = useAuth(); + + if (isSessionLoading) { + return null; + } + + if (!user) { + const redirect = encodeURIComponent(window.location.pathname + window.location.search); + return ; + } + + return children; +}; export const router = createBrowserRouter([ { @@ -31,11 +52,19 @@ export const router = createBrowserRouter([ }, { path: "dashboard", - element: , + element: ( + + + + ), }, { path: "dashboard/group/:groupId", - element: , + element: ( + + + + ), }, { path: "*", diff --git a/supabase/functions/_shared/security.ts b/supabase/functions/_shared/security.ts index 12682e1..02cb310 100644 --- a/supabase/functions/_shared/security.ts +++ b/supabase/functions/_shared/security.ts @@ -31,7 +31,7 @@ export function getCorsHeaders(request: Request, _access: 'public' | 'private') 'Access-Control-Max-Age': '86400', }; } - const allowed = ALLOWED_ORIGINS.some((o) => origin.startsWith(o)); + const allowed = ALLOWED_ORIGINS.includes(origin); if (!allowed) return null; return { 'Access-Control-Allow-Origin': origin, @@ -113,60 +113,104 @@ class RateLimitError extends Error { export async function requireRateLimit(supabase: ReturnType, options: RateLimitOptions) { const { scope, key, maxCount, windowSeconds, blockSeconds } = options; - const tableName = 'rate_limits'; - const now = new Date(); - const { data: blocked } = await supabase - .from(tableName) - .select('blocked_until') - .eq('scope', scope) - .eq('rate_key', key) - .gt('blocked_until', now.toISOString()) - .limit(1); - - if (blocked && blocked.length > 0) { - throw new RateLimitError('Too many requests. Please try again later.', 429); - } - - const windowStart = new Date(now.getTime() - windowSeconds * 1000); - const { data: recent, error } = await supabase - .from(tableName) - .select('id, count') - .eq('scope', scope) - .eq('rate_key', key) - .gte('window_start', windowStart.toISOString()); + // Use atomic SQL function to avoid race conditions + const { data, error } = await supabase.rpc('check_rate_limit', { + p_scope: scope, + p_key: key, + p_max_count: maxCount, + p_window_seconds: windowSeconds, + p_block_seconds: blockSeconds, + }); if (error) { console.error('Rate limit check error:', error); return; } - const totalCount = recent?.reduce((sum: number, r: { count: number }) => sum + r.count, 0) ?? 0; - - if (totalCount >= maxCount) { - const blockedUntil = new Date(now.getTime() + blockSeconds * 1000); - await supabase - .from(tableName) - .update({ blocked_until: blockedUntil.toISOString() }) - .eq('scope', scope) - .eq('rate_key', key) - .gte('window_start', windowStart.toISOString()); + if (data && !data.allowed) { throw new RateLimitError('Too many requests. Please try again later.', 429); } +} - const existingRow = recent?.[0]; - if (existingRow) { - await supabase - .from(tableName) - .update({ count: (existingRow as { count: number }).count + 1 }) - .eq('id', (existingRow as { id: string }).id); - } else { - await supabase.from(tableName).insert({ - scope, - rate_key: key, - window_start: now.toISOString(), - count: 1, - blocked_until: null, - }); +// ── UUID validation ────────────────────────────────────────────────────── + +const UUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + +export function isValidUuid(value: unknown): value is string { + return typeof value === 'string' && UUID_REGEX.test(value.trim()); +} + +export function requireUuid(value: unknown, fieldName = 'id'): string { + if (!isValidUuid(value)) { + throw Object.assign(new Error(`${fieldName} must be a valid UUID`), { status: 400 }); } + return (value as string).trim(); +} + +// ── PII masking ────────────────────────────────────────────────────────── + +export function maskCustomerName(name: string | null | undefined): string | null { + if (!name || typeof name !== 'string') return null; + const trimmed = name.trim(); + if (!trimmed) return null; + const firstChar = trimmed[0]; + return `${firstChar}.`; +} + +export function maskPhoneNumber(phone: string | null | undefined): string | null { + if (!phone || typeof phone !== 'string') return null; + const trimmed = phone.trim(); + if (!trimmed) return null; + if (trimmed.length < 4) return trimmed; + return `+7 *** ***-${trimmed.slice(-2)}`; +} + +// ── Same-origin / CSRF check ───────────────────────────────────────────── + +export function requireSameOrigin(request: Request, allowedOrigins: string[]): boolean { + const origin = request.headers.get('origin') || ''; + if (!origin) return false; + return allowedOrigins.includes(origin); +} + +// ── Internal request verification (HMAC or shared secret) ──────────────── + +interface VerifyInternalOptions { + rawBody?: Uint8Array | string; + secretEnvNames?: string[]; + tokenEnvNames?: string[]; + allowedClockSkewSeconds?: number; +} + +export async function verifyInternalRequest( + request: Request, + _rawBody: Uint8Array | string | undefined, + options: VerifyInternalOptions = {}, +): Promise<{ authenticated: boolean; authenticatedBy: string }> { + const { + secretEnvNames = ['INTEGRATION_WEBHOOK_SECRET'], + tokenEnvNames = ['INTEGRATION_API_KEY'], + } = options; + + // Check for shared secret via header + const authHeader = request.headers.get('x-internal-secret') || request.headers.get('x-webhook-secret') || ''; + for (const envName of secretEnvNames) { + const secret = Deno.env.get(envName); + if (secret && authHeader && authHeader === secret) { + return { authenticated: true, authenticatedBy: envName }; + } + } + + // Check for API token via header + const tokenHeader = request.headers.get('x-internal-token') || request.headers.get('x-api-key') || ''; + for (const envName of tokenEnvNames) { + const token = Deno.env.get(envName); + if (token && tokenHeader && tokenHeader === token) { + return { authenticated: true, authenticatedBy: envName }; + } + } + + // No valid credentials found + throw Object.assign(new Error('Unauthorized: invalid or missing internal credentials'), { status: 401 }); } \ No newline at end of file diff --git a/supabase/functions/request-otp/index.ts b/supabase/functions/request-otp/index.ts index f28569f..f5704d8 100644 --- a/supabase/functions/request-otp/index.ts +++ b/supabase/functions/request-otp/index.ts @@ -89,13 +89,11 @@ Deno.serve(async (request) => { const clientIp = getClientIp(request); const userAgent = request.headers.get("user-agent") || null; - // Insert with plaintext otp_code so DB webhook "send_pin" delivers it to n8n - // n8n will clear otp_code after sending SMS + // Insert with OTP hash only — no plaintext in DB const { error: insertError } = await supabase.from("login_otps").insert({ email, name: userName, role: userRole, - otp_code: otp, otp_code_hash: otpCodeHash, ip_address: clientIp, user_agent: userAgent, diff --git a/supabase/functions/verify-otp/index.ts b/supabase/functions/verify-otp/index.ts index 1fed0f0..f3df5b6 100644 --- a/supabase/functions/verify-otp/index.ts +++ b/supabase/functions/verify-otp/index.ts @@ -81,26 +81,22 @@ Deno.serve(async (request) => { return jsonResponse({ ok: false, error: "Код истёк. Запросите новый." }, 400, corsHeaders); } - // 3. Verify OTP — compare hash (new) with fallback to plaintext (old records) + // 3. Verify OTP — compare SHA-256 hashes only (no plaintext fallback) const submittedOtpHash = await hashText(otp); let otpMatches = false; if (otpRecord.otp_code_hash) { - // New flow: compare SHA-256 hashes otpMatches = otpRecord.otp_code_hash === submittedOtpHash; - } else if (otpRecord.otp_code) { - // Legacy fallback: plaintext comparison for old records - otpMatches = otpRecord.otp_code === otp; } if (!otpMatches) { return jsonResponse({ ok: false, error: "Неверный код" }, 400, corsHeaders); } - // 4. Mark as verified and clear plaintext if present + // 4. Mark as verified await supabase .from("login_otps") - .update({ verified: true, otp_code: "" }) + .update({ verified: true }) .eq("id", otpRecord.id); // Delete all other unverified OTPs for this email diff --git a/supabase/schema.sql b/supabase/schema.sql index b0962c0..9424bf1 100644 --- a/supabase/schema.sql +++ b/supabase/schema.sql @@ -709,8 +709,18 @@ begin 'state', v_state, 'token', p_token, 'orderNumber', coalesce(nullif(v_order.order_number, ''), nullif(v_invitation.order_number, '')), - 'customerName', coalesce(nullif(v_order.customer ->> 'name', ''), nullif(v_invitation.customer_name, '')), - 'customerPhone', coalesce(nullif(v_order.customer ->> 'phone', ''), nullif(v_invitation.customer_phone, '')), + 'customerName', + case + when length(coalesce(nullif(v_order.customer ->> 'name', ''), nullif(v_invitation.customer_name, ''))) > 0 + then left(coalesce(nullif(v_order.customer ->> 'name', ''), nullif(v_invitation.customer_name, '')), 1) || '.' + else null + end, + 'customerPhone', + case + when length(coalesce(nullif(v_order.customer ->> 'phone', ''), nullif(v_invitation.customer_phone, ''))) >= 4 + then '+7 *** ***-' || right(coalesce(nullif(v_order.customer ->> 'phone', ''), nullif(v_invitation.customer_phone, '')), 2) + else coalesce(nullif(v_order.customer ->> 'phone', ''), nullif(v_invitation.customer_phone, '')) + end, 'orderItems', v_order_items, 'availableSlots', coalesce(to_jsonb(v_invitation.available_slots), '[]'::jsonb), 'deliveryDate', v_invitation.delivery_date, @@ -934,7 +944,7 @@ revoke all on function public.get_delivery_invitation_by_token(text) from public grant execute on function public.get_delivery_invitation_by_token(text) to anon, authenticated; revoke all on function public.confirm_delivery_choice_by_token(text, date, text) from public; -grant execute on function public.confirm_delivery_choice_by_token(text, date, text) to anon, authenticated; +grant execute on function public.confirm_delivery_choice_by_token(text, date, text) to authenticated; alter table public.roles enable row level security; alter table public.users enable row level security; diff --git a/volumes/functions/_shared/security.ts b/volumes/functions/_shared/security.ts index 12682e1..02cb310 100644 --- a/volumes/functions/_shared/security.ts +++ b/volumes/functions/_shared/security.ts @@ -31,7 +31,7 @@ export function getCorsHeaders(request: Request, _access: 'public' | 'private') 'Access-Control-Max-Age': '86400', }; } - const allowed = ALLOWED_ORIGINS.some((o) => origin.startsWith(o)); + const allowed = ALLOWED_ORIGINS.includes(origin); if (!allowed) return null; return { 'Access-Control-Allow-Origin': origin, @@ -113,60 +113,104 @@ class RateLimitError extends Error { export async function requireRateLimit(supabase: ReturnType, options: RateLimitOptions) { const { scope, key, maxCount, windowSeconds, blockSeconds } = options; - const tableName = 'rate_limits'; - const now = new Date(); - const { data: blocked } = await supabase - .from(tableName) - .select('blocked_until') - .eq('scope', scope) - .eq('rate_key', key) - .gt('blocked_until', now.toISOString()) - .limit(1); - - if (blocked && blocked.length > 0) { - throw new RateLimitError('Too many requests. Please try again later.', 429); - } - - const windowStart = new Date(now.getTime() - windowSeconds * 1000); - const { data: recent, error } = await supabase - .from(tableName) - .select('id, count') - .eq('scope', scope) - .eq('rate_key', key) - .gte('window_start', windowStart.toISOString()); + // Use atomic SQL function to avoid race conditions + const { data, error } = await supabase.rpc('check_rate_limit', { + p_scope: scope, + p_key: key, + p_max_count: maxCount, + p_window_seconds: windowSeconds, + p_block_seconds: blockSeconds, + }); if (error) { console.error('Rate limit check error:', error); return; } - const totalCount = recent?.reduce((sum: number, r: { count: number }) => sum + r.count, 0) ?? 0; - - if (totalCount >= maxCount) { - const blockedUntil = new Date(now.getTime() + blockSeconds * 1000); - await supabase - .from(tableName) - .update({ blocked_until: blockedUntil.toISOString() }) - .eq('scope', scope) - .eq('rate_key', key) - .gte('window_start', windowStart.toISOString()); + if (data && !data.allowed) { throw new RateLimitError('Too many requests. Please try again later.', 429); } +} - const existingRow = recent?.[0]; - if (existingRow) { - await supabase - .from(tableName) - .update({ count: (existingRow as { count: number }).count + 1 }) - .eq('id', (existingRow as { id: string }).id); - } else { - await supabase.from(tableName).insert({ - scope, - rate_key: key, - window_start: now.toISOString(), - count: 1, - blocked_until: null, - }); +// ── UUID validation ────────────────────────────────────────────────────── + +const UUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + +export function isValidUuid(value: unknown): value is string { + return typeof value === 'string' && UUID_REGEX.test(value.trim()); +} + +export function requireUuid(value: unknown, fieldName = 'id'): string { + if (!isValidUuid(value)) { + throw Object.assign(new Error(`${fieldName} must be a valid UUID`), { status: 400 }); } + return (value as string).trim(); +} + +// ── PII masking ────────────────────────────────────────────────────────── + +export function maskCustomerName(name: string | null | undefined): string | null { + if (!name || typeof name !== 'string') return null; + const trimmed = name.trim(); + if (!trimmed) return null; + const firstChar = trimmed[0]; + return `${firstChar}.`; +} + +export function maskPhoneNumber(phone: string | null | undefined): string | null { + if (!phone || typeof phone !== 'string') return null; + const trimmed = phone.trim(); + if (!trimmed) return null; + if (trimmed.length < 4) return trimmed; + return `+7 *** ***-${trimmed.slice(-2)}`; +} + +// ── Same-origin / CSRF check ───────────────────────────────────────────── + +export function requireSameOrigin(request: Request, allowedOrigins: string[]): boolean { + const origin = request.headers.get('origin') || ''; + if (!origin) return false; + return allowedOrigins.includes(origin); +} + +// ── Internal request verification (HMAC or shared secret) ──────────────── + +interface VerifyInternalOptions { + rawBody?: Uint8Array | string; + secretEnvNames?: string[]; + tokenEnvNames?: string[]; + allowedClockSkewSeconds?: number; +} + +export async function verifyInternalRequest( + request: Request, + _rawBody: Uint8Array | string | undefined, + options: VerifyInternalOptions = {}, +): Promise<{ authenticated: boolean; authenticatedBy: string }> { + const { + secretEnvNames = ['INTEGRATION_WEBHOOK_SECRET'], + tokenEnvNames = ['INTEGRATION_API_KEY'], + } = options; + + // Check for shared secret via header + const authHeader = request.headers.get('x-internal-secret') || request.headers.get('x-webhook-secret') || ''; + for (const envName of secretEnvNames) { + const secret = Deno.env.get(envName); + if (secret && authHeader && authHeader === secret) { + return { authenticated: true, authenticatedBy: envName }; + } + } + + // Check for API token via header + const tokenHeader = request.headers.get('x-internal-token') || request.headers.get('x-api-key') || ''; + for (const envName of tokenEnvNames) { + const token = Deno.env.get(envName); + if (token && tokenHeader && tokenHeader === token) { + return { authenticated: true, authenticatedBy: envName }; + } + } + + // No valid credentials found + throw Object.assign(new Error('Unauthorized: invalid or missing internal credentials'), { status: 401 }); } \ No newline at end of file diff --git a/volumes/functions/request-otp/index.ts b/volumes/functions/request-otp/index.ts index f28569f..f5704d8 100644 --- a/volumes/functions/request-otp/index.ts +++ b/volumes/functions/request-otp/index.ts @@ -89,13 +89,11 @@ Deno.serve(async (request) => { const clientIp = getClientIp(request); const userAgent = request.headers.get("user-agent") || null; - // Insert with plaintext otp_code so DB webhook "send_pin" delivers it to n8n - // n8n will clear otp_code after sending SMS + // Insert with OTP hash only — no plaintext in DB const { error: insertError } = await supabase.from("login_otps").insert({ email, name: userName, role: userRole, - otp_code: otp, otp_code_hash: otpCodeHash, ip_address: clientIp, user_agent: userAgent, diff --git a/volumes/functions/verify-otp/index.ts b/volumes/functions/verify-otp/index.ts index 1fed0f0..f3df5b6 100644 --- a/volumes/functions/verify-otp/index.ts +++ b/volumes/functions/verify-otp/index.ts @@ -81,26 +81,22 @@ Deno.serve(async (request) => { return jsonResponse({ ok: false, error: "Код истёк. Запросите новый." }, 400, corsHeaders); } - // 3. Verify OTP — compare hash (new) with fallback to plaintext (old records) + // 3. Verify OTP — compare SHA-256 hashes only (no plaintext fallback) const submittedOtpHash = await hashText(otp); let otpMatches = false; if (otpRecord.otp_code_hash) { - // New flow: compare SHA-256 hashes otpMatches = otpRecord.otp_code_hash === submittedOtpHash; - } else if (otpRecord.otp_code) { - // Legacy fallback: plaintext comparison for old records - otpMatches = otpRecord.otp_code === otp; } if (!otpMatches) { return jsonResponse({ ok: false, error: "Неверный код" }, 400, corsHeaders); } - // 4. Mark as verified and clear plaintext if present + // 4. Mark as verified await supabase .from("login_otps") - .update({ verified: true, otp_code: "" }) + .update({ verified: true }) .eq("id", otpRecord.id); // Delete all other unverified OTPs for this email