diff --git a/README.md b/README.md index ea3978f..cecfb2e 100644 --- a/README.md +++ b/README.md @@ -8,9 +8,10 @@ uma versão nova do seu app, sozinho. ## O que tem aqui -- `main.py`: o app FastAPI — página, API (`POST/GET /api/links`) e o redirecionamento `GET /` (302). +- `main.py`: o app FastAPI — página, API (`POST/GET /api/links`), o aviso `GET /` e o redirecionamento `GET //ir` (302). - `links.py`: onde os links ficam guardados (na memória) e a validação dos endereços. - `paginas/index.html`: a página (HTML, CSS e o JavaScript do formulário). Mude à vontade. +- `paginas/aviso.html`: a página de aviso que mostra para onde o link leva, antes de sair. - `paginas/nao-encontrado.html`: a página de link que não existe. - `requirements.txt`: as dependências, com versão fixa. @@ -73,7 +74,10 @@ curl https:///api/links # 200 {"links":[...]} ``` -`GET /` redireciona (302) para o link original e soma um clique. A API aceita +`GET /` abre uma página de aviso com o endereço de destino e o botão "Continuar", +que leva a `GET //ir`: ali o app soma um clique e redireciona (302) para o link +original. O aviso existe porque o encurtador é público — sem ele, qualquer pessoa poderia +usar o endereço do seu app para disfarçar um link de golpe. A API aceita chamadas de qualquer site (CORS aberto só em `/api/*`), então outro app seu pode usá-la. Para evitar abuso, cada IP cria no máximo 30 links a cada 10 minutos. diff --git a/links.py b/links.py index e506bbd..f0df7d3 100644 --- a/links.py +++ b/links.py @@ -5,11 +5,15 @@ somem toda vez que o app reinicia ou é publicado de novo. Para guardar de vez, troque este arquivo por consultas a um Postgres. """ +import ipaddress import re import secrets +import unicodedata from datetime import datetime, timezone from urllib.parse import urlsplit, urlunsplit +import idna + ALFABETO = "abcdefghijklmnopqrstuvwxyz0123456789" TAMANHO_CODIGO = 6 TAMANHO_MAXIMO_URL = 2048 @@ -21,6 +25,20 @@ RESERVADOS = {"api", "docs"} FORMATO_CODIGO = re.compile(r"^[a-z0-9]{6}$") +LINK_INVALIDO = "Esse não parece um link válido. Use um endereço que comece com http:// ou https://." + +# Caracteres que o navegador troca por %XX em cada parte do link (conjuntos do +# padrão WHATWG URL, os mesmos do `new URL()` dos modelos Node). Além destes, +# todo caractere fora do ASCII visível (acentos, controle, bidi) vira %XX. +_CODIFICAR_NO_CAMINHO = frozenset(' "#<>?`{}') +_CODIFICAR_NA_CONSULTA = frozenset(" \"#<>'") +_CODIFICAR_NO_FRAGMENTO = frozenset(' "<>`') +_CODIFICAR_NO_USUARIO = _CODIFICAR_NO_CAMINHO | frozenset("/:;=@[\\]^|") + +# Último pedaço do host que o navegador lê como número (ex.: "1", "0x7f"). +_PARECE_NUMERO = re.compile(r"^(?:[0-9]+|0x[0-9a-f]*)$") +_OCTETO = re.compile(r"^(?:0|[1-9][0-9]{0,2})$") + # Dicionário do Python mantém a ordem de inserção: o primeiro é o mais antigo. _links: dict[str, dict] = {} @@ -42,29 +60,79 @@ def _validar_url(valor) -> str: texto = valor.strip() if len(texto) > TAMANHO_MAXIMO_URL: raise ErroDeValidacao(f"Esse link é comprido demais (máximo de {TAMANHO_MAXIMO_URL} caracteres).") + # Barra invertida o navegador lê como "/" (e "a.com\@b.com" abre a.com, não + # b.com); espaço e meio caractere UTF-16 também não entram. + if "\\" in texto or any(c.isspace() or 0xD800 <= ord(c) <= 0xDFFF for c in texto): + raise ErroDeValidacao(LINK_INVALIDO) try: partes = urlsplit(texto) porta = partes.port # porta inválida (ex.: ":abc") levanta ValueError except ValueError: partes = None - if not partes or partes.scheme not in ("http", "https") or not partes.hostname or any(c.isspace() for c in texto): - raise ErroDeValidacao("Esse não parece um link válido. Use um endereço que comece com http:// ou https://.") + if not partes or partes.scheme.lower() not in ("http", "https") or not partes.hostname: + raise ErroDeValidacao(LINK_INVALIDO) return _normalizar(partes, porta) +def _normalizar_host(host: str) -> str: + """O host exatamente como o navegador vai abrir — é ele que a página de aviso + mostra, então nada aqui pode ser ambíguo. Na dúvida, o link é recusado.""" + if ":" in host: # IPv6, que veio entre colchetes + try: + if "%" in host: + raise ValueError + return f"[{ipaddress.IPv6Address(host).compressed}]" + except ValueError: + raise ErroDeValidacao(LINK_INVALIDO) from None + host = host.lower() + # Só letras, números, "-" e ".": fica de fora %XX, controle, bidi e símbolos. + if any(unicodedata.category(c)[0] not in "LMN" and c not in "-." for c in host): + raise ErroDeValidacao(LINK_INVALIDO) + if not host.isascii(): # nome com acento → punycode (xn--...), como o navegador + try: + host = idna.encode(host, uts46=True, transitional=False).decode("ascii") + except (idna.IDNAError, UnicodeError): + raise ErroDeValidacao(LINK_INVALIDO) from None + # Host que termina em número o navegador lê como IPv4 ("0x7f.1" abre + # 127.0.0.1). Aceita só a forma de sempre: 4 números de 0 a 255. + rotulos = host.split(".") + ultimo = rotulos[-2] if len(rotulos) > 1 and rotulos[-1] == "" else rotulos[-1] + if _PARECE_NUMERO.match(ultimo): + if len(rotulos) != 4 or not all(_OCTETO.match(r) and int(r) <= 255 for r in rotulos): + raise ErroDeValidacao(LINK_INVALIDO) + return host + + +def _codificar(texto: str, conjunto: frozenset) -> str: + """Troca por %XX (UTF-8) o que o navegador trocaria; %XX que já vem fica.""" + return "".join( + c if 0x20 < ord(c) < 0x7F and c not in conjunto else "".join(f"%{b:02X}" for b in c.encode("utf-8")) + for c in texto + ) + + def _normalizar(partes, porta) -> str: """Mesma forma que o `new URL(...).href` dos modelos Node: esquema e host em - minúsculas, sem a porta padrão e com "/" quando o caminho vem vazio.""" + minúsculas (com acento vira punycode), sem a porta padrão, "/" quando o + caminho vem vazio e %XX onde o navegador também codifica.""" esquema = partes.scheme.lower() - host = partes.hostname.lower() - if ":" in host: # IPv6 volta entre colchetes - host = f"[{host}]" + host = _normalizar_host(partes.hostname) if porta is not None and porta != {"http": 80, "https": 443}[esquema]: host = f"{host}:{porta}" if partes.username is not None: - credenciais = partes.username + (f":{partes.password}" if partes.password is not None else "") + credenciais = _codificar(partes.username, _CODIFICAR_NO_USUARIO) + if partes.password is not None: + credenciais += ":" + _codificar(partes.password, _CODIFICAR_NO_USUARIO) host = f"{credenciais}@{host}" - return urlunsplit((esquema, host, partes.path or "/", partes.query, partes.fragment)) + return urlunsplit( + ( + esquema, + host, + _codificar(partes.path or "/", _CODIFICAR_NO_CAMINHO), + _codificar(partes.query, _CODIFICAR_NA_CONSULTA), + _codificar(partes.fragment, _CODIFICAR_NO_FRAGMENTO), + ) + ) def criar_link(valor) -> dict: @@ -85,6 +153,23 @@ def listar_links() -> list[dict]: return list(reversed(list(_links.values())[-LINKS_NA_LISTA:])) +def buscar_link(codigo: str) -> dict | None: + """O link do código, ou None se ele não existe (não soma clique).""" + return _links.get(codigo) + + +def host_do_link(url: str) -> str: + """O host de um link já guardado (normalizado na criação): sem usuário/senha + e com a porta só quando não é a padrão — igual ao `new URL(url).host` do Node.""" + partes = urlsplit(url) + host = partes.hostname or "" + if ":" in host: # IPv6 volta entre colchetes + host = f"[{host}]" + if partes.port is not None: + host = f"{host}:{partes.port}" + return host + + def registrar_clique(codigo: str) -> dict | None: """Soma um clique e devolve o link, ou None se o código não existe.""" link = _links.get(codigo) diff --git a/main.py b/main.py index 9013c5b..09f14ec 100644 --- a/main.py +++ b/main.py @@ -7,6 +7,7 @@ import html import json import os import platform +import re import time from datetime import datetime, timedelta, timezone from pathlib import Path @@ -15,7 +16,7 @@ import fastapi from fastapi import FastAPI, Request from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse, Response -from links import FORMATO_CODIGO, ErroDeValidacao, criar_link, listar_links, registrar_clique +from links import FORMATO_CODIGO, ErroDeValidacao, buscar_link, criar_link, host_do_link, listar_links, registrar_clique # Variável de ambiente de exemplo: na Dado, ela se configura no painel do app. MENSAGEM = os.environ.get("MENSAGEM_BOAS_VINDAS") or "Defina a variável MENSAGEM_BOAS_VINDAS para trocar este texto." @@ -36,6 +37,19 @@ PAGINA_INICIAL = ( .replace("{{MENSAGEM}}", html.escape(MENSAGEM)) ) PAGINA_NAO_ENCONTRADA = (PASTA_PAGINAS / "nao-encontrado.html").read_text(encoding="utf-8") +MODELO_AVISO = (PASTA_PAGINAS / "aviso.html").read_text(encoding="utf-8") +# A página de aviso não roda script nenhum: o navegador só aceita o CSS dela. +CSP_AVISO = "default-src 'none'; style-src 'unsafe-inline'; img-src data:; frame-ancestors 'none'; base-uri 'none'; form-action 'none'" +MARCADORES_AVISO = re.compile(r"\{\{(HOST|URL|CODIGO)\}\}") + + +def pagina_aviso(link: dict) -> str: + """Página de aviso antes de sair: mostra para onde o link leva. Todo valor é + escapado (o link foi digitado por alguém) e entra numa passada só, para um + valor nunca ser lido como outro marcador.""" + valores = {"HOST": host_do_link(link["url"]), "URL": link["url"], "CODIGO": link["codigo"]} + return MARCADORES_AVISO.sub(lambda m: html.escape(valores[m.group(1)]), MODELO_AVISO) + # Freio simples contra abuso: no máximo 30 links novos a cada 10 minutos por IP. LIMITE_POR_IP = 30 @@ -171,14 +185,36 @@ async def criar(request: Request): return JSONResponse(status_code=201, content=para_json(link, request)) -# O redirecionamento é feito pelo servidor: GET / → 302 para o link. -# Fica por último: as rotas fixas acima têm prioridade. +# GET / não redireciona direto: mostra antes uma página de aviso com o +# destino. Sem ela, qualquer pessoa usaria o seu endereço para disfarçar um +# link de golpe. A página não conta clique — quem só olha não foi ao destino. +# Estas rotas ficam por último: as rotas fixas acima têm prioridade. @app.get("/{codigo}", include_in_schema=False) +def aviso(codigo: str): + link = buscar_link(codigo) if FORMATO_CODIGO.match(codigo) else None + if link is None: + return HTMLResponse(PAGINA_NAO_ENCONTRADA, status_code=404) + return HTMLResponse( + pagina_aviso(link), + headers={ + "Cache-Control": "no-store", + "Referrer-Policy": "no-referrer", + "X-Robots-Tag": "noindex, nofollow", + "Content-Security-Policy": CSP_AVISO, + }, + ) + + +# O botão "Continuar" leva para GET //ir: aqui o servidor soma o +# clique e redireciona (302) para o link. +@app.get("/{codigo}/ir", include_in_schema=False) def redirecionar(codigo: str): link = registrar_clique(codigo) if FORMATO_CODIGO.match(codigo) else None if link is None: return HTMLResponse(PAGINA_NAO_ENCONTRADA, status_code=404) - return RedirectResponse(link["url"], status_code=302, headers={"Cache-Control": "no-store"}) + return RedirectResponse( + link["url"], status_code=302, headers={"Cache-Control": "no-store", "Referrer-Policy": "no-referrer"} + ) if __name__ == "__main__": diff --git a/paginas/aviso.html b/paginas/aviso.html new file mode 100644 index 0000000..2f21fc1 --- /dev/null +++ b/paginas/aviso.html @@ -0,0 +1,111 @@ + + + + + + + + Você está saindo deste site + + + + +
+
+ +

Você está saindo deste site

+
+

Este link curto leva para outro endereço.

+ +
+

Você está indo para

+

{{HOST}}

+

{{URL}}

+

+ Confira se o endereço é o que você esperava. Este encurtador é público: qualquer pessoa pode criar links nele. +

+ +
+ + +
+ + diff --git a/requirements.txt b/requirements.txt index 9ad2ce5..cba294e 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,2 +1,3 @@ fastapi==0.156.8 +idna==3.20 uvicorn==0.54.0