mirror of
https://github.com/yusufipk/OpenFrame.git
synced 2026-09-11 17:46:06 +00:00
feat(validation): implement validateAnnotationStrokes function for safe annotation data handling
feat(rate-limit): add TRUSTED_PROXY_MODE for configurable proxy header trust feat(comments): validate annotation data structure in comment routes and components
This commit is contained in:
+37
-20
@@ -150,33 +150,50 @@ function isPlausibleIp(value: string): boolean {
|
||||
/**
|
||||
* Get client IP from request headers.
|
||||
*
|
||||
* Header priority:
|
||||
* 1. cf-connecting-ip — set by Cloudflare (trusted proxy); cannot be spoofed by clients
|
||||
* 2. x-forwarded-for — first entry, trusted only behind a proxy that overwrites it
|
||||
* 3. x-real-ip — set by some reverse proxies (Nginx)
|
||||
* 4. 127.0.0.1 — local development fallback
|
||||
* Trusting proxy-injected headers is only safe when a known trusted proxy sits in front
|
||||
* of this server and strips or overwrites those headers before forwarding requests.
|
||||
* Set TRUSTED_PROXY_MODE to opt in:
|
||||
*
|
||||
* Deployed behind Cloudflare, so cf-connecting-ip is the canonical source.
|
||||
* TRUSTED_PROXY_MODE=cloudflare — trust cf-connecting-ip (Cloudflare edge)
|
||||
* TRUSTED_PROXY_MODE=nginx — trust x-real-ip / x-forwarded-for (Nginx real_ip_header)
|
||||
*
|
||||
* Without TRUSTED_PROXY_MODE set, no proxy headers are trusted: all requests appear
|
||||
* as 127.0.0.1, which means rate limits apply per-process rather than per-client IP.
|
||||
* In that configuration, prefer session/user-keyed rate limits for authenticated endpoints.
|
||||
*
|
||||
* WARNING: Do not set TRUSTED_PROXY_MODE unless you have confirmed that your proxy
|
||||
* strips or overwrites the corresponding headers on every inbound request. Failing to
|
||||
* do so allows clients to spoof their IP and bypass rate limits.
|
||||
*/
|
||||
export function getClientIp(request: Request): string {
|
||||
// Cloudflare always sets this to the true client IP
|
||||
const cfIp = request.headers.get('cf-connecting-ip');
|
||||
if (cfIp && isPlausibleIp(cfIp)) {
|
||||
return cfIp;
|
||||
const mode = process.env.TRUSTED_PROXY_MODE?.trim().toLowerCase();
|
||||
|
||||
if (mode === 'cloudflare') {
|
||||
// cf-connecting-ip is injected by Cloudflare and cannot be set by clients
|
||||
// when origin access is restricted to Cloudflare's IP ranges.
|
||||
const cfIp = request.headers.get('cf-connecting-ip');
|
||||
if (cfIp && isPlausibleIp(cfIp)) {
|
||||
return cfIp;
|
||||
}
|
||||
}
|
||||
|
||||
const forwardedFor = request.headers.get('x-forwarded-for');
|
||||
if (forwardedFor) {
|
||||
const first = forwardedFor.split(',')[0].trim();
|
||||
if (isPlausibleIp(first)) return first;
|
||||
if (mode === 'nginx') {
|
||||
// x-real-ip is set by Nginx's real_ip_header directive (connection-level, not spoofable
|
||||
// by clients when set_real_ip_from is configured for the upstream proxy).
|
||||
const realIp = request.headers.get('x-real-ip');
|
||||
if (realIp && isPlausibleIp(realIp)) return realIp;
|
||||
|
||||
// x-forwarded-for last entry added by Nginx when proxy_add_x_forwarded_for is used.
|
||||
const forwardedFor = request.headers.get('x-forwarded-for');
|
||||
if (forwardedFor) {
|
||||
const entries = forwardedFor.split(',');
|
||||
const last = entries[entries.length - 1].trim();
|
||||
if (isPlausibleIp(last)) return last;
|
||||
}
|
||||
}
|
||||
|
||||
const realIp = request.headers.get('x-real-ip');
|
||||
if (realIp && isPlausibleIp(realIp)) {
|
||||
return realIp;
|
||||
}
|
||||
|
||||
// Fallback for local development
|
||||
// No trusted proxy configured — fall back to a constant value.
|
||||
// Rate limiting will apply per-process; use userId-keyed limits for authenticated endpoints.
|
||||
return '127.0.0.1';
|
||||
}
|
||||
|
||||
|
||||
@@ -11,6 +11,57 @@ export function isValidHttpUrl(urlString: string): boolean {
|
||||
}
|
||||
}
|
||||
|
||||
// Matches exactly 6-digit hex colours produced by the annotation canvas (e.g. #FF3B30)
|
||||
const ANNOTATION_COLOR_RE = /^#[0-9a-fA-F]{6}$/;
|
||||
const MAX_STROKES = 500;
|
||||
const MAX_POINTS_PER_STROKE = 2000;
|
||||
const MIN_STROKE_WIDTH = 1;
|
||||
const MAX_STROKE_WIDTH = 20;
|
||||
|
||||
/**
|
||||
* Validates and returns a safe copy of annotation stroke data.
|
||||
*
|
||||
* Accepts only an array of plain stroke objects with the exact shape created
|
||||
* by AnnotationCanvas. Rejects anything that could trigger prototype pollution
|
||||
* or carry unexpected properties into the renderer.
|
||||
*
|
||||
* Returns null when the input is absent or structurally invalid.
|
||||
*/
|
||||
export function validateAnnotationStrokes(
|
||||
data: unknown
|
||||
): { points: { x: number; y: number }[]; color: string; width: number }[] | null {
|
||||
if (data === null || data === undefined) return null;
|
||||
if (!Array.isArray(data)) return null;
|
||||
if (data.length > MAX_STROKES) return null;
|
||||
|
||||
const result: { points: { x: number; y: number }[]; color: string; width: number }[] = [];
|
||||
|
||||
for (const stroke of data) {
|
||||
if (stroke === null || typeof stroke !== 'object' || Array.isArray(stroke)) return null;
|
||||
|
||||
const { points, color, width } = stroke as Record<string, unknown>;
|
||||
|
||||
if (!Array.isArray(points)) return null;
|
||||
if (points.length > MAX_POINTS_PER_STROKE) return null;
|
||||
|
||||
const safePoints: { x: number; y: number }[] = [];
|
||||
for (const pt of points) {
|
||||
if (pt === null || typeof pt !== 'object' || Array.isArray(pt)) return null;
|
||||
const { x, y } = pt as Record<string, unknown>;
|
||||
if (typeof x !== 'number' || !isFinite(x)) return null;
|
||||
if (typeof y !== 'number' || !isFinite(y)) return null;
|
||||
safePoints.push({ x, y });
|
||||
}
|
||||
|
||||
if (typeof color !== 'string' || !ANNOTATION_COLOR_RE.test(color)) return null;
|
||||
if (typeof width !== 'number' || width < MIN_STROKE_WIDTH || width > MAX_STROKE_WIDTH) return null;
|
||||
|
||||
result.push({ points: safePoints, color, width });
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates a URL and returns an error message if invalid
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user