Skip to content

Vaynerov Technologies

No solo desarrollamos — conjuramos cada línea de código y píxel.

Todos los artículosHistorias de construcción

Un museo para todo lo que tenga URL

Quería recorrer mi lista de subastas como una galería en lugar de leerla como una hoja de cálculo. Para eso hicieron falta un parser sin dependencias, una protección SSRF con un riesgo residual documentado, un proxy de imágenes que no reenvía nada y un museo que no toca ningún servidor.

Edward AmirainFundador, Vaynerov Technologies
Publicado 13 min de lectura
En esta página
  1. Pega un enlace, obtén una pieza
  2. Protección SSRF: un servidor que descarga URLs es un arma cargada
  3. El proxy de imágenes que no reenvía nada
  4. Los sitios que se esconden de los robots
  5. Privacidad en localStorage: tu museo es tuyo
  6. Una galería 3D procedural que no se mueve nunca
  7. Limitarnos la tasa a nosotros mismos, y otras lecciones

Una lista de seguimiento es la manera menos digna de mirar un objeto bonito. Trescientos píxeles de ancho, recortado en cuadrado por alguien con prisa, apilado junto a una cuenta atrás. He pasado más tardes de las que me gustaría admitir entornando los ojos ante un instrumento de latón a través de esa mirilla. Hace cuatro días publicamos lo contrario: Vaynerov Project Reliquary, un museo que se construye a sí mismo alrededor de cualquier cosa a la que puedas darle un enlace.

La propuesta es una frase — un museo para todo lo que tenga URL — y el producto es esa frase tomada al pie de la letra. Pegas un enlace. Segundos después estás en un vestíbulo, y lo que pegaste está en la pared bajo una luz cálida, enmarcado a un tamaño que elige su propia relación de aspecto. Nada de eso se guarda en nuestros servidores. Esa era la promesa fácil; todo lo que hay aguas arriba fue el trabajo.

0
bytes almacenados en el servidor
60
piezas por museo
8
imágenes conservadas por pieza
4
cargas de textura simultáneas

Los límites de funcionamiento del Reliquary. El primer número es el producto; el resto es lo que hace falta para que el primero sea sostenible.

El cliente normaliza lo que pegas — un host suelto pasa a https:// — y luego lo envía por POST a /api/museum/parse. La ruta valida la URL antes de gastar nada del presupuesto de límite de tasa: un pegado malformado no debería costarte uno de tus ocho análisis por minuto. Después, un plazo de 10 segundos, un techo de 3 MB y un fetch mucho más paranoico de lo que parece.

La especificación tenía dos restricciones escritas arriba del todo: ninguna dependencia nueva de npm, ningún recurso binario. Las dos moldearon este producto más que cualquier funcionalidad. La primera implica que el parser es puro trabajo de cadenas y expresiones regulares: sin DOM, sin cheerio, sin nada headless. Sobre todo un cilicio; también una ruta de análisis sin más superficie de ataque que la que escribí yo mismo.

La extracción funciona en cascada, y el orden es la opinión: JSON-LD, después OpenGraph y las tarjetas de Twitter, después <title> y el meta de descripción, y por último los enriquecedores por sitio. JSON-LD gana allí donde existe, porque es la afirmación estructurada que la página hace sobre sí misma y no algo inferido de su mobiliario. El recorredor lee cada bloque application/ld+json, desciende por los miembros de @graph y por los mainEntity anidados, y entiende SaleEvent porque así es como se describen los catálogos de subastas. El precio sale de offers, pero solo cuando el importe es positivo y la moneda tiene forma de código ISO-4217.

OpenGraph exigía otro truco. Sus metadatos de imagen son posicionales: og:image:width y og:image:height afinan el og:image que vino último, y og:image:secure_url sustituye al src anterior. Un barrido ingenuo de clave-valor aplana todo eso en una media borrosa entre la imagen más grande y la más pequeña de la página. El nuestro recorre las etiquetas en orden de documento y mantiene la asociación, que es la razón de que la foto principal de un anuncio se convierta de forma fiable en el cuadro de la pared.

Después, la higiene, que es la mayor parte del código. Las URLs de imagen se absolutizan contra la URL final tras las redirecciones, los esquemas que no sean http(s) se descartan, los SVG se tiran — no sobreviven al proxy de imágenes más adelante, así que conservar uno solo colgaría un marco que fallará después. Cualquier cosa por debajo de 50 píxeles declarados se va también, igual que cualquier cosa cuya URL huela a baliza.

// A 1×1 tracking GIF is technically an image. It is not an exhibit.
const TRACKING_HINT = /1x1|pixel|spacer|blank|beacon|tracker/i;

Las supervivientes se deduplican y se limitan a ocho por pieza. Una página que no da ni título ni imagen recibe un nothing_found honesto en lugar de un marco vacío.

Y cuando un fragmento pegado ofrece más de un par (moneda, importe) distinto, el precio se omite por completo: adivinar cuál de tres números querías decir es la pequeña mentira segura de sí misma que vuelve poco fiable a una herramienta.

Un camino desde una URL hasta una pared. La salida del servidor se vuelve a sanear en el cliente antes de que se monte nada: tratamos la respuesta de nuestra propia API como no confiable, porque de vuelta pasó por una página que no controlamos.

Protección SSRF: un servidor que descarga URLs es un arma cargada

Esta es la forma incómoda de esta funcionalidad. Un desconocido escribe una cadena y mi servidor le hace una petición HTTP, desde dentro de mi infraestructura, con la posición de red que tenga esa infraestructura. Falsificación de peticiones del lado del servidor de manual — y el premio no es http://localhost, es http://169.254.169.254, la dirección link-local donde los proveedores de nube aparcan los metadatos de instancia y, históricamente, las credenciales. Así que safe-fetch.ts se niega por capas:

  • Esquema: solo http/https; userinfo rechazado de plano; el puerto debe ser 80 o 443.
  • Sufijos de hostlocalhost, *.localhost, *.local, *.internal.
  • IPv4 — 0/8, 10/8, 100.64/10 (NAT de operador), 127/8, 169.254/16, 172.16/12, 192.0.0/24, 192.0.2/24, 192.168/16, 198.18/15, 198.51.100/24, 203.0.113/24, 224/4, 240/4. Una dirección que no se puede parsear se rechaza, no se permite.
  • IPv6 — se quita el índice de zona y luego ::, ::1, fc00::/7, fe80::/10, ff00::/8, el bloque de documentación 2001:db8 y el prefijo NAT64 64:ff9b.
  • DNS{ all: true, verbatim: true }; un resultado vacío se rechaza, y todas las direcciones devueltas deben ser públicas. Una sola respuesta privada envenena el nombre de host entero.

Las reglas de IPv6 llevan un requisito de orden fácil de invertir. Una dirección como ::ffff:169.254.169.254 tiene una cola en cuartetos decimales, así que un validador que lea primero la cola deja pasar un cuarteto de aspecto público sobre un prefijo privado. La clasificación del prefijo va primero; solo después se entregan al validador IPv4 las colas mapeadas.

Las redirecciones son donde suelen morir las protecciones, así que las nuestras son manuales: sin seguimiento automático, cuatro saltos como máximo, y cada salto se vuelve a analizar y a pasar por la validación completa. Una URL pública permisiva que hace un 302 hacia link-local es el truco más viejo del género. Un único plazo de AbortController cubre la cadena entera en vez de cada salto, así que cinco saltos lentos no pueden multiplicarse hasta un minuto. El tope de bytes se aplica dos veces — contra content-length y luego mediante un bucle lector que cancela al desbordar — porque content-length es una afirmación que hace otro.

Contra lo que no nos defendemos también está escrito en el archivo: el rebinding de DNS. Entre nuestra resolución y la conexión real, un resolutor hostil puede cambiar su respuesta. Cerrar eso significa fijar la IP validada, lo que significa un dispatcher propio de undici — una superficie de dependencias que juzgamos peor que el riesgo residual en una descarga de solo lectura que devuelve metadatos analizados. Prefiero nombrar un límite a fingir que no está. Una confesión más del mismo archivo: las peticiones salen con un user agent de Chrome 137, porque varios marketplaces bloquean sin más los UA por defecto de fetch. Es la mentira más educada del código base, y no funciona lo bastante a menudo.

El proxy de imágenes que no reenvía nada

Los CDN de subastas y marketplaces casi nunca envían Access-Control-Allow-Origin. Carga una de sus imágenes directamente en una textura WebGL y el canvas se contamina o la carga falla, así que el cliente pasa siempre por /api/museum/image: un segundo endpoint que descarga URLs, paranoico otra vez y en otra clave, con una barrera ^https?:// encima del tope de zod porque data: y file: no pueden acercarse jamás a un fetch.

Los tipos de contenido salen de una lista blanca: jpeg, png, webp, gif, avif, bmp. El SVG está ausente a propósito, y no por purismo del ráster: un SVG puede ejecutar script cuando se navega directamente a él, y un proxy que te sirve SVG del mismo origen es un servicio de entrega de XSS almacenado con pasos extra. Nada se transmite en streaming: el cuerpo se lee bajo un tope de 10 MB y se reemite como un Buffer nuevo con cabeceras que escribimos nosotros, sin reenviar nada de arriba salvo el tipo de contenido validado. Después nosniff, default-src 'none'; sandbox, CORP de mismo origen — y la línea de caché que al principio me salté, max-age=3600, s-maxage=86400, stale-while-revalidate=604800. Sin ella, cada readquisición volvía a descargar todas las texturas a través del proxy limitado por tasa.

Los sitios que se esconden de los robots

Algunas de las páginas que más gente quiere colgar en una pared pertenecen a sitios que no hablan con un servidor. eBay devuelve 403 en /itm/* incluso a curl llevando cabeceras de Chrome idénticas byte a byte — un muro de borde, no un fallo de nuestro fetch, y tardé un rato en demostrármelo a mí mismo antes de aceptarlo. LiveAuctioneers es más taimado: las páginas de categoría sirven HTML completo, mientras que la página de un artículo devuelve un desafío de Incapsula de 960 bytes con HTTP 200. Una comprobación por código de estado llama a eso un éxito y te mete un captcha analizado en el museo. Así que el olfateo tiene dos disparadores estrechos: HTML por debajo de 8 KB que contenga un marcador de desafío, o un 403/429 desde un dominio del que hemos confirmado que levanta muro.

const CHALLENGE = /Incapsula|DataDome|captcha-delivery|PerimeterX|px-captcha|_pxhd|cf_chl|challenge-platform/i;

Junto a un cuerpo de menos de 8 KB, estos marcadores significan que descargamos un portero en vez de una página. La detección devuelve 422 bot_walled: un rodeo señalizado, no un callejón sin salida.

Este sitio se esconde de los robots: le cerró la puerta a nuestro curador, pero tu navegador entra sin problema.

Ese es el texto que muestra el cliente, y también es el diseño. Tu navegador ya está al otro lado del muro. Selecciona todo, copia, pega, y toma el relevo una segunda ruta. /api/museum/parse-html no hace ninguna petición de red: la URL que aportas es atribución y base para los enlaces relativos, nunca se descarga. El HTML pegado no se almacena, no se renderiza y no se devuelve como marcado. Los topes de 4 MB de caracteres y 16 MB en el cable los aplica un lector limitado antes de que corra request.json(), porque analizar un cuerpo hostil para descubrir que es demasiado grande es la forma en que una sola petición te tumba.

Pega una página de catálogo entera y te llevas un ala entera — hasta treinta lotes, encontrados por una heurística estructural que ignora cualquier patrón repetido confinado a menos del 30 % del documento, ya que eso es mobiliario de la página y no la página. La entrada hostil recibe el mismo trato que las redes hostiles: el recorredor de JSON-LD es una cola recorrida por índice, sin spreads y con un techo de 20,000 nodos, porque shift() se vuelve cuadrático con una carga enorme y push(...hugeArray) desborda la pila.

Privacidad en localStorage: tu museo es tuyo

Hay una sola clave de almacenamiento, vaynerov:reliquary:v1, y vive en tu navegador. Sin cuenta, sin sincronización, sin una fila en una tabla con tu nombre. Un museo de las cosas con las que estás calladamente obsesionado no es asunto de nadie — y un producto sin datos de usuario no tiene datos de usuario que perder.

La ruta de lectura trata esa clave como entrada hostil, porque un visitante puede editarla a mano. Cada pieza tiene que presentar un id, una URL de origen, un dominio y un título o se descarta; las listas de imágenes se vuelven a filtrar desde cero; los duplicados colapsan por id y la colección se limita a 60. Las importaciones por lotes se montan en un solo objeto de estado, así que un catálogo de treinta lotes provoca una reconstrucción del plano y un guardado, en lugar de treinta de cada — y los ids que chocan reciben UUID nuevos, porque puede que un visitante quiera de verdad el mismo lote colgado dos veces.

Las imágenes se guardan como sus URLs remotas originales y se envuelven con el proxy solo en el momento de renderizar, de modo que un museo guardado se mantiene independiente de cómo descarguemos imágenes este mes. El coste honesto: si borras los datos del sitio, el edificio se vacía, y no te sigue al móvil. Volvería a hacer el mismo cambio.

El plano es un módulo puro: entran piezas, sale un plano de planta, en metros. Sin React, sin three.js. Emite un vestíbulo de 11 por 10 metros y una cadena de galerías que avanza hacia −Z, y sostiene un contrato por encima de todos los demás: añadir la pieza N+1 no mueve nunca las piezas 1 a N. Un museo que se reordena cuando le añades algo no es un museo, es un feed.

Todo lo que podría desplazarse se deriva. El tamaño del marco sale de un hash del id de la pieza más la propia relación de aspecto de la imagen, así que una recarga reproduce el museo idéntico desde localStorage hasta el centímetro. La capacidad de una sala es 6 + ((i × 3 + 2) % 5), y cada sala se construye con la longitud que necesita su capacidad completa, de modo que una sala que se va llenando no cambia de tamaño a tu alrededor. Los marcos alternan pared derecha, pared izquierda, con un paso de 3.4 m.

Determinista por construcción. Las salas se dimensionan para su capacidad completa antes de colgar el primer marco, así que la pieza sesenta encuentra la misma pared que habría encontrado en una carga nueva.

La luz fue la parte difícil. Hay exactamente un foco real en el edificio — intensidad 10, un #ffeccb cálido — y hace lerp de sala en sala según caminas, porque un salto de luz al cruzar una puerta se lee como un tirón del render y no como arquitectura. Cada foco por obra que crees ver es un cono de gradiente aditivo falso. Debajo: ambiente a 0.82, mapeo tonal ACES filmic con exposición 1.22 y una niebla exponencial marrón cálida para que la sala siguiente se lea como un pasillo en penumbra en vez de como un vacío negro.

Que no haya recursos binarios significa que cada superficie se dibuja con un canvas en tiempo de ejecución: paredes de yeso reutilizadas como su propio mapa de relieve, zócalo de nogal, pilastras de piedra en las jambas, marcos dorados. Contra todo ese ámbar, el turquesa aparece solo como bisturí: una lámina de portal apenas presente y una línea de rodapié con caída en ambos extremos, para que muera en las esquinas en lugar de rodear cada plano con una tubería de neón.

Te mueves a 3.2 m/s con mirada por arrastre y sin bloqueo de puntero, porque bloquear el puntero convierte hacer clic en un cuadro en una pelea. Haz clic en uno y la cámara retrocede clamp(longSide × 1.3, 1.5, 3.4) metros, desplazada medio metro hacia la derecha de la cámara para que la obra se lea despejada del panel de detalles.

Limitarnos la tasa a nosotros mismos, y otras lecciones

Entrar en un ala disparaba más de veinte peticiones de imagen simultáneas y chocaba de frente con el límite de tasa de nuestro propio proxy: mi servidor diciéndole a mi cliente que se calmara. El arreglo es un pequeño semáforo: cuatro cargas simultáneas, cada 60 ms. El paseo automático hasta una pieza recién colgada pasa por dos puntos de ruta por puerta, así que la cámara no atraviesa nunca una pared de camino a enseñarte algo.

La mejor tarde de trabajo fue la menos arquitectónica. Los CDN de los marketplaces sirven miniaturas por convención de URL, y las convenciones son adivinables: LiveAuctioneers acepta ?height=1600&quality=85, los tamaños s-l de eBay llegan hasta s-l1600, el il_NNNxNNN de Etsy se convierte en il_fullxfull. El mismo recurso, medido a mano: 21 KB antes, 412 KB después. Esa es la diferencia entre una mancha en la pared y algo delante de lo que merece la pena quedarse de pie.

El Reliquary salió en la misma release que el motor 3D compartido que hay detrás de el Laboratorio y que la expansión del sitio a seis idiomas — la interfaz del museo son 87 claves, y los seis idiomas llevan todas y cada una. Una cadena se queda en inglés a propósito: las letras sobre el arco del vestíbulo dicen VAYNEROV PROJECT RELIQUARY en todos los idiomas, igual que el nombre de un edificio no cambia cuando cambias tú.

Lo que no esperaba es cuánto trabajo de diseño hicieron las restricciones. La ausencia de dependencias forzó un parser que entiendo por completo. La ausencia de recursos binarios forzó materiales procedurales, que es la razón de que un museo entero se descargue con el peso de una página web. Salí a mirar un sextante de latón como es debido y acabé con un edificio.