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:
parent
cdb2ec7098
commit
d04901d296
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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: "*",
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue