Compare commits
No commits in common. "b51411a68b8d8f52f6397d5027957b876659e182" and "2323f8a466c29bf6552950deb20cd009fdce3fd9" have entirely different histories.
b51411a68b
...
2323f8a466
5 changed files with 91 additions and 84 deletions
|
@ -1,3 +0,0 @@
|
||||||
{
|
|
||||||
"printWidth": 100
|
|
||||||
}
|
|
|
@ -1,2 +1 @@
|
||||||
- [Deta Space](https://deta.space)
|
- [Deta Space](https://deta.space)
|
||||||
- [Fly.io](https://fly.io/)
|
|
||||||
|
|
|
@ -1,59 +0,0 @@
|
||||||
## Crear certificados no autorizados
|
|
||||||
|
|
||||||
Es posible crear certificados para "nombres" (dominios o IPs) que no te pertenecen de distintas maneras. De esta manera, podés interceptar conexiones y así romper TLS (la seguridad a nivel "transporte"), la base de toda la seguridad de la Web.
|
|
||||||
|
|
||||||
Pero ¿como? Primero hay que entender como funciona la "infraestructura de llaves pública" (PKI, Public Key Infrastructure).
|
|
||||||
|
|
||||||
Las "autoridades de certificados" son las organizaciones que verifican los dominios y otorgan certificados válidos a quienes le pertenecen. Los sistemas operativos y los navegadores confían en una lista de autoridades de distintos países y distintos estados (con y sin fines de lucros, etc). Si se duda la confianza de estas, se les saca de las listas y los certificados que otorgan paran de ser válidos (esto es importante más abajo :).
|
|
||||||
|
|
||||||
La forma que se verifica que "sos el nombre que decís" es distinta de cada autoridad. La forma más "moderna" y la que solemos usar es un sistema de verificación y otorgación **automático** llamado "[ACME]" (Automatic Certificate Management Environment). Esta fue desarrollada por Let's Encrypt, pero luego fue implementada por otros servicios. Acá solo voy a dar ejemplos de vulnerabilidades en este sistema, pero seguro existen otros en las otras autoridades con sus propios métodos.
|
|
||||||
|
|
||||||
ACME verifica a través de distintos "métodos", los más comúnes siendo `http-01` y `dns-01`.
|
|
||||||
|
|
||||||
1. `http-01` busca tu dominio en DNS **generalmente desde varios servidores**[[1]](https://letsencrypt.org/docs/challenge-types/) (esto es importante después) y hace un pedido al servidor en esa IP. Si todos los pedidos contestan con la llave compartida que te dijo ACME, estás verificadx, y obtenes un certificado.
|
|
||||||
2. `dns-01` busca tu dominio en DNS y busca la llave compartida en el registro de DNS mismo.
|
|
||||||
|
|
||||||
`dns-01` generalmente es más seguro porque no confía en la IP registrada en el DNS en si, sino solo en el registro mismo (del que confias de todas maneras en `http-01`).
|
|
||||||
|
|
||||||
Lo que pasa es que es posible interceptar conexiones entre los servidores de la autoridad y los del dominio verificado:
|
|
||||||
|
|
||||||
Por ejemplo, al momento de escribir esto nulo.ar está hosteado bajo mi conexión hogareña de Personal. Alguien dentro de Personal, o un hacker podría interceptar conexiones a mi dirección IP y hacer pedidos de certificados para nulo.ar. Esto sería posible sin tener que comprometer a NIC Argentina ni a Hurricane Electric, que manejan la cadena de DNS de mi dominio.
|
|
||||||
|
|
||||||
Sin ir a la conspiración, al ser una conexión hogareña Personal no me asigna una IP "estática" a mi, sino que es "dinámica". Esto significa que aleatoriamente, Personal puede decidir cambiarme de IP y darsela a otra persona. Tengo [un programa](https://gitea.nulo.in/Nulo/ddnser) que automáticamente actualiza el registro de DNS de nulo.ar para apuntar a la dirección actual, pero este puede fallar y de todas maneras corre cada ~5 minutos. En ese tiempo, lx otrx cliente que obtiene la IP podría hacer este mismo ataque.
|
|
||||||
|
|
||||||
Este es un ejemplo específico, pero es un ataque viable, y de hecho ya ejecutado. Abajo comento de un caso en el que se hace [BGP hijacking][BGP hijacking - Wikipedia] para interceptar las conexiones de un servidor, crear un certificado y insertar malware en el sitio para robar 1,9 millones de dolares.
|
|
||||||
|
|
||||||
Otro ataque es más "simple" pero requiere "una conspiración mayor" para ejecutar: que una autoridad de certificados comprometida cree un certificado bajo el nombre de nulo.ar sin verificación. Esto [ya](https://www.techrepublic.com/article/compromised-certificate-authorities-how-to-protect-yourself/) [pasó](https://blog.mozilla.org/security/2011/03/25/comodo-certificate-issue-follow-up/) [varias](https://www.mail-archive.com/dev-security-policy@lists.mozilla.org/msg05455.html) [veces](https://bugzilla.mozilla.org/show_bug.cgi?id=1496088), muchas veces por errores de la autoridad en su software. sslmate.com mantiene [una lista](https://sslmate.com/resources/certificate_authority_failures) con muchos incidentes de esta índole de las autoridades.
|
|
||||||
|
|
||||||
Previamente, la única forma de verificar que una autoridad había falsificado un certificado era obteniendo el certificado falsificado y compartiendolo. Esto es problematico porque un ataque teorico podría solo utilizar los certificados falsificados a las victimas a las que se quieren interceptar específicamente.
|
|
||||||
|
|
||||||
Sin embargo, hoy en día los navegadores (en Google Chrome desde 2018[[2]](https://groups.google.com/a/chromium.org/g/ct-policy/c/wHILiYf31DE/m/iMFmpMEkAQAJ)) requieren que los certificados estén registrados en un registro de transparencia ([Certificate Transparency - Wikipedia](https://en.wikipedia.org/wiki/Certificate_Transparency)). De esta manera, se puede inspeccionar estos registros públicos por certificados inusuales. [crt.sh](https://crt.sh/) es un sitio que permite acceder a estos registros fácilmente.
|
|
||||||
|
|
||||||
[ACME]: https://en.wikipedia.org/wiki/Automatic_Certificate_Management_Environment
|
|
||||||
|
|
||||||
### Casos
|
|
||||||
|
|
||||||
- > February 2022: Attackers hijacked BGP prefixes that belonged to a South Korean cryptocurrency platform, and then issued a certificate on the domain via ZeroSSL to serve a malicious JavaScript file, stealing $1.9 million worth of cryptocurrency.[[31]](https://therecord.media/klayswap-crypto-users-lose-funds-after-bgp-hijack)
|
|
||||||
|
|
||||||
de [BGP hijacking - Wikipedia](https://en.wikipedia.org/w/index.php?title=BGP_hijacking&oldid=1146362657)
|
|
||||||
|
|
||||||
[BGP hijacking - Wikipedia]: https://en.wikipedia.org/index.php?title=BGP_hijacking
|
|
||||||
|
|
||||||
### Soluciones o mitigaciones
|
|
||||||
|
|
||||||
#### Monitorear Certificate Transparency
|
|
||||||
|
|
||||||
Esto nos deja saber si se sacaron certificados inautorizados, pero no nos deja prevenirlos. Propuesta en Sutty: [Monitorear Certificate Transparency logs revisando que solo haya certificados nuestros](https://0xacab.org/sutty/sutty/-/issues/8880)
|
|
||||||
|
|
||||||
#### Restringir metodos de verificación via registros CAA
|
|
||||||
|
|
||||||
Gracias a [RFC8657] <small>Certification Authority Authorization (CAA) Record Extensions for Account URI and Automatic Certificate Management Environment (ACME) Method Binding</small>, podemos pedirles a los servidores de expedición de certificados que solo saquen certificados bajo ciertos proveedores y métodos. Esto nos permite limitar la expedición a un proveedor y al método `dns-01`. De esta manera, aún si pueden interceptar las conexiones a nuestra IP (via BGP hijacking o comprometiendo a nuestro ISP o...) no pueden generar certificados (tendrían que hackear nuestro DNS).
|
|
||||||
|
|
||||||
Let's Encrypt lo implementó en su entorno de prueba [en 2018] y en producción [en diciembre 2022]. Osea: ¡ya lo podemos usar!
|
|
||||||
|
|
||||||
[en 2018]: https://community.letsencrypt.org/t/acme-caa-validationmethods-support/63125
|
|
||||||
[en diciembre 2022]: https://community.letsencrypt.org/t/enabling-acme-caa-account-and-method-binding/189588
|
|
||||||
|
|
||||||
GrapheneOS [usa esto](https://github.com/GrapheneOS/ns1.grapheneos.org/blob/bc06ac067c5786180cceccdf8466b0a94a1a7e5c/zones.yaml#LL23C65-L23C65) pero usa http-01. Esto reduce las posibilidades de ataque (al menos solo tenés que confiar en Let's Encrypt) pero no es perfecto.
|
|
||||||
|
|
||||||
[RFC8657]: https://datatracker.ietf.org/doc/html/rfc8657
|
|
109
compilar.ts
109
compilar.ts
|
@ -1,5 +1,14 @@
|
||||||
import { copyFile, mkdir, opendir, readFile, readdir, writeFile } from "fs/promises";
|
import {
|
||||||
|
copyFile,
|
||||||
|
mkdir,
|
||||||
|
opendir,
|
||||||
|
readFile,
|
||||||
|
readdir,
|
||||||
|
writeFile,
|
||||||
|
} from "fs/promises";
|
||||||
import { basename, extname, join } from "path";
|
import { basename, extname, join } from "path";
|
||||||
|
import { execFile as execFileCallback } from "child_process";
|
||||||
|
import { promisify } from "util";
|
||||||
import * as commonmark from "commonmark";
|
import * as commonmark from "commonmark";
|
||||||
import {
|
import {
|
||||||
a,
|
a,
|
||||||
|
@ -25,6 +34,8 @@ import {
|
||||||
img,
|
img,
|
||||||
} from "@nulo/html.js";
|
} from "@nulo/html.js";
|
||||||
|
|
||||||
|
const execFile = promisify(execFileCallback);
|
||||||
|
|
||||||
const reader = new commonmark.Parser({ smart: true });
|
const reader = new commonmark.Parser({ smart: true });
|
||||||
const writer = new commonmark.HtmlRenderer({ safe: false, smart: true });
|
const writer = new commonmark.HtmlRenderer({ safe: false, smart: true });
|
||||||
|
|
||||||
|
@ -57,7 +68,19 @@ for (const entry of dir) {
|
||||||
const { name } = entry;
|
const { name } = entry;
|
||||||
const extension = extname(name);
|
const extension = extname(name);
|
||||||
|
|
||||||
if ([".ts", ".md", ".css", ".js", ".png", ".jpg", ".mp4", ".svg", ".html"].includes(extension)) {
|
if (
|
||||||
|
[
|
||||||
|
".ts",
|
||||||
|
".md",
|
||||||
|
".css",
|
||||||
|
".js",
|
||||||
|
".png",
|
||||||
|
".jpg",
|
||||||
|
".mp4",
|
||||||
|
".svg",
|
||||||
|
".html",
|
||||||
|
].includes(extension)
|
||||||
|
) {
|
||||||
await copyFile(join(config.sourcePath, name), join(config.buildPath, name));
|
await copyFile(join(config.sourcePath, name), join(config.buildPath, name));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -77,7 +100,10 @@ async function compilePage(config: Config, sourceFileName: string) {
|
||||||
|
|
||||||
let contentHtml, image;
|
let contentHtml, image;
|
||||||
if (extname(sourceFileName) === ".md") {
|
if (extname(sourceFileName) === ".md") {
|
||||||
({ html: contentHtml, image } = await compileMarkdownHtml(config, sourceFileName));
|
({ html: contentHtml, image } = await compileMarkdownHtml(
|
||||||
|
config,
|
||||||
|
sourceFileName
|
||||||
|
));
|
||||||
} else if (sourceFileName.endsWith(".gen.js"))
|
} else if (sourceFileName.endsWith(".gen.js"))
|
||||||
contentHtml = await compileJavascript(config, sourceFileName);
|
contentHtml = await compileJavascript(config, sourceFileName);
|
||||||
else throw false;
|
else throw false;
|
||||||
|
@ -86,7 +112,14 @@ async function compilePage(config: Config, sourceFileName: string) {
|
||||||
...generateHead(title, name),
|
...generateHead(title, name),
|
||||||
article(
|
article(
|
||||||
{ itemscope: "", itemtype: "https://schema.org/Article" },
|
{ itemscope: "", itemtype: "https://schema.org/Article" },
|
||||||
...(isIndex ? [] : generateHeader(name, sourceFileName, fileConnections.length > 0, image)),
|
...(isIndex
|
||||||
|
? []
|
||||||
|
: generateHeader(
|
||||||
|
name,
|
||||||
|
sourceFileName,
|
||||||
|
fileConnections.length > 0,
|
||||||
|
image
|
||||||
|
)),
|
||||||
main({ itemprop: "articleBody" }, raw(contentHtml)),
|
main({ itemprop: "articleBody" }, raw(contentHtml)),
|
||||||
...generateConnectionsSection(fileConnections)
|
...generateConnectionsSection(fileConnections)
|
||||||
)
|
)
|
||||||
|
@ -109,7 +142,10 @@ async function compileMarkdownHtml(
|
||||||
config: Config,
|
config: Config,
|
||||||
sourceFileName: string
|
sourceFileName: string
|
||||||
): Promise<{ html: string; image?: Image }> {
|
): Promise<{ html: string; image?: Image }> {
|
||||||
let markdown = await readFile(join(config.sourcePath, sourceFileName), "utf-8");
|
let markdown = await readFile(
|
||||||
|
join(config.sourcePath, sourceFileName),
|
||||||
|
"utf-8"
|
||||||
|
);
|
||||||
|
|
||||||
let image;
|
let image;
|
||||||
if (markdown.startsWith("!!")) {
|
if (markdown.startsWith("!!")) {
|
||||||
|
@ -117,7 +153,8 @@ async function compileMarkdownHtml(
|
||||||
const imageNode = node.firstChild?.firstChild;
|
const imageNode = node.firstChild?.firstChild;
|
||||||
if (!imageNode || !imageNode.destination)
|
if (!imageNode || !imageNode.destination)
|
||||||
throw new Error("Intenté parsear un ^!! pero no era una imágen");
|
throw new Error("Intenté parsear un ^!! pero no era una imágen");
|
||||||
if (!imageNode.firstChild?.literal) console.warn(`El ^!! de ${sourceFileName} no tiene alt`);
|
if (!imageNode.firstChild?.literal)
|
||||||
|
console.warn(`El ^!! de ${sourceFileName} no tiene alt`);
|
||||||
|
|
||||||
image = {
|
image = {
|
||||||
src: imageNode.destination,
|
src: imageNode.destination,
|
||||||
|
@ -134,7 +171,10 @@ async function compileMarkdownHtml(
|
||||||
return { html: contentHtml, image };
|
return { html: contentHtml, image };
|
||||||
}
|
}
|
||||||
|
|
||||||
async function compileJavascript(config: Config, sourceFileName: string): Promise<string> {
|
async function compileJavascript(
|
||||||
|
config: Config,
|
||||||
|
sourceFileName: string
|
||||||
|
): Promise<string> {
|
||||||
const fn = await import("./" + join(config.sourcePath, sourceFileName));
|
const fn = await import("./" + join(config.sourcePath, sourceFileName));
|
||||||
return await fn.default();
|
return await fn.default();
|
||||||
}
|
}
|
||||||
|
@ -152,13 +192,34 @@ function generateHead(titlee: string, outputName: string): Renderable[] {
|
||||||
name: "viewport",
|
name: "viewport",
|
||||||
content: "width=device-width, initial-scale=1.0",
|
content: "width=device-width, initial-scale=1.0",
|
||||||
}),
|
}),
|
||||||
meta({ name: "author", content: "Nulo" }),
|
meta({
|
||||||
meta({ property: "og:title", content: titlee }),
|
name: "author",
|
||||||
meta({ property: "og:type", content: "website" }),
|
content: "Nulo",
|
||||||
meta({ property: "og:url", content: `https://nulo.ar/${outputName}.html` }),
|
}),
|
||||||
meta({ property: "og:image", content: "cowboy.svg" }),
|
meta({
|
||||||
link({ rel: "stylesheet", href: "drip.css" }),
|
property: "og:title",
|
||||||
link({ rel: "icon", href: "cowboy.svg" }),
|
content: titlee,
|
||||||
|
}),
|
||||||
|
meta({
|
||||||
|
property: "og:type",
|
||||||
|
content: "website",
|
||||||
|
}),
|
||||||
|
meta({
|
||||||
|
property: "og:url",
|
||||||
|
content: `https://nulo.ar/${outputName}.html`,
|
||||||
|
}),
|
||||||
|
meta({
|
||||||
|
property: "og:image",
|
||||||
|
content: "cowboy.svg",
|
||||||
|
}),
|
||||||
|
link({
|
||||||
|
rel: "stylesheet",
|
||||||
|
href: "drip.css",
|
||||||
|
}),
|
||||||
|
link({
|
||||||
|
rel: "icon",
|
||||||
|
href: "cowboy.svg",
|
||||||
|
}),
|
||||||
title(titlee),
|
title(titlee),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
@ -178,7 +239,10 @@ interface Dateish {
|
||||||
day: number;
|
day: number;
|
||||||
}
|
}
|
||||||
function dateishToString({ year, month, day }: Dateish): string {
|
function dateishToString({ year, month, day }: Dateish): string {
|
||||||
return `${year}-${String(month).padStart(2, "0")}-${String(day).padStart(2, "0")}`;
|
return `${year}-${String(month).padStart(2, "0")}-${String(day).padStart(
|
||||||
|
2,
|
||||||
|
"0"
|
||||||
|
)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
type TitleMetadata =
|
type TitleMetadata =
|
||||||
|
@ -189,7 +253,8 @@ type TitleMetadata =
|
||||||
}
|
}
|
||||||
| { date: Dateish };
|
| { date: Dateish };
|
||||||
function parseName(name: string): TitleMetadata {
|
function parseName(name: string): TitleMetadata {
|
||||||
const titleWithDate = /^((?<year>\d{4})-(?<month>\d{2})-(?<day>\d{2}))? ?(?<title>.*)$/;
|
const titleWithDate =
|
||||||
|
/^((?<year>\d{4})-(?<month>\d{2})-(?<day>\d{2}))? ?(?<title>.*)$/;
|
||||||
|
|
||||||
const found = name.match(titleWithDate);
|
const found = name.match(titleWithDate);
|
||||||
if (!found || !found.groups) throw new Error("Algo raro pasó");
|
if (!found || !found.groups) throw new Error("Algo raro pasó");
|
||||||
|
@ -273,12 +338,16 @@ function generateHeader(
|
||||||
},
|
},
|
||||||
"Historial"
|
"Historial"
|
||||||
),
|
),
|
||||||
...(linkConexiones ? [" / ", a({ href: "#conexiones" }, "Conexiones")] : [])
|
...(linkConexiones
|
||||||
|
? [" / ", a({ href: "#conexiones" }, "Conexiones")]
|
||||||
|
: [])
|
||||||
),
|
),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
function generateConnectionsSection(fileConnections: Connection[]): Renderable[] {
|
function generateConnectionsSection(
|
||||||
|
fileConnections: Connection[]
|
||||||
|
): Renderable[] {
|
||||||
return fileConnections.length > 0
|
return fileConnections.length > 0
|
||||||
? [
|
? [
|
||||||
section(
|
section(
|
||||||
|
@ -339,7 +408,9 @@ async function hackilyTransformHtml(html: string): Promise<string> {
|
||||||
html = html
|
html = html
|
||||||
.replaceAll("<a h", '<a rel="noopener noreferrer" h')
|
.replaceAll("<a h", '<a rel="noopener noreferrer" h')
|
||||||
.replaceAll(wikilinkExp, (_, l) => render(internalLink(l)));
|
.replaceAll(wikilinkExp, (_, l) => render(internalLink(l)));
|
||||||
for (const [match, archivo] of html.matchAll(/<nulo-sitio-reemplazar-con archivo="(.+?)" \/>/g)) {
|
for (const [match, archivo] of html.matchAll(
|
||||||
|
/<nulo-sitio-reemplazar-con archivo="(.+?)" \/>/g
|
||||||
|
)) {
|
||||||
if (!archivo.endsWith(".gen.js")) throw false;
|
if (!archivo.endsWith(".gen.js")) throw false;
|
||||||
html = html.replace(match, await compileJavascript(config, archivo));
|
html = html.replace(match, await compileJavascript(config, archivo));
|
||||||
}
|
}
|
||||||
|
|
|
@ -7,7 +7,6 @@
|
||||||
"esModuleInterop": true,
|
"esModuleInterop": true,
|
||||||
"strict": true,
|
"strict": true,
|
||||||
"allowJs": true,
|
"allowJs": true,
|
||||||
"checkJs": true,
|
"checkJs": true
|
||||||
"noEmit": true
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
Reference in a new issue