security: fix 9 vulnerabilities + OrdersTable 14px font + colored status badges

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)
This commit is contained in:
root 2026-06-19 12:14:19 +00:00
parent cdb2ec7098
commit d04901d296
12 changed files with 245 additions and 132 deletions

View File

@ -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"

View File

@ -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

View File

@ -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:

View File

@ -179,7 +179,7 @@ export const OrdersTable = ({
) : (
<div className="overflow-x-auto">
<div className="min-w-[920px]">
<div className="grid grid-cols-[minmax(130px,2fr)_minmax(90px,1fr)_minmax(80px,0.8fr)_minmax(80px,1fr)_minmax(100px,1fr)_minmax(70px,0.7fr)_minmax(80px,0.8fr)] gap-0 border-b border-[var(--color-border)] bg-[var(--color-surface-strong)] text-[11px] uppercase tracking-[0.12em] text-[var(--color-text-muted)]">
<div className="grid grid-cols-[minmax(130px,2fr)_minmax(90px,1fr)_minmax(80px,0.8fr)_minmax(80px,1fr)_minmax(100px,1fr)_minmax(70px,0.7fr)_minmax(80px,0.8fr)] gap-0 border-b border-[var(--color-border)] bg-[var(--color-surface-strong)] text-sm uppercase tracking-[0.12em] text-[var(--color-text-muted)]">
<div className="px-3 py-2 font-medium">Группа / Клиент</div>
<div className="px-3 py-2 font-medium">Счета</div>
<div className="px-3 py-2 font-medium">Город</div>
@ -208,41 +208,41 @@ export const OrdersTable = ({
onClick={() => onOpenOrder(group.id)}
>
<div className="min-w-0 px-3 py-2">
<div className="text-xs font-medium leading-snug break-words" style={{display:"-webkit-box",WebkitLineClamp:2,WebkitBoxOrient:"vertical",overflow:"hidden"}}>{group.displayTitle || group.customerName || group.groupKey}</div>
<div className="mt-0.5 text-[11px] text-[var(--color-text-muted)]">
<div className="text-sm font-medium leading-snug break-words" style={{display:"-webkit-box",WebkitLineClamp:2,WebkitBoxOrient:"vertical",overflow:"hidden"}}>{group.displayTitle || group.customerName || group.groupKey}</div>
<div className="mt-0.5 text-[13px] text-[var(--color-text-muted)]">
{group.customerPhone || ""}
</div>
</div>
<div className="px-3 py-2">
<div className="text-xs text-[var(--color-text)]">{primaryBill}</div>
<div className="text-sm text-[var(--color-text)]">{primaryBill}</div>
{totalBills > 1 && (
<span className="inline-block mt-0.5 rounded-full bg-[var(--color-accent-soft)] px-1.5 py-0.5 text-[10px] font-medium text-[var(--color-accent)]">
<span className="inline-block mt-0.5 rounded-full bg-[var(--color-accent-soft)] px-1.5 py-0.5 text-xs font-medium text-[var(--color-accent)]">
{totalBills} сч.
</span>
)}
</div>
<div className="px-3 py-2 text-xs text-[var(--color-text-muted)]">
<div className="px-3 py-2 text-sm text-[var(--color-text-muted)]">
{group.city || "—"}
</div>
<div className="px-3 py-2">
<span className="inline-flex items-center rounded-full border px-2 py-0.5 text-[10px] font-medium leading-tight" style={{borderColor: "var(--color-border)", background: "var(--color-surface)", color: "var(--color-text)"}}>
<Badge tone={getOrderGroupStatusTone(group)} className="text-sm">
{getOrderGroupDisplayStatusLabel(group)}
</span>
</Badge>
</div>
<div className="px-3 py-2 text-xs">
<div className="px-3 py-2 text-sm">
{group.deliveryDate ? (
<span>{fmtDate(group.deliveryDate)}{group.deliveryTime ? <span className="text-[var(--color-text-muted)]"> · {group.deliveryTime}</span> : ""}</span>
) : (
<span className="text-[var(--color-text-muted)]"></span>
)}
</div>
<div className="px-3 py-2 text-xs">
<div className="px-3 py-2 text-sm">
<span className="inline-flex items-center gap-1">
{group.deliveryType === "pickup" ? "🏪" : "🚚"}
<span className="text-[var(--color-text-muted)]">{group.deliveryType === "pickup" ? "Самовывоз" : "Доставка"}</span>
</span>
</div>
<div className="px-3 py-2 text-xs">
<div className="px-3 py-2 text-sm">
{group.assignedDriverName || <span className="text-[var(--color-text-muted)]"></span>}
</div>
</button>

View File

@ -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 <Navigate to={`/login?redirect=${redirect}`} replace />;
}
return children;
};
export const router = createBrowserRouter([
{
@ -31,11 +52,19 @@ export const router = createBrowserRouter([
},
{
path: "dashboard",
element: <DashboardPage />,
element: (
<RequireAuth>
<DashboardPage />
</RequireAuth>
),
},
{
path: "dashboard/group/:groupId",
element: <GroupDetailPage />,
element: (
<RequireAuth>
<GroupDetailPage />
</RequireAuth>
),
},
{
path: "*",

View File

@ -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<typeof createClient>, 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 });
}

View File

@ -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,

View File

@ -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

View File

@ -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;

View File

@ -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<typeof createClient>, 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 });
}

View File

@ -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,

View File

@ -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