Съдържание на курса
Урок 2 — Клиентът сгреши (I): 400, 401, 403, 404, 405 и 415
От Dorian Chávez · основател и главен изпълнителен директор на Hábil ·
02:10 ч. във вторник е и revisor е в продукция от три седмици, отворен за всеки, който достигне до порта му. Тази нощ някой от екипа по заплатите е написал скрипт, за да регистрира своите услуги, и го е оставил да работи. Скриптът изпраща данните като формуляр, а не като JSON, и revisor му отговаря с 400 на всеки опит, със същия ред както винаги. Скриптът гледа само кода, а за него един 400 си е 400: не може да разчете изречението в тялото, затова изпраща отново същото. Междувременно, в друг терминал, един стажант открива, че с произволен DELETE може да премахне услугата за разплащания на друг екип, защото revisor не пита никого кой е. А в трети терминал скрипт, който никой не разпознава, обхожда /servicios/1, /servicios/2, /servicios/3… и си отбелязва при кой отговорът е различен. В 02:40 вече има пълния списък на онова, което компанията наблюдава. Три различни проблема, а API-то се справя с всички тях с два кода и една входна точка, която не съществува.
Този урок изгражда тази входна точка: седем въпроса, винаги в един и същ ред, и различен код за всеки отговор.
Време: 90 мин или 2 × 45 мин (разделянето е между раздели 2.4 и 2.5)
Какво изграждаш: входната точка на API-то на revisor: маршрут, метод, идентификационни данни, разрешение, съществуване, тип на съдържанието и четене на тялото, с различен код за всеки неуспех; и малка програма, която се опитва да отгатне кои услуги съществуват, за да провериш, че входната точка не ѝ го позволява
Какво научаваш: седемте въпроса на входната точка и защо вървят в този ред: път → метод → идентификационни данни → разрешение → съществуване → формат → четене; 401 срещу 403; 404 срещу 405; 415 и 413 срещу 400; WWW-Authenticate и Allow, двете заглавки, които спецификацията прави задължителни; и кога 404 нарочно скрива ресурс, който съществува, с карето за сигурност за изброяването на ресурси
Откъде идваш. От Урок 1, с revisor, който регистрира, проверява, премахва и инспектира услуги и пренасочва старите си маршрути с 301 и 308. Този revisor вече връща две грешки, 400 и 404, но Урок 1 ги използва като предпазна мрежа и ти обеща да ги обясни тук. Има и три дупки, които този урок затваря: не знае кой му говори, не различава маршрут, който не съществува, от метод, който не поддържа, и приема всяко тяло, което прилича на JSON, независимо с какъв тип идва.
Какво не прави този урок. Не преподава истинска автентикация: идентификационните данни са четири думи, записани в кода, а в истинско API идват от доставчик на идентичност, с токени, които изтичат и се подписват (README обяснява защо курсът оставя тази тема настрана). Не преподава 422: тук всичко от тялото, което не може да се използва, получава 400, а Урок 3 разделя онова, което не може да се прочете, от онова, което се прочита, но не може да се изпълни. И телата на грешките остават един ред текст; Урок 5 ги заменя с application/problem+json.
За изпълненията. Linux Mint 22 е референтната система за курса. Изходите в този урок са получени на 21 октомври 2026 г. на macOS с Node 24.14.1 и curl 8.7.1, и нито един не зависи от нещо специфично за macOS. При твоето изпълнение ще се различават две неща: датата в заглавката Date и последните осем цифри на всеки ETag ("3-1-80925024" в изпълнението от урока), които revisor генерира на случаен принцип при всяко стартиране; раздел 2.7.4 обяснява защо, а когато дадена заявка се нуждае от етикета, копирай онзи, който си получил ти в предишния отговор. revisor пази всичко в паметта: за да получиш същото като в урока, стартирай го наново преди всяка серия от заявки, защото предишна регистрация или премахване променя отговорите. Програмите са в programas/02-errores-del-cliente-peticion/, в хранилището на курса: revisor е в fig02_01/, програмата за изброяване — в fig02_02.ts, счупените версии от „Грешката, която ще видиш“ — в defectos/, а всяко изпълнение — в своя файл .salida.txt. Всички минават tsc в строг режим с командата от раздел 0.9 на Урок 0 плюс опцията --allowImportingTsExtensions, която е необходима, откакто revisor има няколко файла, които се импортират взаимно със своето разширение .ts.
След урока ще можеш да
- Изреждаш седемте въпроса на входната точка по ред и да предвиждаш за дадена заявка при кой от тях ще спре и с какъв код ще отговори.
- Различаваш с изпълнение
401от403и404от405, и да казваш коя заглавка изисква всеки от тях. - Пишеш заглавката
WWW-Authenticateна401и заглавкатаAllowна405, и да цитираш раздела от RFC 9110, който ги прави задължителни. - Отхвърляш тяло с грешен тип с
415иAccept, а тяло, което не може да се прочете — с400. - Доказваш с програма, че API-то не разкрива кои услуги на друг екип съществуват, и да обясняваш каква промяна в кода би била достатъчна, за да ги разкрие.
- Обясняваш защо таблица с идентификационни данни, написана като JavaScript обект, може да приеме идентификационни данни, които никой не е регистрирал.
Защо, преди как
Грешка на клиента е код 4xx, а Урок 0 ти даде въпроса, на който отговаря това семейство: кой сгреши? Онзи, който е поискал. Но „онзи, който е поискал, е сгрешил“ не помага особено на онзи, който е поискал. Той трябва да знае какво е направил погрешно, защото всяка грешка се поправя по различен начин:
| Ако проблемът е… | Онзи, който е поискал, трябва да… | Код |
|---|---|---|
| маршрутът не съществува | провери адреса | 404 |
| маршрутът съществува, но не с този метод | използва друг метод, от тези в Allow |
405 |
| не е казал кой е или го е казал грешно | се автентикира | 401 |
| казал е кой е, но няма право | поиска разрешение от някого или се откаже | 403 |
| ресурсът не съществува (за него) | спре да го търси | 404 |
| изпратил е тялото във формат, който не се приема | смени формата с посочения в Accept |
415 |
| тялото не може да се прочете | поправи тялото | 400 |
Всеки ред е различно действие и почти винаги го изпълнява програма, а не човек. Скриптът от сцената в началото опитваше отново, защото за една програма 400 си е 400: тялото казваше „el cuerpo no es JSON“ („тялото не е JSON“), но програмата не тълкува изречения, а кодът беше същият като за всяко друго невалидно тяло. С 415 и Accept: application/json кодът щеше да му каже, че проблемът е във формата и кой формат да използва. (А и скриптът беше написан зле: нито един добре написан клиент не повтаря без промени заявка, получила 4xx, защото това семейство казва, че промяна трябва да направи онзи, който е поискал.) Добре избраният код казва на клиента какво да направи — това е вторият принцип на курса, а Урок 7 превръща тази таблица в програмата, която взема решението.
Таблицата има и друго свойство, по-малко видимо и по-важно: подредена е. Не е едно и също първо да попиташ „кой си?“ и после „съществува ли?“, или обратното. Ако revisor каже на непознат „тази услуга не съществува“, преди да го попита кой е, вече му е казал нещо. Ако му каже „тази услуга е на друг екип“, му е казал, че съществува. Редът на въпросите решава какво научава за твоето API онзи, който не би трябвало да научи нищо, и затова този урок му отделя толкова място.
Урокът има четири части:
- Входната точка (2.1): седемте въпроса и защо са в този ред.
- Четирите въпроса на входната точка (2.2 до 2.4): маршрут и метод (
404,405), идентификационни данни (401) и разрешение (403). - Трите въпроса на манипулатора (2.5 и 2.6): съществуване (
404, онзи, който скрива) и формат и четене (415,400), с карето за сигурност. - Пълният
revisor(2.7): файловете и изпълнението на всички случаи.
Понятията
2.1 Входната точка: седем въпроса по ред
2.1.1 Въпросите
Започни с две заявки, които вече познаваш от сцената в началото: една без идентификационни данни и друга с метод, който маршрутът не поддържа.
$ curl -i http://127.0.0.1:18080/servicios
HTTP/1.1 401 Unauthorized
WWW-Authenticate: Bearer realm="revisor"
Content-Type: text/plain; charset=utf-8
Content-Length: 20
Date: Wed, 21 Oct 2026 13:18:28 GMT
Connection: keep-alive
Keep-Alive: timeout=5
falta la credencial
$ curl -i -X PATCH http://127.0.0.1:18080/servicios/1 -H 'Authorization: Bearer escritura-a'
HTTP/1.1 405 Method Not Allowed
Allow: GET, HEAD, DELETE
Content-Type: text/plain; charset=utf-8
Content-Length: 35
Date: Wed, 21 Oct 2026 13:18:28 GMT
Connection: keep-alive
Keep-Alive: timeout=5
PATCH no se admite en /servicios/1
Две заявки, два различни кода: всяка е спряла при различен въпрос. Ето ги и седемте:
Когато заявка пристигне в revisor, преди да се направи каквото и да е с нея, тя минава през поредица от въпроси. Всеки има отговор „да“ или „не“, а едно „не“ спира заявката в тази точка със свой собствен код:
| # | Въпрос | Ако отговорът е „не“ | Къде се отговаря |
|---|---|---|---|
| 1 | Съществува ли маршрутът? | 404 |
rutas.ts |
| 2 | Поддържа ли маршрутът този метод? | 405 + Allow |
rutas.ts |
| 3 | Кой си? | 401 + WWW-Authenticate |
rutas.ts |
| 4 | Можеш ли да направиш това? | 403 |
rutas.ts |
| 5 | Съществува ли ресурсът — за теб? | 404 |
манипулаторът, в servicios.ts |
| 6 | Идва ли тялото във формат, който приемам? | 415 + Accept |
манипулаторът |
| 7 | Мога ли да го прочета? | 400 или 413, ако надхвърля ограничението за размер |
манипулаторът |
Казано на един ред: маршрут → метод → идентификационни данни → разрешение → съществуване → формат → четене. Седем въпроса и седем стъпки. На първите четири отговаря входната точка, в rutas.ts, по един и същ начин за всички маршрути. Последните три зависят от онова, което иска всеки маршрут, и затова на тях отговаря манипулаторът: само POST носи тяло и само маршрут с {id} сочи към ресурс, който може да не съществува. Заявка, която премине и седемте, стига до своята работа и връща успешен отговор от тези, които видя в Урок 1.
Преди първия въпрос има стъпка, която вече познаваш: наследените маршрути. /viejo/servicios и /api/servicios се пренасочват с 301 и 308, без да се пита нищо повече, защото пренасочването не предава никакви данни: само казва къде е новият маршрут, и там започват въпросите. Уроци 3 и 4 добавят още две спирки на същото място, преди всичко останало (изведената от поддръжка версия на API-то и режима на поддръжка), и нито една не променя реда на седемте.
2.1.2 Защо в този ред
Редът не е произволен, но не е и единственият възможен. Произтича от три правила, които си струва да имаш предвид, защото тях ще използваш, когато проектираш своя собствена входна точка.
Първо правило: на въпрос не може да се отговори без предишните. Не можеш да кажеш дали даден метод се поддържа, без да знаеш за кой маршрут става дума; не можеш да кажеш дали някой може да направи нещо, без да знаеш кой е; не можеш да прочетеш тяло, без да знаеш дали е във формат, който разбираш. Само това правило определя голяма част от реда: маршрут преди метод, идентификационни данни преди разрешение, формат преди четене.
Второ правило: който не се е идентифицирал, не научава нищо от данните. Идентификационните данни са преди съществуването. Ако беше обратното, непознат би могъл да поиска /servicios/1, /servicios/2 и /servicios/3 и да разбере по разликата между 404 и каквото и да е, което се връща след това, кои от тях съществуват. С идентификационните данни на първо място непознатият получава 401 на всички, независимо дали услугата съществува. Същото важи и за тялото: ако revisor валидираше тялото, преди да поиска идентификационни данни, всеки би могъл да научи с помощта на 400 какви полета очаква регистрацията и какви стойности приема, без изобщо да се е идентифицирал. А освен това сървърът би хабил работа, за да чете тела от някого, който няма право да ги изпраща.
Трето правило: разрешението, което зависи от ролята, е преди съществуването. Читател не може да изтрие нищо, независимо дали то съществува. Затова 403 идва преди въпроса за ресурса и отговорът на „читател се опитва да изтрие услуга 99“ е 403, а не 404, въпреки че 99 не съществува. Така 403 не казва нищо за това какво съществува: зависи само от идентификационните данни и от метода, а не от идентификатора. Ще го видиш с изпълнение в раздел 2.4.
Остава едно решение, което не произтича от никое правило и което си струва да назовем: маршрутът и методът са преди идентификационните данни. revisor отговаря с 404 на маршрут, който не съществува, и с 405 на метод, който не се поддържа, дори на онзи, който не е казал кой е. Така разкрива на непознат какви маршрути и какви методи има API-то. revisor го прави нарочно, защото тази информация вече е публична: тя е в документацията му, а в Урок 8 ще бъде в описание OpenAPI, което всеки може да прочете. Онова, което входната точка скрива, са данните, а не формата на API-то. Има API-та, които решават иначе и отговарят с 401 на всичко, което пристига без идентификационни данни, дори на маршрут, който не съществува; това е защитимо решение, когато и формата на API-то е тайна. Никоя спецификация не задължава едното или другото: това е проектантско решение, а не норма, а задължително е да го запишеш и да го прилагаш еднакво за всички маршрути.
2.1.3 Кодът на входната точка
Входната точка живее във функцията atender в rutas.ts, която в Урок 1 беше цикъл от шест реда. Сега в нея са записани първите четири въпроса един след друг, номерирани както в таблицата. Пълният файл е в раздел 2.7; тук е функцията, за да я четеш с таблицата до нея:
export async function atender(peticion: IncomingMessage, respuesta: ServerResponse): Promise<void> {
console.log(`${peticion.method} ${peticion.url}`);
const url = new URL(peticion.url ?? "/", "http://revisor");
if (redirigir(url.pathname, url.search, respuesta)) return;
// 1. ¿Existe la ruta?
let ruta: Ruta | undefined;
let parametros: string[] = [];
for (const candidata of RUTAS) {
const encontrada = candidata.patron.exec(url.pathname);
if (encontrada !== null) {
ruta = candidata;
parametros = encontrada.slice(1);
break;
}
}
if (ruta === undefined) {
fallar(respuesta, 404, "no existe esa ruta");
return;
}
// 2. ¿Admite este método?
const nombreMetodo = peticion.method === "HEAD" ? "GET" : (peticion.method ?? "GET");
// Object.hasOwn y no ruta.metodos[nombreMetodo] a secas: el método lo escribe el cliente, y un objeto
// también «tiene» lo que hereda de Object.prototype (constructor, toString…). Igual que las credenciales.
const metodo = Object.hasOwn(ruta.metodos, nombreMetodo) ? ruta.metodos[nombreMetodo] : undefined;
if (metodo === undefined) {
fallar(respuesta, 405, `${peticion.method} no se admite en ${url.pathname}`, { Allow: permitidos(ruta) });
return;
}
// 3. ¿Quién eres?
const identificacion = identificar(peticion);
if (identificacion.tipo !== "valida") {
const detalle = motivo(identificacion);
fallar(respuesta, 401, detalle, { "WWW-Authenticate": reto(identificacion) });
return;
}
// 4. ¿Puedes?
if (!puede(identificacion.credencial, metodo)) {
fallar(respuesta, 403, "tu credencial solo puede leer; para escribir pide una con rol de escritor");
return;
}
// 5, 6 y 7. ¿Existe el recurso? ¿En qué formato viene? ¿Se puede leer? Lo decide cada manejador.
await metodo.manejador({ peticion, respuesta, parametros, credencial: identificacion.credencial });
}
Чете се отгоре надолу и всеки блок завършва с return, ако отговорът е „не“. Първият въпрос търси маршрута по неговия шаблон, без да гледа метода: това е разликата с Урок 1, където маршрутът и методът се търсеха заедно и грешен метод не намираше нищо и попадаше в 404. Разделянето им е онова, което позволява да се отговори с 405. Вторият въпрос търси метода в намерения маршрут, като третира HEAD като GET, и го търси с Object.hasOwn по причина, която раздел 2.3.3 обяснява. Третият и четвъртият използват autenticacion.ts, който е нов. А накрая входната точка извиква манипулатора и му предава идентификационните данни, за да може манипулаторът да отговори на трите въпроса, които са негови.
Обърни внимание как е декларирана таблицата с маршрути: всеки метод казва дали чете или записва.
type Manejador = (contexto: Contexto) => void | Promise<void>;
// Cada método dice qué función lo atiende y qué rol mínimo exige.
type Metodo = { manejador: Manejador; escribe: boolean };
type Ruta = { patron: RegExp; metodos: Record<string, Metodo> };
const lee = (manejador: Manejador): Metodo => ({ manejador, escribe: false });
const escribe = (manejador: Manejador): Metodo => ({ manejador, escribe: true });
const RUTAS: Ruta[] = [
{ patron: /^\/servicios$/, metodos: { GET: lee(listar), POST: escribe(darDeAlta) } },
{ patron: /^\/servicios\/(\d+)$/, metodos: { GET: lee(consultar), DELETE: escribe(retirar) } },
{ patron: /^\/servicios\/(\d+)\/revisiones$/, metodos: { POST: escribe(pedirRevision) } },
{ patron: /^\/revisiones\/(\d+)$/, metodos: { GET: lee(consultarRevision) } },
];
lee и escribe са две функции от по един ред, които обвиват манипулатора с тази маркировка. Маркировката е онова, което използва въпрос 4: читател може да използва методите, които четат, но не и тези, които записват. Това е умишлено малък модел на разрешения, с три роли, но има свойството, което има значение: разрешението се декларира заедно с маршрута, на същия ред, а не е скрито във всеки манипулатор, където е лесно да бъде забравено в някой от тях.
2.2 Маршрут и метод: 404 и 405
2.2.1 Маршрут, който не съществува
Първият въпрос е най-простият. Ако никой в таблицата не разпознае маршрута, отговорът е 404 Not Found. RFC 9110 го дефинира в раздел 15.5.5: сървърът не е намерил текущо представяне на поискания ресурс или не желае да разкрие, че той съществува. Запомни втората половина на това изречение; ще ти потрябва в раздел 2.5.
$ curl -i http://127.0.0.1:18080/servicioss -H 'Authorization: Bearer escritura-a'
HTTP/1.1 404 Not Found
Content-Type: text/plain; charset=utf-8
Content-Length: 19
Date: Wed, 21 Oct 2026 13:18:28 GMT
Connection: keep-alive
Keep-Alive: timeout=5
no existe esa ruta
/servicioss, с една „s“ в повече, не е в таблицата и входната точка спира при въпрос 1. Забележи, че заявката носеше валидни идентификационни данни, и това нямаше значение: входната точка все още не е стигнала дотам да ги погледне.
2.2.2 Метод, който маршрутът не поддържа
Вторият въпрос е онзи, на който revisor от Урок 1 не можеше да отговори. /servicios/1 съществува, но поддържа само GET (за проверка) и DELETE (за премахване). Ако му изпратиш PATCH:
$ curl -i -X PATCH http://127.0.0.1:18080/servicios/1 -H 'Authorization: Bearer escritura-a'
HTTP/1.1 405 Method Not Allowed
Allow: GET, HEAD, DELETE
Content-Type: text/plain; charset=utf-8
Content-Length: 35
Date: Wed, 21 Oct 2026 13:18:28 GMT
Connection: keep-alive
Keep-Alive: timeout=5
PATCH no se admite en /servicios/1
405 Method Not Allowed. RFC 9110 го дефинира в раздел 15.5.6: сървърът познава метода, но ресурсът не го поддържа. И в същия раздел поставя първата от двете задължителни заглавки на този урок: в 405 сървърът трябва (MUST) да изпрати заглавка Allow със списъка от методи, които ресурсът поддържа. Раздел 10.2.1, който дефинира Allow, го повтаря и добавя, че тя може да се изпраща във всеки друг отговор, но в 405 е задължителна.
Защо задължителна? Защото без нея 405 не казва какво да се направи. „Не този метод“ оставя клиента да пробва методи на сляпо; „не този метод, а тези“ му дава пълната поправка. Allow: GET, HEAD, DELETE казва точно какво можеш да правиш с /servicios/1. Функцията permitidos в rutas.ts я сглобява от методите в таблицата и добавя HEAD веднага след GET, защото всеки маршрут, който поддържа GET, поддържа и HEAD (Node не изпраща тялото; видя го в Урок 0).
Това е грешката, която revisor от Урок 1 правеше нарочно: на същия този PATCH отговаряше с 404 no existe esa ruta. Това беше лъжа, и то скъпа: клиент, който получи 404, заключава, че адресът е грешен, и започва да търси друг, докато адресът е бил правилен и е трябвало да се смени методът.
2.2.3 405 срещу 501
Има съседен код, който си струва да се разграничи: 501 Not Implemented. Раздел 9.1 на RFC 9110 прокарва границата: ако сървърът не разпознава или не имплементира метода никъде, би трябвало да отговори с 501; ако го разпознава и имплементира, но не за този ресурс, би трябвало да отговори с 405. revisor отговаря с 405 на всеки метод, който Node познава и който маршрутът не поддържа, като PATCH или PUT. До измислен метод, като BORRAR, дори не се стига: HTTP анализаторът (parser) на Node го отхвърля по-рано с 400 Bad Request без тяло (ще го видиш с -X constructor в раздел 2.3.3). Според RFC там би трябвало да има 501, но това решение го взема Node, а не revisor. 501 не е главно действащо лице в този курс: лабораторията го връща на /codigo/501, ако искаш да го видиш.
Една тънкост, която си струва да провериш със собствените си ръце: маршрутът за инспекции, /servicios/{id}/revisiones, поддържа само POST. Какво става, ако му направиш HEAD?
$ curl -i -I http://127.0.0.1:18080/servicios/1/revisiones
HTTP/1.1 405 Method Not Allowed
Allow: POST
Content-Type: text/plain; charset=utf-8
Content-Length: 45
Date: Wed, 21 Oct 2026 13:18:28 GMT
Connection: keep-alive
Keep-Alive: timeout=5
405 с Allow: POST. HEAD се поддържа само там, където се поддържа GET, а този маршрут няма GET. И заявката не носеше идентификационни данни: входната точка спря при въпрос 2, без да стигне до 3, както повелява редът.
2.3 Кой си?: 401 и WWW-Authenticate
2.3.1 Липсващи идентификационни данни, невалидни идентификационни данни
Третият въпрос изисква клиентът да каже кой е. В HTTP това става със заглавката Authorization, а revisor използва най-разпространената схема в API-тата: Bearer, дефинирана в RFC 6750. Заявката изпраща Authorization: Bearer <идентификационни данни>, и който притежава идентификационните данни (който ги „носи“ — оттам и името), е този, за когото се представя. Точно затова в истинско API идентификационни данни Bearer се пренасят само криптирани, по https: RFC 6750 в своя раздел 5.3 изисква клиентите винаги да използват TLS, защото който ги прочете по пътя, може да ги използва. Курсът използва http само защото всичко се случва в твоята машина, на 127.0.0.1, и нищо не излиза в мрежата. Идентификационните данни в курса са четири, записани в autenticacion.ts:
| Идентификационни данни | Екип | Роля |
|---|---|---|
lectura-a |
a | читател |
escritura-a |
a | редактор |
escritura-b |
b | редактор |
admin |
всички | администратор |
Без идентификационни данни:
$ curl -i http://127.0.0.1:18080/servicios
HTTP/1.1 401 Unauthorized
WWW-Authenticate: Bearer realm="revisor"
Content-Type: text/plain; charset=utf-8
Content-Length: 20
Date: Wed, 21 Oct 2026 13:18:28 GMT
Connection: keep-alive
Keep-Alive: timeout=5
falta la credencial
С идентификационни данни, които не съществуват:
$ curl -i http://127.0.0.1:18080/servicios -H 'Authorization: Bearer inventada'
HTTP/1.1 401 Unauthorized
WWW-Authenticate: Bearer realm="revisor", error="invalid_token"
Content-Type: text/plain; charset=utf-8
Content-Length: 28
Date: Wed, 21 Oct 2026 13:18:28 GMT
Connection: keep-alive
Keep-Alive: timeout=5
la credencial no es válida
И двата са 401 Unauthorized и двата носят втората задължителна заглавка от урока. Раздел 15.5.2 на RFC 9110 дефинира 401 така: заявката не е приложена, защото ѝ липсват валидни идентификационни данни за автентикация за ресурса. А в следващото изречение: сървърът, който генерира 401, трябва да изпрати заглавка WWW-Authenticate с поне едно предизвикателство (challenge), приложимо към ресурса. Предизвикателството казва на клиента как да се автентикира: с каква схема (Bearer) и в каква област (realm="revisor", име за защитеното пространство). 401 без WWW-Authenticate е като табела „Идентифицирайте се“, която не казва с какво.
Разликата между двата отговора е в предизвикателството. Когато не са дошли идентификационни данни, предизвикателството е само Bearer realm="revisor". Когато са дошли такива, които не стават, се добавя error="invalid_token". Това правило не е на revisor, а на RFC 6750, в неговия раздел 3.1: invalid_token е кодът за идентификационни данни, които са изтекли, отменени, неправилно оформени или невалидни по каквато и да е друга причина, и върви с 401; но ако заявката не носи никаква информация за автентикация (защото клиентът не е знаел, че е нужна, или защото е използвал метод на автентикация, който сървърът не поддържа), сървърът не би трябвало да включва в предизвикателството код за грешка или друга информация за грешка (атрибутите error, error_description и error_uri). Тялото на отговора остава извън това правило и revisor го използва, за да обясни на испански какво се е случило. Разграничението е полезно за клиента: „не си изпратил нищо“ се поправя, като се изпратят идентификационните данни; „изпратил си такива, които не стават“ се поправя, като се сдобиеш с други.
Последното изречение на RFC има случай, който си струва да се види: клиент, който изпраща своето потребителско име и парола със схемата Basic, тази от прозорчето за потребителско име и парола, което отваря браузърът (дефинира я RFC 7617). Той не е изпратил лоши идентификационни данни Bearer: не е изпратил никакви Bearer. revisor отговаря с предизвикателството без код за грешка и го казва в тялото:
$ curl -i http://127.0.0.1:18080/servicios -H 'Authorization: Basic bGVjdHVyYS1hOg=='
HTTP/1.1 401 Unauthorized
WWW-Authenticate: Bearer realm="revisor"
Content-Type: text/plain; charset=utf-8
Content-Length: 33
Date: Wed, 21 Oct 2026 13:18:28 GMT
Connection: keep-alive
Keep-Alive: timeout=5
solo se admite el esquema Bearer
И още един детайл, от раздел 11.1 на RFC 9110: името на схемата за автентикация не различава главни и малки букви. Bearer, bearer и BEARER са една и съща схема, а сървър, който приема само първата, отхвърля клиенти, които спазват спецификацията:
$ curl -i http://127.0.0.1:18080/servicios -H 'Authorization: bearer lectura-a'
HTTP/1.1 200 OK
Content-Type: application/json
Content-Length: 279
Date: Wed, 21 Oct 2026 13:18:28 GMT
Connection: keep-alive
Keep-Alive: timeout=5
{"servicios":[{"id":1,"nombre":"catalogo","url":"http://127.0.0.1:18081/codigo/200","limite_ms":2000},{"id":2,"nombre":"pagos","url":"http://127.0.0.1:18081/codigo/503","limite_ms":2000},{"id":4,"nombre":"inventario","url":"http://127.0.0.1:18081/codigo/200","limite_ms":2000}]}
Списъкът вече съдържа услуга 4, която беше регистрирана по-рано в същото изпълнение (раздел 2.7 го показва изцяло). Онова, което наистина различава главни и малки букви, са самите идентификационни данни: Bearer Lectura-a са идентификационни данни, които не съществуват.
Бележка за името, защото обърква всички поне веднъж: 401 се казва Unauthorized, „неупълномощен“, но онова, което казва, е неавтентикиран: не знам кой си. „Неупълномощен“ в смисъл на „знам кой си и нямаш право“ е 403, този от следващия раздел. Името е наследство от първите версии на HTTP и вече не може да се промени; значението е в спецификацията, а не във фразата.
2.3.2 autenticacion.ts
// fig02_01/autenticacion.ts — quién eres (la credencial) y qué puedes hacer (el rol).
import type { IncomingMessage } from "node:http";
export type Credencial = { nombre: string; equipo: string; rol: "lector" | "escritor" | "admin" };
// Credenciales de práctica. En una API real vienen de un proveedor de identidad, no del código.
// Van en un Map y no en un objeto literal: CREDENCIALES[token] sobre un objeto también encuentra lo
// que hereda de Object.prototype, y «Bearer constructor» o «Bearer toString» pasarían como válidas.
// Map.get solo devuelve lo que se guardó con set.
const CREDENCIALES = new Map<string, Credencial>([
["lectura-a", { nombre: "lectura-a", equipo: "a", rol: "lector" }],
["escritura-a", { nombre: "escritura-a", equipo: "a", rol: "escritor" }],
["escritura-b", { nombre: "escritura-b", equipo: "b", rol: "escritor" }],
["admin", { nombre: "admin", equipo: "*", rol: "admin" }],
]);
// «ninguna»: no vino Authorization. «otro-esquema»: vino, pero no es Bearer (Basic, por ejemplo).
// «invalida»: vino Bearer con una credencial que no existe o mal escrita.
export type Identificacion =
| { tipo: "ninguna" }
| { tipo: "otro-esquema" }
| { tipo: "invalida" }
| { tipo: "valida"; credencial: Credencial };
export function identificar(peticion: IncomingMessage): Identificacion {
const cabecera = peticion.headers.authorization;
if (cabecera === undefined) return { tipo: "ninguna" };
// credentials = auth-scheme [ 1*SP token68 ] (RFC 9110, sección 11.4). El nombre del esquema no
// distingue mayúsculas (sección 11.1): «Bearer», «bearer» y «BEARER» son el mismo esquema.
const esquema = /^[^ ]+/.exec(cabecera);
if (esquema === null || esquema[0].toLowerCase() !== "bearer") return { tipo: "otro-esquema" };
const encontrada = /^[^ ]+ +(\S+)$/.exec(cabecera);
const credencial = encontrada === null ? undefined : CREDENCIALES.get(encontrada[1]);
return credencial === undefined ? { tipo: "invalida" } : { tipo: "valida", credencial };
}
// La frase del 401 para cada caso.
export function motivo(identificacion: Identificacion): string {
if (identificacion.tipo === "ninguna") return "falta la credencial";
if (identificacion.tipo === "otro-esquema") return "solo se admite el esquema Bearer";
return "la credencial no es válida";
}
// El reto que exige un 401 (RFC 9110, sección 11.6.1; el esquema Bearer es del RFC 6750).
// error="invalid_token" solo cuando llegó una credencial Bearer y no sirve (RFC 6750, sección 3).
// Sin credencial, o con un esquema que no se admite, no va ningún código de error (sección 3.1).
export function reto(identificacion: Identificacion): string {
return identificacion.tipo === "invalida"
? 'Bearer realm="revisor", error="invalid_token"'
: 'Bearer realm="revisor"';
}
Файлът има четири части. Таблицата с идентификационни данни, която в истинско API не би съществувала: идентификационните данни се издават от доставчик на идентичност и сървърът ги проверява, например като проверява подписа на токен. Функцията identificar, която чете заглавката Authorization и връща един от четири резултата: не е дошло нищо, дошло е с друга схема, дошли са невалидни Bearer или са дошли валидни, заедно с идентификационните данни. Първо отделя името на схемата (онова, което е преди първия интервал) и го сравнява с малки букви; ако не е bearer, дори не търси идентификационните данни. Функцията motivo пише изречението за тялото на 401 за всеки случай, а функцията reto сглобява стойността на WWW-Authenticate: кодът invalid_token само когато са дошли идентификационни данни Bearer, които не стават. Типът Identificacion е обединение от четири случая и входната точка проверява tipo, преди да използва credencial; TypeScript не ѝ позволява да го използва в другите три.
2.3.3 Защо таблицата е Map
В таблицата с идентификационни данни има детайл, който изглежда като въпрос на стил, а е въпрос на сигурност: тя е Map и се преглежда с CREDENCIALES.get(token). Формата, която почти на всеки му хрумва първа, е обект, { "lectura-a": {…}, "admin": {…} }, преглеждан с CREDENCIALES[token]. Изглежда също толкова добре, TypeScript не се оплаква и четирите набора идентификационни данни работят по същия начин. Но има дупка.
В JavaScript обект, написан с фигурни скоби, наследява свойства от друг обект, своя прототип, Object.prototype. Затова всеки обект отговаря на toString, на hasOwnProperty или на constructor, въпреки че никой не ги е написал в него. А CREDENCIALES[token] не различава записаното в обекта от наследеното: ако токенът е constructor, търсенето намира нещо — функцията Object, — и това нещо не е undefined. За identificar, която пита само дали е намерила нещо, Bearer constructor биха били валидни идентификационни данни. Разделът „Грешката, която ще видиш“ го предизвиква с изпълнение, а онова, което revisor отговаря в този случай, не е това, което би очаквал.
Map.get няма този проблем: връща само онова, което е записано със set (или в началния списък), а за всеки друг ключ — undefined. Другата правилна защита е обект без прототип, създаден с Object.create(null) и преглеждан с Object.hasOwn(objeto, token), който гледа само собствените свойства. И двете затварят дупката; Map освен това показва ясно на онзи, който чете кода, че това е таблица за търсене, а не обект със свойства.
Случаят учи на нещо, което отива отвъд JavaScript: на въпроса „кой си?“ се отговаря с „да“ само когато е намерено точно онова, което се търси. Всеки път в кода, който може да върне „намерих нещо“ случайно (наследено свойство, сравнение, което приема празен низ, търсене, което не различава главни и малки букви там, където би трябвало), е идентификационни данни, които никой не е издал. В истинско API тази случайност живее в проверката на подписа на токен, в сравнението на ключ или в заявката към база данни, а правилото е същото.
Същата грижа се появява и на друго място във входната точка. Таблицата с методи на всеки маршрут също е обект ({ GET: …, DELETE: … }), а методът се изписва от клиента. Затова atender не я преглежда директно с ruta.metodos[nombreMetodo], а с Object.hasOwn(ruta.metodos, nombreMetodo), което гледа само методите, записани в таблицата. Би ли стигнал метод с име constructor? На практика той дори не стига до входната точка:
$ curl -i -X constructor http://127.0.0.1:18080/servicios/1 -H 'Authorization: Bearer escritura-a'
HTTP/1.1 400 Bad Request
Connection: close
HTTP анализаторът на Node приема само методите, които познава, и на всеки друг отговаря сам с 400, без тяло и като затваря връзката, преди да извика atender; затова терминалът на revisor не го отбелязва. Входната точка не зависи от този филтър: таблица, която решава нещо според стойност, изпратена от клиента, се пише така, че да намира само записаното в нея, дори някой друг компонент да филтрира по-рано, защото другият компонент може да се промени утре.
2.4 Можеш ли?: 403
2.4.1 Читател, който се опитва да записва
След като идентификационните данни са проверени, четвъртият въпрос е дали те позволяват онова, което се иска. Функцията puede в rutas.ts решава това на един ред: ако методът чете, всеки може; ако записва — само редактор или администратор.
$ curl -i -X DELETE http://127.0.0.1:18080/servicios/1 -H 'Authorization: Bearer lectura-a'
HTTP/1.1 403 Forbidden
Content-Type: text/plain; charset=utf-8
Content-Length: 74
Date: Wed, 21 Oct 2026 13:18:28 GMT
Connection: keep-alive
Keep-Alive: timeout=5
tu credencial solo puede leer; para escribir pide una con rol de escritor
403 Forbidden. Раздел 15.5.4 на RFC 9110 го определя така: сървърът е разбрал заявката, но отказва да я изпълни. И добавя това, което го отличава от 401: ако заявката е носела идентификационни данни, сървърът ги смята за недостатъчни, за да ѝ даде достъп, и клиентът не би трябвало да повтаря заявката със същите идентификационни данни; може да я повтори с други. 403 не изисква никаква заглавка, за разлика от 401 и 405. (С токени на OAuth RFC 6750 позволява към 403 да се добави предизвикателство с error="insufficient_scope", за да се каже, че на токена му липсват разрешения; revisor не използва обхвати (scopes) и не го изпраща.) Може да обясни в тялото защо е отказал, и revisor го прави: tu credencial solo puede leer; para escribir pide una con rol de escritor („идентификационните ти данни позволяват само четене; за писане поискай такива с роля на редактор“).
Обърни внимание какво не казва този текст: не повтаря идентификационните данни. Изкушаващо би било да се напише „идентификационните данни lectura-a позволяват само четене“, което звучи по-точно. Но в revisor името на идентификационните данни е самите идентификационни данни: lectura-a е едновременно кой си и ключът за влизане. Повтарянето му в тялото означава да се запише тайна в отговор, а отговорите за грешки завършват на места, които никой не следи: лога на балансьор на натоварването, кеша на посредник, отчета за грешки на клиента или екранната снимка, която някой поставя в чат, за да поиска помощ. В реално API се случва същото с токен: съобщението за грешка казва какво липсва на клиента (роля на редактор) и никога не връща това, което клиентът е изпратил, за да се идентифицира. Клиентът вече го знае; този, който не трябва да го знае, е всичко, което е по средата.
Въпросът, който решава между двата кода, е кратък и си струва да го знаеш наизуст: би ли се променило нещо, ако се идентифицира отново? Ако не знам кой си или онова, което ми даде, не става, идентифицирането ти решава проблема: 401. Ако знам кой си и онова, което искаш, не ти се полага, повторното идентифициране със същото не променя нищо: 403. Клиент, който получи 401, може да поиска нови идентификационни данни и да опита отново; клиент, който получи 403, не бива да опитва отново със същите, и разумното е да се откаже или да поиска разрешение от човек.
2.4.2 403 не зависи от идентификатора
Тук се появява третото правило за реда. Какво става, ако читателят се опита да изтрие услуга, която не съществува?
$ curl -i -X DELETE http://127.0.0.1:18080/servicios/99 -H 'Authorization: Bearer lectura-a'
HTTP/1.1 403 Forbidden
Content-Type: text/plain; charset=utf-8
Content-Length: 74
Date: Wed, 21 Oct 2026 13:18:28 GMT
Connection: keep-alive
Keep-Alive: timeout=5
tu credencial solo puede leer; para escribir pide una con rol de escritor
403, същото като при услуга 1. Входната точка отговори на въпрос 4, преди да стигне до 5, така че не знае — и не я интересува — дали услуга 99 съществува. За читател всеки DELETE е 403, с какъвто и да е идентификатор. Точно това искаме: 403 на тази входна точка говори за идентификационните данни и за метода и не позволява да се види нищо от данните. Ако редът беше обратен, съществуване преди разрешение, читателят би получавал 403 за услугите, които съществуват, и 404 за тези, които не съществуват, и би имал пълна карта на съществуващото, без да е успял да изтрие каквото и да е. Това е същото изтичане, което карето за сигурност в раздел 2.5 описва за 404, но по друг път.
2.5 Съществува ли — за теб?: 404, който скрива
2.5.1 Всяка услуга принадлежи на екип
От този урок всяка услуга принадлежи на екип. Трите начални услуги вече го имаха записано в almacen.ts: catalogo и pagos са на екип a, nomina е на екип b. Урок 1 пренебрегваше това; сега revisor го използва на три места. При извеждане на списъка всеки набор идентификационни данни вижда само услугите на своя екип:
$ curl -i http://127.0.0.1:18080/servicios -H 'Authorization: Bearer lectura-a'
HTTP/1.1 200 OK
Content-Type: application/json
Content-Length: 189
Date: Wed, 21 Oct 2026 13:18:28 GMT
Connection: keep-alive
Keep-Alive: timeout=5
{"servicios":[{"id":1,"nombre":"catalogo","url":"http://127.0.0.1:18081/codigo/200","limite_ms":2000},{"id":2,"nombre":"pagos","url":"http://127.0.0.1:18081/codigo/503","limite_ms":2000}]}
lectura-a вижда услуги 1 и 2 и не вижда 3. При регистриране новата услуга остава в екипа на онзи, който я е създал. А при проверка, премахване или заявяване на инспекция на услуга въпрос 5 не е само „съществува ли?“, а „съществува ли за теб?“.
2.5.2 Един и същ 404 за чуждото и за несъществуващото
Какво трябва да отговори revisor, когато lectura-a поиска услуга 3, която съществува, но е на екип b? Има два възможни отговора и спецификацията позволява и двата. Първият е 403: „съществува и не ти се полага“. Вторият е 404: „за теб не съществува“. RFC 9110 предвижда и двата в един-единствен абзац на раздел 15.5.4: изходен сървър (origin server), който иска да „скрие“ съществуването на забранен ресурс, може вместо 403 да отговори с 404. А дефиницията на 404, която видя в 2.2.1, вече го съдържаше: не е намерил ресурса или не желае да разкрие, че той съществува.
revisor избира втория. Виж двата отговора един до друг, първо услуга 3, която съществува, и после 99, която не съществува:
$ curl -i http://127.0.0.1:18080/servicios/3 -H 'Authorization: Bearer lectura-a'
HTTP/1.1 404 Not Found
Content-Type: text/plain; charset=utf-8
Content-Length: 24
Date: Wed, 21 Oct 2026 13:18:28 GMT
Connection: keep-alive
Keep-Alive: timeout=5
no existe el servicio 3
$ curl -i http://127.0.0.1:18080/servicios/99 -H 'Authorization: Bearer lectura-a'
HTTP/1.1 404 Not Found
Content-Type: text/plain; charset=utf-8
Content-Length: 25
Date: Wed, 21 Oct 2026 13:18:28 GMT
Connection: keep-alive
Keep-Alive: timeout=5
no existe el servicio 99
Двата са еднакви с изключение на номера на услугата, който е онзи, който си поискал. Същият код, същите заглавки, същото тяло, същата дължина минус един байт заради допълнителната цифра. С тези два отговора няма как да се разбере коя от двете услуги съществува. А собственикът я вижда:
$ curl -i http://127.0.0.1:18080/servicios/3 -H 'Authorization: Bearer escritura-b'
HTTP/1.1 200 OK
ETag: "3-1-a06a8084"
Content-Type: application/json
Content-Length: 86
Date: Wed, 21 Oct 2026 13:18:28 GMT
Connection: keep-alive
Keep-Alive: timeout=5
{"id":3,"nombre":"nomina","url":"http://127.0.0.1:18081/codigo/200","limite_ms":2000}
Решението е във функция от четири реда в servicios.ts, която всички манипулатори използват, вместо да търсят услугата директно:
// Un servicio de otro equipo se trata como si no existiera: el 404 esconde a propósito.
function visible(id: string, credencial: Credencial): Servicio | undefined {
const servicio = buscarServicio(Number(id));
if (servicio === undefined) return undefined;
return credencial.equipo === "*" || servicio.equipo === credencial.equipo ? servicio : undefined;
}
function noExiste(respuesta: ServerResponse, id: string): void {
fallar(respuesta, 404, `no existe el servicio ${id}`);
}
visible връща услугата само ако тя е на екипа на идентификационните данни (или ако идентификационните данни са на администратор, чийто екип е *), и undefined във всеки друг случай. За манипулатора „не съществува“ и „на друг е“ са едно и също undefined и затова водят до един и същ 404, със същия текст, записан на едно-единствено място: noExiste. Съдържанието на отговора не може да се различава, защото кодът няма как да ги различи. Това е сигурният начин да се напише: не два клона, които се стараем да поддържаме еднакви, а един-единствен клон. Това, което се гарантира, е еднаквостта на отговора (код, заглавки и текст), а не на времето, което му трябва: при хранилище в паметта като това разликата не може да се измери, но с истинска база данни намирането на запис и проверката чий е може да отнеме различно време от отхвърлянето на несъществуващ запис, и който мери микросекунди, би могъл да го забележи. Това е страничен канал (side channel, информация, която изтича през нещо, което не е съдържанието), и за него се внимава отделно, например като се търси по идентификатор и екип с едно-единствено търсене.
Каре за сигурност:
404, който скрива, и изброяването на ресурсиКакво е изброяването. Това е разкриването на кои ресурси съществуват чрез изпробване на идентификатори един след друг и сравняване на отговорите. Не е нужно да се чете никаква стойност: достатъчно е API-то да отговаря различно на съществуващото и на несъществуващото. Ако
/servicios/3отговаря с403, а/servicios/99— с404, онзи, който пробва от 1 до 1000, си тръгва със списъка на съществуващите услуги, колко са и, ако идентификаторите са последователни, колко се създават всяка седмица. Ръководството на OWASP за тестване на сигурността описва тази техника в своя тест WSTG-IDNT-04, а примерът му е точно този:/account1отговаря с403,/account2отговаря с404, и онзи, който пробва, заключава, че първият акаунт съществува, а вторият — не.Защо има значение, макар да не се чете нищо. Да знаеш какво съществува е първата стъпка на почти всяка атака: със списъка се търсят ресурсите, които си струват, откраднати идентификационни данни се пробват само там, където има нещо, и се откриват вътрешни имена, които не е трябвало да излизат навън. А разликата не е задължително да е в кода: различно тяло („не съществува“ срещу „на друг екип е“), различна дължина или една заглавка в повече са достатъчни.
Защитата на
revisor. Чуждото и несъществуващото получават идентичен отговор: същия код, същото тяло, излезли от същата функция. RFC 9110 изрично го позволява (раздел 15.5.4) и това правят API-та, които работят в голям мащаб: документацията на API-то на GitHub обяснява, че отговаря с404, а не с403, на частен ресурс, за да не потвърди, че съществува. А редът на входната точка затваря другите два пътя: непознат получава401за всички идентификатори (въпрос 3), а читател, който се опитва да записва, получава403за всички (въпрос 4), независимо дали съществуват.Какво не прави
404. Скриването на съществуването не е същото като защитата на ресурса. Онова, което пречи на екип a да прочете услугата на екип b, не е404, а това, чеvisibleсравнява екипа при всеки достъп. Забравянето на това сравнение в някой манипулатор е грешка номер едно в списъка с рискове за API-та на OWASP, API1:2023, Broken Object Level Authorization, и защитата, която предлага, е точно тази: във всяка функция, която получава идентификатор от клиента, да се проверява дали онзи, който иска, може да направи това с този запис. Същото ръководство предлага освен това да се използват случайни идентификатори, трудни за отгатване, вместо последователни;revisorизползва последователни, за да бъде курсът четим, и затова еднаквият404е онова, което го защитава. Със случайни идентификатори изброяването би било много по-скъпо, но еднаквият отговор пак би бил необходим.Кога се записва като политика. Скриването си има цена: законният собственик, който сбърка идентификационните данни, получава „не съществува“ и може да загуби известно време в търсене на грешка в адреса. Затова решението се записва и се прилага еднакво в цялото API: в
revisorвсеки ресурс на друг екип отговаря с404, за всеки маршрут и всеки метод. Онова, което не е допустимо, е да се скрива в някои маршрути, а в други не; първият маршрут, който отговори различно, разваля онова, което останалите скриват.
2.5.3 Проверка с програма
Твърдение за сигурност се проверява с атака, а не с четене на кода. Тази програма прави онова, което би направил някой, който иска да изброява: обхожда идентификаторите от 1 до 6 с дадени идентификационни данни, отбелязва какво отговаря revisor на всеки и прави своите изводи. Запиши я като ~/curso-http/fig02_02.ts:
// fig02_02.ts
// El enumerador: prueba identificadores uno tras otro y agrupa los que recibieron la misma respuesta.
// Uso: node fig02_02.ts <credencial>
const credencial = process.argv[2] ?? "lectura-a";
const grupos = new Map<string, number[]>(); // respuesta → identificadores que la recibieron
for (let id = 1; id <= 6; id++) {
const respuesta = await fetch(`http://127.0.0.1:18080/servicios/${id}`, {
headers: { Authorization: `Bearer ${credencial}` },
});
const cuerpo = (await respuesta.text()).trim();
// Un éxito trae los datos del servicio; un error trae un texto, y en él se borra el número pedido,
// para que dos errores que solo difieren en el id cuenten como la misma respuesta.
const huella = respuesta.ok
? `${respuesta.status} con datos`
: `${respuesta.status} ${cuerpo.replaceAll(String(id), "N")}`;
console.log(`servicio ${id}: ${respuesta.status} ${cuerpo}`);
grupos.set(huella, [...(grupos.get(huella) ?? []), id]);
}
console.log(`\nrespuestas distintas: ${grupos.size}`);
for (const [huella, ids] of grupos) console.log(` ${ids.join(", ").padEnd(18)} → ${huella}`);
if (grupos.size > 2) console.log("más de dos respuestas: lo que no es tuyo ya no se ve todo igual");
Програмата не знае нищо за вътрешността на revisor: вижда само онова, което би видял всеки отвън. За всеки идентификатор пази отпечатък на отговора: кода и, ако е грешка, тялото с изтрит поискан номер, така че „no existe el servicio 3“ и „no existe el servicio 4“ да се броят за един и същ отговор. Накрая групира идентификаторите по отпечатък. Ако чуждото и несъществуващото получават един и същ отговор, трябва да има точно две групи: твоите, с данни, и всички останали. Трета група означава, че нещо различава чуждото от несъществуващото, и програмата предупреждава за това. Това е същото разсъждение, което би направил човек с електронна таблица, само че за една секунда. Сравняването на тялото, а не само на кода, има значение: истинската програма за изброяване гледа всичко, което се променя, а 404 с два различни текста издава толкова, колкото 403 срещу 404.
С работещи лаборатория и revisor от 2.7, току-що стартирани:
$ node fig02_02.ts lectura-a
servicio 1: 200 {"id":1,"nombre":"catalogo","url":"http://127.0.0.1:18081/codigo/200","limite_ms":2000}
servicio 2: 200 {"id":2,"nombre":"pagos","url":"http://127.0.0.1:18081/codigo/503","limite_ms":2000}
servicio 3: 404 no existe el servicio 3
servicio 4: 404 no existe el servicio 4
servicio 5: 404 no existe el servicio 5
servicio 6: 404 no existe el servicio 6
respuestas distintas: 2
1, 2 → 200 con datos
3, 4, 5, 6 → 404 no existe el servicio N
Две групи. lectura-a знае, че съществуват 1 и 2, които са негови, и не знае нищо за 3: за него 3 е толкова несъществуваща, колкото 6, която наистина не съществува. В „Грешката, която ще видиш“ ще промениш четири реда в servicios.ts и ще видиш как се появява третата група.
2.5.4 Инспекциите също принадлежат на екип
Въпросът „съществува ли за теб?“ не се ограничава с услугите. Инспекцията принадлежи на услугата, която е инспектирала, и следователно на нейния екип. consultarRevision отговаря с 404, ако инспекцията не съществува или ако услугата ѝ не е видима за идентификационните данни:
export function consultarRevision({ respuesta, parametros, credencial }: Contexto): void {
const revision = buscarRevision(Number(parametros[0]));
if (revision === undefined || visible(String(revision.servicio), credencial) === undefined) {
fallar(respuesta, 404, `no existe la revisión ${parametros[0]}`);
return;
}
const cabeceras: Cabeceras = revision.estado === "pendiente" ? { "Retry-After": "1" } : {};
enviarJson(respuesta, 200, representarRevision(revision), cabeceras);
}
Това е същият шаблон с друг ресурс и си струва да се отбележи, защото там най-често се забравя: основният ресурс се защитава грижливо, а „дъщерният“ ресурс, до който се стига с друг идентификатор, остава отворен. Ако consultarRevision търсеше инспекцията, без да пита чия е услугата ѝ, всеки от екип b би могъл да чете резултатите от инспекциите на екип a, обхождайки /revisiones/1, /revisiones/2…, въпреки че не може да вижда услугите.
2.6 Тялото: 415 и 400
2.6.1 Форматът преди четенето
Последните два въпроса се задават само когато заявката носи тяло, а в revisor това е регистрацията, POST /servicios. Първо: идва ли във формат, който приемам? Клиентът посочва формата на своето тяло със заглавката Content-Type, а revisor приема само application/json. След това: мога ли да го прочета? Дори да твърди, че е JSON, може и да не е.
На двата въпроса отговаря функцията leerJson в servicios.ts, която е нова:
// Lee el cuerpo como JSON. Si no puede, ya respondió 415 o 400 y devuelve undefined.
async function leerJson(peticion: IncomingMessage, respuesta: ServerResponse): Promise<Record<string, unknown> | undefined> {
const tipo = (peticion.headers["content-type"] ?? "").split(";")[0].trim().toLowerCase();
if (tipo !== "application/json") {
fallar(respuesta, 415, `el cuerpo debe ser application/json, no ${tipo === "" ? "(sin tipo)" : tipo}`, {
Accept: "application/json",
});
return undefined;
}
try {
const datos: unknown = JSON.parse(await leerCuerpo(peticion));
if (typeof datos === "object" && datos !== null && !Array.isArray(datos)) return datos as Record<string, unknown>;
} catch (error) {
// 413 (RFC 9110, sección 15.5.14): el cuerpo pasa del tope. «Connection: close» hace que Node cierre
// la conexión al terminar de responder, en vez de seguir recibiendo lo que el cliente quería mandar.
if (error instanceof CuerpoDemasiadoGrande) {
fallar(respuesta, 413, `el cuerpo pasa de ${TOPE_CUERPO} bytes`, { Connection: "close" });
return undefined;
}
// si no, cae al 400 de abajo
}
fallar(respuesta, 400, "el cuerpo no es un objeto JSON");
return undefined;
}
Първата част сравнява Content-Type с application/json. Преди да сравни, взема това, което е преди първото ;, и го превръща в малки букви, защото application/json; charset=utf-8 и Application/JSON са един и същ тип: раздел 8.3.1 на RFC 9110 казва, че типът и подтипът не различават главни от малки букви и че това, което е след ;, са параметри. Ако типът не е JSON, отговаря с 415. Втората част се опитва да прочете JSON и освен това изисква той да бъде обект: число, текст или списък са валиден JSON, но не са услуга. Ако кое да е от двете неща се провали, отговаря с 400, със същия текст и за двете: „el cuerpo no es un objeto JSON“ („тялото не е JSON обект“) покрива както прекъснат JSON, така и такъв, който се чете добре, но не е обект. Освен това, преди да чете, leerCuerpo проверява размера и ако тялото е твърде голямо, leerJson отговаря с 413; раздел 2.6.4 го обяснява.
2.6.2 415: форматът не е онзи, който приемам
curl -d е най-краткият начин да изпратиш тяло и крие капан, който си струва да се види веднъж. Ако не му посочиш типа, изпраща application/x-www-form-urlencoded, формата на уеб формулярите, както казва документацията на опцията -d:
$ curl -i -X POST http://127.0.0.1:18080/servicios -H 'Authorization: Bearer escritura-a' -d 'nombre=inventario'
HTTP/1.1 415 Unsupported Media Type
Accept: application/json
Content-Type: text/plain; charset=utf-8
Content-Length: 74
Date: Wed, 21 Oct 2026 13:18:28 GMT
Connection: keep-alive
Keep-Alive: timeout=5
el cuerpo debe ser application/json, no application/x-www-form-urlencoded
415 Unsupported Media Type. Раздел 15.5.16 на RFC 9110 го дефинира: сървърът отказва да обслужи заявката, защото съдържанието идва във формат, който този метод не поддържа за този ресурс. И добавя коя заглавка помага на клиента: ако проблемът е в медийния тип, заглавката Accept в отговора може да каже кои типове биха били приети. Тук спецификацията казва може, а не трябва: за разлика от Allow при 405 и WWW-Authenticate при 401, Accept при 415 е препоръчителна, а не задължителна. revisor я изпраща винаги, защото тя превръща грешката в инструкция: „не приемам формуляри; приемам application/json“. И един разумен въпрос: какво става с тялото, което revisor не е прочел? Измерено е: POST от 8 MiB с Content-Type: text/plain получи 415, а следващата заявка по същата връзка получи нормалния си 200; Node сам изхвърля тялото, което никой не е прочел, когато отговорът приключи, така че тук не е нужен Connection: close, за разлика от 413, където целта е да се спре приемането.
Accept в отговор е по-рядко срещана, отколкото в заявка, и не бива да ги бъркаш. В заявка Accept казва какви формати клиентът иска да получи, а ако сървърът не може да произведе нито един, кодът е друг — 406 Not Acceptable, който не е главно действащо лице в този курс. В отговора на 415 Accept казва какви формати сървърът приема да получи. Същото име, обратна посока.
С изрично посочен тип, който също не е JSON, резултатът е същият, а тялото казва какъв тип е пристигнал:
$ curl -i -X POST http://127.0.0.1:18080/servicios -H 'Authorization: Bearer escritura-a' -H 'Content-Type: text/plain' -d 'inventario'
HTTP/1.1 415 Unsupported Media Type
Accept: application/json
Content-Type: text/plain; charset=utf-8
Content-Length: 51
Date: Wed, 21 Oct 2026 13:18:28 GMT
Connection: keep-alive
Keep-Alive: timeout=5
el cuerpo debe ser application/json, no text/plain
2.6.3 400: твърди, че е JSON, и не може да се прочете
Сега типът е правилен, но JSON-ът е прекъснат по средата:
$ curl -i -X POST http://127.0.0.1:18080/servicios -H 'Authorization: Bearer escritura-a' -H 'Content-Type: application/json' -d '{"nombre": "inventario",'
HTTP/1.1 400 Bad Request
Content-Type: text/plain; charset=utf-8
Content-Length: 31
Date: Wed, 21 Oct 2026 13:18:28 GMT
Connection: keep-alive
Keep-Alive: timeout=5
el cuerpo no es un objeto JSON
400 Bad Request. Това е най-общият код в семейството и раздел 15.5.1 на RFC 9110 го дефинира със същата широта: сървърът не може или не иска да обработи заявката заради нещо, което възприема като грешка на клиента, и дава като примери неправилно оформен синтаксис или неправилно рамкирано съобщение. Прекъснат JSON е неправилно оформен синтаксис.
Защо 415 е преди 400, а не обратното? Заради първото правило за реда: не можеш да кажеш дали нещо може да се прочете, без да знаеш в какъв формат е. Ако revisor се опиташе да прочете като JSON тяло, което твърди, че е формуляр, грешката, която би видял, би била „не е JSON“, и би отговорил с 400; клиентът би заключил, че неговият JSON е грешен, докато грешна е заглавката. Редът прави така, че всеки неуспех да има свой код.
2.6.4 413: тялото е твърде голямо
Форматът не е единственото, което може да се отхвърли, преди да се разбере едно тяло: също и размерът. За да прочете тяло, revisor го пази в паметта, а сървър, който пази в паметта всичко, което му изпратят, дава на всеки ключа да му я напълни. Първо червеното. В копие на revisor (пълната версия е в defectos/sin-tope/respuestas.ts) смени ограничението в respuestas.ts с безкрайност:
export const TOPE_CUERPO = Infinity;
Стартирай лабораторията и копието и в друг терминал запази номера на процеса на revisor в променлива: REV=$(pgrep -f 'node servidor.ts'). С него ps -o rss= -p $REV казва колко памет заема процесът, в килобайти. Измери я, изпрати регистрация от 50 MB букви a и измери отново:
$ ps -o rss= -p $REV
93056
$ head -c 52428800 /dev/zero | tr '\0' 'a' | curl -sS -o /dev/null -w '%{http_code} en %{time_total} s\n' -X POST http://127.0.0.1:18080/servicios -H 'Authorization: Bearer escritura-a' -H 'Content-Type: application/json' --data-binary @-
400 en 0.041348 s
$ ps -o rss= -p $REV
224064
$ curl -sS -o /dev/null -w '%{http_code}\n' http://127.0.0.1:18080/servicios/1 -H 'Authorization: Bearer lectura-a'
200
Паметта на процеса се качи от около 93 MB до около 224 MB заради една заявка, а revisor я прочете цялата, за да отговори накрая с 400, защото 50 MB букви a не са JSON. С десет такива заявки едновременно или с тяло от няколко гигабайта процесът остава без памет и спира да работи за всички. И обърни внимание кой може да го направи: всеки с идентификационни данни за писане, защото редът на входната точка наистина държи непознатите отвън (тялото се чете едва при въпрос 7). Това е защита, но не е достатъчна: идентификационни данни за писане също могат да бъдат откраднати или злоупотребени.
Сега зеленото, с revisor от урока. Същото измерване:
$ ps -o rss= -p $REV
91616
$ head -c 52428800 /dev/zero | tr '\0' 'a' | curl -sS -o /dev/null -w '%{http_code} en %{time_total} s\n' -X POST http://127.0.0.1:18080/servicios -H 'Authorization: Bearer escritura-a' -H 'Content-Type: application/json' --data-binary @-
413 en 0.002827 s
$ ps -o rss= -p $REV
84272
$ curl -sS -o /dev/null -w '%{http_code}\n' http://127.0.0.1:18080/servicios/1 -H 'Authorization: Bearer lectura-a'
200
413 за около три хилядни от секундата, а паметта не нарасна: падна от около 92 MB на около 84 MB, защото събирачът на боклук (garbage collector) на Node се възползва, за да върне памет (при твоето изпълнение цифрите ще са други; важното е, че не се качват). revisor не запази 50-те MB: отговори с 413 веднага щом видя, че надхвърлят ограничението, а онова, което продължи да пристига, го прочете и изхвърли, без да го пази. Ето какво прави leerCuerpo в respuestas.ts (той е същият като в Урок 1, който вече го използваше като предпазна мрежа):
// Tope del cuerpo: 16 KiB. Un servicio es un nombre de hasta 40 caracteres, una URL y un número; cabe
// de sobra. Sin tope, leerCuerpo guardaba en memoria todo lo que llegara: un POST de 50 MB subía la
// memoria del revisor unos 150 MB, y cualquiera con la dirección podía llenarla.
export const TOPE_CUERPO = 16 * 1024;
// La señal de que el cuerpo pasó del tope. Quien llama responde 413 (RFC 9110, sección 15.5.14).
export class CuerpoDemasiadoGrande extends Error {}
// Cuando el cuerpo ya pasó del tope, el cliente puede seguir mandando. Si el servidor responde el 413 y destruye la
// conexión en ese momento, lo que todavía llega a una conexión cerrada se contesta con un RST del sistema operativo,
// y ese RST puede borrar el 413 antes de que el cliente lo lea: el cliente ve EPIPE o ECONNRESET en vez de la
// respuesta. El RFC 9112, sección 9.6, aconseja un cierre en etapas: responder, cerrar solo la escritura (media cerrada),
// seguir leyendo y tirando lo que llegue hasta que el cliente cierre o pase un plazo (el RFC no fija ninguno; el de 2 s es del curso), y cerrar del todo.
// Node, con «Connection: close», llama a socket.destroySoon() al terminar de responder, que cierra y destruye; aquí
// se cambia ese método, solo en esta conexión, para que únicamente cierre la escritura.
const ESPERA_CIERRE_MS = 2000;
function cerrarConCalma(peticion: IncomingMessage): void {
const socket = peticion.socket;
socket.destroySoon = (): void => {
socket.end();
};
peticion.resume();
const reloj = setTimeout(() => socket.destroy(), ESPERA_CIERRE_MS);
socket.once("close", () => clearTimeout(reloj));
}
// Lee el cuerpo contando los bytes. Si Content-Length ya anuncia más del tope, rechaza sin guardar nada;
// si no lo anuncia (cuerpo por partes) o miente, deja de guardar en cuanto se pasa. En los dos casos no
// guarda más de TOPE_CUERPO bytes. No se destruye la petición, porque con ella se iría la conexión y
// no se podría responder: se prepara el cierre en etapas (ver arriba) y el 413 sale de inmediato, con
// «Connection: close».
export function leerCuerpo(peticion: IncomingMessage): Promise<string> {
return new Promise((resolver, rechazar) => {
const demasiado = (): void => {
cerrarConCalma(peticion);
rechazar(new CuerpoDemasiadoGrande(`el cuerpo pasa de ${TOPE_CUERPO} bytes`));
};
if (Number(peticion.headers["content-length"] ?? 0) > TOPE_CUERPO) {
demasiado();
return;
}
const trozos: Buffer[] = [];
let total = 0;
const alLlegar = (trozo: Buffer): void => {
total += trozo.length;
if (total > TOPE_CUERPO) {
peticion.off("data", alLlegar);
peticion.off("end", alTerminar);
demasiado();
return;
}
trozos.push(trozo);
};
const alTerminar = (): void => resolver(Buffer.concat(trozos).toString("utf8"));
peticion.on("data", alLlegar);
peticion.on("end", alTerminar);
peticion.on("error", rechazar);
});
}
Той има две защити, защото има два начина да се изпрати тяло. Ако заявката носи Content-Length и той вече обявява повече от 16 KiB (16 384 байта), отхвърля без да запазва нищо. Ако не го носи, защото тялото идва на части (chunked, както видя в Урок 0), или ако числото лъже, брои байтовете, докато пристигат, и спира да ги пази веднага щом надхвърлят ограничението. И в двата случая никога не пази повече от 16 KiB и отговаря с 413 веднага, със затваряне на етапи: затваря само писането (полузатворена връзка), продължава да чете и изхвърля онова, което клиентът успее да изпрати, и унищожава връзката след 2 секунди, ако клиентът не я е затворил. Ако отговореше и затвореше рязко, докато клиентът още изпраща, операционната система би отговорила с RST, който може да изтрие 413, преди клиентът да го прочете (RFC 9112, раздел 9.6). Защо 16 KiB? Защото една услуга е име до 40 знака, URL и число: побира се много пъти. Ограничението се избира според това, което пътят трябва да получи, а не според това, което сървърът издържа.
А този, който чете тялото, leerJson, превръща това отхвърляне в отговора. Пълното изпълнение от 2.7 съдържа трите проби на ограничението:
$ head -c 20000 /dev/zero | tr '\0' 'a' | curl -i -X POST http://127.0.0.1:18080/servicios -H 'Authorization: Bearer escritura-a' -H 'Content-Type: application/json' --data-binary @-
HTTP/1.1 413 Content Too Large
Connection: close
Content-Type: text/plain; charset=utf-8
Content-Length: 30
Date: Wed, 21 Oct 2026 23:42:11 GMT
el cuerpo pasa de 16384 bytes
$ head -c 20000 /dev/zero | tr '\0' 'a' | curl -sS -o /dev/null -w 'por partes: %{http_code}\n' -X POST -T - http://127.0.0.1:18080/servicios -H 'Authorization: Bearer escritura-a' -H 'Content-Type: application/json'
por partes: 413
$ head -c 16384 /dev/zero | tr '\0' 'a' | curl -sS -o /dev/null -w 'en el tope: %{http_code}\n' -X POST http://127.0.0.1:18080/servicios -H 'Authorization: Bearer escritura-a' -H 'Content-Type: application/json' --data-binary @-
en el tope: 400
$ curl -sS -o /dev/null -w '%{http_code}\n' http://127.0.0.1:18080/servicios/1 -H 'Authorization: Bearer lectura-a'
200
413 Content Too Large, който раздел 15.5.14 на RFC 9110 определя така: сървърът отказва да обработи заявката, защото съдържанието ѝ е по-голямо, отколкото той е готов или способен да обработи, и може да затвори връзката. revisor го прави с Connection: close: Node затваря връзката, след като приключи с отговора, а това, което клиентът още е изпращал, вече е източено, без да се пази. Заявката не се унищожава преди това, защото тогава не би могло да се отговори нищо. Втората проба изпраща тялото на части (-T -), без Content-Length, и също получава 413: там сработи броенето по време на четене. Третата изпраща точно 16 384 байта, точно на ограничението: те се прочитат изцяло и, тъй като не са JSON, отговорът е 400. А заявката в края показва, че revisor още работи.
413 и 415 са роднини: и двата казват, че тялото не може да бъде прието такова, каквото идва, единият заради формата, а другият заради размера, и двата се решават, без да се разбира съдържанието. Затова вървят заедно във въпроса за тялото, преди 400, който наистина трябва да го прочете. Един детайл за името, който вече видя в Урок 0: Node нарича този код „Payload Too Large“, старото име; RFC 9110 го промени на „Content Too Large“, а respuestas.ts използва това от регистъра.
2.6.5 Това, което все още е 400
В този урок има още четири ситуации, които също получават 400, и си струва да ги видим заедно, защото Урок 3 ще премести някои от тях към друг код.
JSON, който не е обект. null, 7, "pagos" и [] са валиден JSON, но нито един не е услуга. leerJson проверява това, преди да пропусне тялото, и отговаря с 400 el cuerpo no es un objeto JSON. Това не е подробност: ако darDeAlta получеше null и прочетеше datos.nombre, JavaScript би хвърлил грешка, а грешка, която никой не прихване в сървър на Node, прекратява процеса. Упражнение 4 разглежда това.
Липсващи полета. Ако тялото е JSON обект, но не носи nombre или url като текст, revisor отговаря с 400 faltan nombre y url („липсват nombre и url“).
Лимит извън диапазона. limite_ms е незадължителен и по подразбиране е 2000, но ако дойде, трябва да е цяло число между 100 и 10000:
$ curl -i -X POST http://127.0.0.1:18080/servicios -H 'Authorization: Bearer escritura-a' -H 'Content-Type: application/json' -d '{"nombre":"eterno","url":"http://127.0.0.1:18081/codigo/200","limite_ms":3600000}'
HTTP/1.1 400 Bad Request
Content-Type: text/plain; charset=utf-8
Content-Length: 47
Date: Wed, 21 Oct 2026 13:18:28 GMT
Connection: keep-alive
Keep-Alive: timeout=5
limite_ms debe ser un entero entre 100 y 10000
Границата не е каприз. revisor чака отговора на всяка услуга най-много limite_ms милисекунди, а без граница лимит от часове би го оставил да чака услуга, която не отговаря, с връзката заета през цялото това време; а лимит от нула би я счел за извън строй, без изобщо да я е проверил. Числово поле, което клиентът контролира и сървърът използва, за да реши колко работа да свърши, е поле, което винаги се ограничава. Валидацията е преди проверката на дестинацията, защото проверката на дестинацията резолва име чрез DNS, а няма смисъл да се хаби тази заявка за регистрация, която така или иначе ще бъде отхвърлена.
Забранена дестинация. Защитата срещу SSRF от Урок 1 остава на мястото си: регистрация, която сочи към адреса за метаданни на облака или към име, което се резолва до 127.0.0.1, се отхвърля с 400 destino prohibido: … („забранена дестинация: …“). Правилото е това от Урок 1 и покрива много повече от частните мрежи. При IPv4 това е черен списък, копиран от регистъра на IANA за адреси със специално предназначение: освен 10/8, 172.16/12 и 192.168/16 отхвърля споделеното пространство на доставчиците (100.64.0.0/10, където един облак публикува своите метаданни), мрежите за документация и за тестове на производителността, multicast и резервирания блок 240.0.0.0/4. При IPv6 това е бял списък: приема се само глобалният unicast, 2000::/3, без някои специални блокове, които попадат в него (документация, Teredo). А преди да приложи който и да е от двата, изважда IPv4 адреса, който някои IPv6 адреси носят скрит в битовете си (64:ff9b::…, 2002:…, ::…), и го проверява като IPv4: [2002:a9fe:a9fe::] отвътре е 169.254.169.254. Пълното изпълнение от 2.7 го проверява с дванадесет регистрации, по една за всеки блок, и дванадесетте се отхвърлят:
$ curl -sS -w ' [%{http_code}]\n' -X POST http://127.0.0.1:18080/servicios -H 'Authorization: Bearer escritura-a' -H 'Content-Type: application/json' -d '{"nombre":"rango-1","url":"http://100.100.100.200/latest/meta-data/"}'
destino prohibido: 100.100.100.200:80: dirección privada o reservada
[400]
$ curl -sS -w ' [%{http_code}]\n' -X POST http://127.0.0.1:18080/servicios -H 'Authorization: Bearer escritura-a' -H 'Content-Type: application/json' -d '{"nombre":"rango-2","url":"http://[64:ff9b::a9fe:a9fe]/"}'
destino prohibido: [64:ff9b::a9fe:a9fe]:80: dirección privada o reservada
[400]
$ curl -sS -w ' [%{http_code}]\n' -X POST http://127.0.0.1:18080/servicios -H 'Authorization: Bearer escritura-a' -H 'Content-Type: application/json' -d '{"nombre":"rango-3","url":"http://224.0.0.1/"}'
destino prohibido: 224.0.0.1:80: dirección privada o reservada
[400]
$ curl -sS -w ' [%{http_code}]\n' -X POST http://127.0.0.1:18080/servicios -H 'Authorization: Bearer escritura-a' -H 'Content-Type: application/json' -d '{"nombre":"rango-4","url":"http://[ff02::1]/"}'
destino prohibido: [ff02::1]:80: dirección privada o reservada
[400]
$ curl -sS -w ' [%{http_code}]\n' -X POST http://127.0.0.1:18080/servicios -H 'Authorization: Bearer escritura-a' -H 'Content-Type: application/json' -d '{"nombre":"rango-5","url":"http://[2002:a9fe:a9fe::]/"}'
destino prohibido: [2002:a9fe:a9fe::]:80: dirección privada o reservada
[400]
$ curl -sS -w ' [%{http_code}]\n' -X POST http://127.0.0.1:18080/servicios -H 'Authorization: Bearer escritura-a' -H 'Content-Type: application/json' -d '{"nombre":"rango-6","url":"http://[::7f00:1]/"}'
destino prohibido: [::7f00:1]:80: dirección privada o reservada
[400]
$ curl -sS -w ' [%{http_code}]\n' -X POST http://127.0.0.1:18080/servicios -H 'Authorization: Bearer escritura-a' -H 'Content-Type: application/json' -d '{"nombre":"rango-7","url":"http://[64:ff9b:1::a9fe:a9fe]/"}'
destino prohibido: [64:ff9b:1::a9fe:a9fe]:80: dirección privada o reservada
[400]
$ curl -sS -w ' [%{http_code}]\n' -X POST http://127.0.0.1:18080/servicios -H 'Authorization: Bearer escritura-a' -H 'Content-Type: application/json' -d '{"nombre":"rango-8","url":"http://240.0.0.1/"}'
destino prohibido: 240.0.0.1:80: dirección privada o reservada
[400]
$ curl -sS -w ' [%{http_code}]\n' -X POST http://127.0.0.1:18080/servicios -H 'Authorization: Bearer escritura-a' -H 'Content-Type: application/json' -d '{"nombre":"rango-9","url":"http://198.18.0.1/"}'
destino prohibido: 198.18.0.1:80: dirección privada o reservada
[400]
$ curl -sS -w ' [%{http_code}]\n' -X POST http://127.0.0.1:18080/servicios -H 'Authorization: Bearer escritura-a' -H 'Content-Type: application/json' -d '{"nombre":"rango-10","url":"http://192.0.2.1/"}'
destino prohibido: 192.0.2.1:80: dirección privada o reservada
[400]
$ curl -sS -w ' [%{http_code}]\n' -X POST http://127.0.0.1:18080/servicios -H 'Authorization: Bearer escritura-a' -H 'Content-Type: application/json' -d '{"nombre":"rango-11","url":"http://[2001:db8::1]/"}'
destino prohibido: [2001:db8::1]:80: dirección privada o reservada
[400]
$ curl -sS -w ' [%{http_code}]\n' -X POST http://127.0.0.1:18080/servicios -H 'Authorization: Bearer escritura-a' -H 'Content-Type: application/json' -d '{"nombre":"rango-12","url":"http://[2001:0:4136:e378:8000:63bf:3fff:fdd2]/"}'
destino prohibido: [2001:0:4136:e378:8000:63bf:3fff:fdd2]:80: dirección privada o reservada
[400]
Последните три имат нещо общо: тялото е могло да се прочете, а неуспехът е в това, че прочетеното не може да се изпълни. Урок 3 въвежда записана политика за тази разлика: 400 за онова, което не може да се прочете, и 422 за онова, което се чете, но не може да се изпълни. Засега 422 не съществува в revisor и всичко от тялото, което не може да се използва, е 400. Това, че кодът е един и същ, не означава, че и тялото трябва да е същото: текстът на всеки 400 казва какво се е провалило, и това е разликата между полезния 400 и онзи от сцената в началото.
2.7 Пълният revisor
2.7.1 Файловете, които се променят
revisor от този урок живее в папката ~/curso-http/fig02_01/. Ако имаш папката от Урок 1, копирай я с това име и промени три файла: един нов, autenticacion.ts (раздел 2.3.2), и два променени, rutas.ts и servicios.ts, дадени тук изцяло:
// fig02_01/rutas.ts — la puerta de la API: qué ruta existe, qué método admite y quién puede usarlo.
import type { IncomingMessage, ServerResponse } from "node:http";
import { identificar, motivo, reto, type Credencial } from "./autenticacion.ts";
import { enviarVacio, fallar } from "./respuestas.ts";
import {
consultar, consultarRevision, darDeAlta, listar, pedirRevision, retirar, type Contexto,
} from "./servicios.ts";
type Manejador = (contexto: Contexto) => void | Promise<void>;
// Cada método dice qué función lo atiende y qué rol mínimo exige.
type Metodo = { manejador: Manejador; escribe: boolean };
type Ruta = { patron: RegExp; metodos: Record<string, Metodo> };
const lee = (manejador: Manejador): Metodo => ({ manejador, escribe: false });
const escribe = (manejador: Manejador): Metodo => ({ manejador, escribe: true });
const RUTAS: Ruta[] = [
{ patron: /^\/servicios$/, metodos: { GET: lee(listar), POST: escribe(darDeAlta) } },
{ patron: /^\/servicios\/(\d+)$/, metodos: { GET: lee(consultar), DELETE: escribe(retirar) } },
{ patron: /^\/servicios\/(\d+)\/revisiones$/, metodos: { POST: escribe(pedirRevision) } },
{ patron: /^\/revisiones\/(\d+)$/, metodos: { GET: lee(consultarRevision) } },
];
function redirigir(ruta: string, consulta: string, respuesta: ServerResponse): boolean {
if (ruta === "/viejo/servicios" || ruta.startsWith("/viejo/servicios/")) {
enviarVacio(respuesta, 301, { Location: ruta.slice("/viejo".length) + consulta });
return true;
}
if (ruta === "/api/servicios" || ruta.startsWith("/api/servicios/")) {
enviarVacio(respuesta, 308, { Location: ruta.slice("/api".length) + consulta });
return true;
}
return false;
}
// Los métodos que admite una ruta, para la cabecera Allow (GET trae HEAD consigo).
function permitidos(ruta: Ruta): string {
const metodos = Object.keys(ruta.metodos);
if (metodos.includes("GET")) metodos.splice(metodos.indexOf("GET") + 1, 0, "HEAD");
return metodos.join(", ");
}
function puede(credencial: Credencial, metodo: Metodo): boolean {
return !metodo.escribe || credencial.rol === "escritor" || credencial.rol === "admin";
}
export async function atender(peticion: IncomingMessage, respuesta: ServerResponse): Promise<void> {
console.log(`${peticion.method} ${peticion.url}`);
const url = new URL(peticion.url ?? "/", "http://revisor");
if (redirigir(url.pathname, url.search, respuesta)) return;
// 1. ¿Existe la ruta?
let ruta: Ruta | undefined;
let parametros: string[] = [];
for (const candidata of RUTAS) {
const encontrada = candidata.patron.exec(url.pathname);
if (encontrada !== null) {
ruta = candidata;
parametros = encontrada.slice(1);
break;
}
}
if (ruta === undefined) {
fallar(respuesta, 404, "no existe esa ruta");
return;
}
// 2. ¿Admite este método?
const nombreMetodo = peticion.method === "HEAD" ? "GET" : (peticion.method ?? "GET");
// Object.hasOwn y no ruta.metodos[nombreMetodo] a secas: el método lo escribe el cliente, y un objeto
// también «tiene» lo que hereda de Object.prototype (constructor, toString…). Igual que las credenciales.
const metodo = Object.hasOwn(ruta.metodos, nombreMetodo) ? ruta.metodos[nombreMetodo] : undefined;
if (metodo === undefined) {
fallar(respuesta, 405, `${peticion.method} no se admite en ${url.pathname}`, { Allow: permitidos(ruta) });
return;
}
// 3. ¿Quién eres?
const identificacion = identificar(peticion);
if (identificacion.tipo !== "valida") {
const detalle = motivo(identificacion);
fallar(respuesta, 401, detalle, { "WWW-Authenticate": reto(identificacion) });
return;
}
// 4. ¿Puedes?
if (!puede(identificacion.credencial, metodo)) {
fallar(respuesta, 403, "tu credencial solo puede leer; para escribir pide una con rol de escritor");
return;
}
// 5, 6 y 7. ¿Existe el recurso? ¿En qué formato viene? ¿Se puede leer? Lo decide cada manejador.
await metodo.manejador({ peticion, respuesta, parametros, credencial: identificacion.credencial });
}
// fig02_01/servicios.ts — lo que hace cada ruta de la API del revisor.
import type { IncomingMessage, ServerResponse } from "node:http";
import {
buscarRevision, buscarServicio, crearRevision, crearServicio, etiqueta, listarServicios,
retirarServicio, terminarRevision, type Revision, type Servicio,
} from "./almacen.ts";
import type { Credencial } from "./autenticacion.ts";
import { revisarDestino } from "./destinos.ts";
import { coincideDebil, hayCoincidencia } from "./etiquetas.ts";
import { revisar } from "./cliente.ts";
import { CuerpoDemasiadoGrande, enviarJson, enviarVacio, fallar, leerCuerpo, TOPE_CUERPO, type Cabeceras } from "./respuestas.ts";
export type Contexto = {
peticion: IncomingMessage;
respuesta: ServerResponse;
parametros: string[];
credencial: Credencial;
};
const ESPERA_EN_COLA_MS = 1000;
function representar(servicio: Servicio): object {
return { id: servicio.id, nombre: servicio.nombre, url: servicio.url, limite_ms: servicio.limiteMs };
}
function representarRevision(revision: Revision): object {
return { id: revision.id, servicio: revision.servicio, estado: revision.estado, resultado: revision.resultado };
}
// Un servicio de otro equipo se trata como si no existiera: el 404 esconde a propósito.
function visible(id: string, credencial: Credencial): Servicio | undefined {
const servicio = buscarServicio(Number(id));
if (servicio === undefined) return undefined;
return credencial.equipo === "*" || servicio.equipo === credencial.equipo ? servicio : undefined;
}
function noExiste(respuesta: ServerResponse, id: string): void {
fallar(respuesta, 404, `no existe el servicio ${id}`);
}
// Lee el cuerpo como JSON. Si no puede, ya respondió 415 o 400 y devuelve undefined.
async function leerJson(peticion: IncomingMessage, respuesta: ServerResponse): Promise<Record<string, unknown> | undefined> {
const tipo = (peticion.headers["content-type"] ?? "").split(";")[0].trim().toLowerCase();
if (tipo !== "application/json") {
fallar(respuesta, 415, `el cuerpo debe ser application/json, no ${tipo === "" ? "(sin tipo)" : tipo}`, {
Accept: "application/json",
});
return undefined;
}
try {
const datos: unknown = JSON.parse(await leerCuerpo(peticion));
if (typeof datos === "object" && datos !== null && !Array.isArray(datos)) return datos as Record<string, unknown>;
} catch (error) {
// 413 (RFC 9110, sección 15.5.14): el cuerpo pasa del tope. «Connection: close» hace que Node cierre
// la conexión al terminar de responder, en vez de seguir recibiendo lo que el cliente quería mandar.
if (error instanceof CuerpoDemasiadoGrande) {
fallar(respuesta, 413, `el cuerpo pasa de ${TOPE_CUERPO} bytes`, { Connection: "close" });
return undefined;
}
// si no, cae al 400 de abajo
}
fallar(respuesta, 400, "el cuerpo no es un objeto JSON");
return undefined;
}
// SSRF: el revisor se conecta a la URL que alguien escribió. Se revisa al registrarla y otra vez
// antes de cada conexión (en cliente.ts). Si no se permite, ya respondió y devuelve false.
async function destinoPermitido(url: string, respuesta: ServerResponse): Promise<boolean> {
const veredicto = await revisarDestino(url);
if (veredicto.permitido) return true;
fallar(respuesta, 400, `destino prohibido: ${veredicto.motivo}`);
return false;
}
export function listar({ respuesta, credencial }: Contexto): void {
const propios = listarServicios().filter((s) => credencial.equipo === "*" || s.equipo === credencial.equipo);
enviarJson(respuesta, 200, { servicios: propios.map(representar) });
}
export async function darDeAlta({ peticion, respuesta, credencial }: Contexto): Promise<void> {
const datos = await leerJson(peticion, respuesta);
if (datos === undefined) return;
if (typeof datos.nombre !== "string" || typeof datos.url !== "string") {
fallar(respuesta, 400, "faltan nombre y url");
return;
}
// limite_ms es opcional, pero si viene se acota: sin cota, un límite de horas dejaría al revisor
// esperando a un servicio que no responde, y uno de 0 lo daría por caído sin consultarlo.
const limiteMs = datos.limite_ms === undefined ? 2000 : datos.limite_ms;
if (typeof limiteMs !== "number" || !Number.isInteger(limiteMs) || limiteMs < 100 || limiteMs > 10000) {
fallar(respuesta, 400, "limite_ms debe ser un entero entre 100 y 10000");
return;
}
if (!(await destinoPermitido(datos.url, respuesta))) return;
const servicio = crearServicio({ nombre: datos.nombre, url: datos.url, limiteMs, equipo: credencial.equipo });
enviarJson(respuesta, 201, representar(servicio), {
Location: `/servicios/${servicio.id}`,
ETag: etiqueta(servicio),
});
}
export function consultar({ peticion, respuesta, parametros, credencial }: Contexto): void {
const servicio = visible(parametros[0], credencial);
if (servicio === undefined) {
noExiste(respuesta, parametros[0]);
return;
}
const actual = etiqueta(servicio);
// If-None-Match trae «*» o una lista, y se compara en débil (RFC 9110, secciones 13.1.2 y 8.8.3.2).
const condicion = peticion.headers["if-none-match"];
if (condicion !== undefined && hayCoincidencia(condicion, actual, coincideDebil)) {
enviarVacio(respuesta, 304, { ETag: actual });
return;
}
enviarJson(respuesta, 200, representar(servicio), { ETag: actual });
}
export function retirar({ respuesta, parametros, credencial }: Contexto): void {
const servicio = visible(parametros[0], credencial);
if (servicio === undefined) {
noExiste(respuesta, parametros[0]);
return;
}
retirarServicio(servicio.id);
enviarVacio(respuesta, 204);
}
export function pedirRevision({ respuesta, parametros, credencial }: Contexto): void {
const servicio = visible(parametros[0], credencial);
if (servicio === undefined) {
noExiste(respuesta, parametros[0]);
return;
}
const revision = crearRevision(servicio.id);
setTimeout(async () => {
terminarRevision(revision.id, await revisar(servicio.url, servicio.limiteMs));
}, ESPERA_EN_COLA_MS);
enviarJson(respuesta, 202, representarRevision(revision), { Location: `/revisiones/${revision.id}` });
}
export function consultarRevision({ respuesta, parametros, credencial }: Contexto): void {
const revision = buscarRevision(Number(parametros[0]));
if (revision === undefined || visible(String(revision.servicio), credencial) === undefined) {
fallar(respuesta, 404, `no existe la revisión ${parametros[0]}`);
return;
}
const cabeceras: Cabeceras = revision.estado === "pendiente" ? { "Retry-After": "1" } : {};
enviarJson(respuesta, 200, representarRevision(revision), cabeceras);
}
В servicios.ts, освен онова, което вече видя (visible, noExiste, leerJson и границата на limite_ms), се промени Contexto: сега той носи идентификационните данни, които входната точка е проверила, и всеки манипулатор ги получава. Нито един манипулатор не чете отново заглавката Authorization; входната точка го е направила веднъж и му е предала резултата. Така има едно-единствено място, където се решава кой си.
2.7.2 Файловете, които не се променят
Останалите файлове са тези от Урок 1, без нито една промяна. Дадени са изцяло, за да може revisor да се сглоби от този урок:
// fig02_01/almacen.ts — los servicios que observa el revisor y las revisiones pedidas, en memoria.
import { randomBytes } from "node:crypto";
export type Servicio = {
id: number;
nombre: string;
url: string;
limiteMs: number;
equipo: string;
version: number;
};
export type Resultado = {
estado: "ARRIBA" | "LENTO" | "CAIDO";
codigo: number | null;
ms: number | null;
detalle: string;
};
export type Revision = {
id: number;
servicio: number;
estado: "pendiente" | "terminada";
resultado: Resultado | null;
};
export type DatosServicio = { nombre: string; url: string; limiteMs: number; equipo: string };
const servicios = new Map<number, Servicio>();
const revisiones = new Map<number, Revision>();
let siguienteServicio = 1;
let siguienteRevision = 1;
export function crearServicio(datos: DatosServicio): Servicio {
const servicio: Servicio = { id: siguienteServicio++, ...datos, version: 1 };
servicios.set(servicio.id, servicio);
return servicio;
}
export function buscarServicio(id: number): Servicio | undefined {
return servicios.get(id);
}
export function listarServicios(): Servicio[] {
return [...servicios.values()];
}
export function retirarServicio(id: number): boolean {
return servicios.delete(id);
}
export function crearRevision(servicio: number): Revision {
const revision: Revision = { id: siguienteRevision++, servicio, estado: "pendiente", resultado: null };
revisiones.set(revision.id, revision);
return revision;
}
export function buscarRevision(id: number): Revision | undefined {
return revisiones.get(id);
}
export function terminarRevision(id: number, resultado: Resultado): void {
const revision = revisiones.get(id);
if (revision !== undefined) {
revision.estado = "terminada";
revision.resultado = resultado;
}
}
// La etiqueta (ETag) cambia cada vez que cambia el servicio: "id-versión-arranque".
// El almacén vive en memoria: al reiniciar el revisor, los id y las versiones vuelven a empezar, y
// "4-1" podría describir otro servicio, con lo que un cliente recibiría un 304 falso (RFC 9110,
// sección 8.8.1: una etiqueta no se reutiliza para otra representación). Por eso cada arranque
// sortea un identificador propio: tras reiniciar, ninguna etiqueta vieja coincide.
const ARRANQUE = randomBytes(4).toString("hex");
export function etiqueta(servicio: Servicio): string {
return `"${servicio.id}-${servicio.version}-${ARRANQUE}"`;
}
// Tres servicios de arranque, para que cada corrida empiece igual.
crearServicio({ nombre: "catalogo", url: "http://127.0.0.1:18081/codigo/200", limiteMs: 2000, equipo: "a" });
crearServicio({ nombre: "pagos", url: "http://127.0.0.1:18081/codigo/503", limiteMs: 2000, equipo: "a" });
crearServicio({ nombre: "nomina", url: "http://127.0.0.1:18081/codigo/200", limiteMs: 2000, equipo: "b" });
// fig02_01/cliente.ts — la cara cliente del revisor: consulta un servicio y clasifica lo que respondió.
import type { Resultado } from "./almacen.ts";
import { revisarDestino } from "./destinos.ts";
export async function revisar(url: string, limiteMs: number): Promise<Resultado> {
// Antes de CADA conexión se vuelve a revisar el destino: el DNS pudo cambiar desde el alta.
// LÍMITE DECLARADO: esto NO cierra el DNS rebinding. fetch vuelve a resolver el nombre, y entre
// esta revisión y la conexión el nombre puede apuntar a otra IP. La lección 8 lo completa:
// se conecta a la IP ya validada, con http.request y su opción lookup.
const veredicto = await revisarDestino(url);
if (!veredicto.permitido) return { estado: "CAIDO", codigo: null, ms: null, detalle: `destino prohibido: ${veredicto.motivo}` };
const inicio = performance.now();
try {
const respuesta = await fetch(url, { redirect: "manual", signal: AbortSignal.timeout(limiteMs) });
await respuesta.body?.cancel();
const ms = Math.round(performance.now() - inicio);
const detalle = String(respuesta.status);
if (respuesta.status >= 400) return { estado: "CAIDO", codigo: respuesta.status, ms, detalle };
return { estado: ms >= limiteMs / 2 ? "LENTO" : "ARRIBA", codigo: respuesta.status, ms, detalle };
} catch (error) {
const agotado = error instanceof DOMException && error.name === "TimeoutError";
return { estado: "CAIDO", codigo: null, ms: null, detalle: agotado ? "tiempo agotado" : "sin conexión" };
}
}
// fig02_01/destinos.ts — a dónde puede conectarse el revisor. Defensa contra SSRF.
//
// SSRF (server-side request forgery): quien da de alta un servicio escribe una URL, y el revisor
// se conecta a ella DESDE SU RED. Sin esta revisión, cualquiera con permiso de escritura podría hacer
// que el revisor consulte 127.0.0.1, la red interna o 169.254.169.254, donde las nubes publican los
// metadatos de la máquina (y a veces sus credenciales). Un revisor de servicios es el caso típico:
// conectarse a URLs ajenas es justo su trabajo, así que no se puede quitar; se acota.
//
// La regla: se resuelve el nombre y CADA dirección debe estar en la lista de destinos permitidos
// (ip:puerto) o ser pública. Se valida al registrar la URL Y antes de cada conexión, porque el DNS
// puede cambiar entre una y otra.
//
// LÍMITE DECLARADO: aun validando antes de cada fetch, el DNS puede cambiar entre la validación y la
// conexión (DNS rebinding). La defensa completa es conectarse a la IP ya validada (con http.request
// y su opción lookup), no volver a resolver el nombre. La lección 8 la hace en cliente.ts; hasta
// entonces, el límite queda escrito aquí.
import { lookup } from "node:dns/promises";
import { BlockList, isIP } from "node:net";
// Destinos permitidos aunque sean privados: el laboratorio del curso. Se cambian con REVISOR_DESTINOS.
const PERMITIDOS = new Set((process.env.REVISOR_DESTINOS ?? "127.0.0.1:18081").split(",").map((d) => d.trim()));
// Qué direcciones son «privadas o reservadas». Fuente: los registros de direcciones de uso especial
// de la IANA (IPv4 e IPv6, que actualizan al RFC 6890) y la OWASP SSRF Prevention Cheat Sheet.
//
// IPv4 va con LISTA NEGRA: no existe un bloque «global» que se pueda permitir entero, así que se
// rechazan los bloques del registro que no son alcanzables desde internet, más el multicast.
const IPV4_PROHIBIDAS = new BlockList();
IPV4_PROHIBIDAS.addSubnet("0.0.0.0", 8, "ipv4"); // «esta red», incluye 0.0.0.0
IPV4_PROHIBIDAS.addSubnet("10.0.0.0", 8, "ipv4"); // uso privado (RFC 1918)
IPV4_PROHIBIDAS.addSubnet("100.64.0.0", 10, "ipv4"); // espacio compartido (CGNAT); Alibaba Cloud publica ahí sus metadatos
IPV4_PROHIBIDAS.addSubnet("127.0.0.0", 8, "ipv4"); // loopback
IPV4_PROHIBIDAS.addSubnet("169.254.0.0", 16, "ipv4"); // enlace local, incluye los metadatos de la nube
IPV4_PROHIBIDAS.addSubnet("172.16.0.0", 12, "ipv4"); // uso privado (RFC 1918)
IPV4_PROHIBIDAS.addSubnet("192.0.0.0", 24, "ipv4"); // asignaciones de protocolo del IETF
IPV4_PROHIBIDAS.addSubnet("192.0.2.0", 24, "ipv4"); // documentación (TEST-NET-1)
IPV4_PROHIBIDAS.addSubnet("192.88.99.0", 24, "ipv4"); // relevo 6to4, retirado
IPV4_PROHIBIDAS.addSubnet("192.168.0.0", 16, "ipv4"); // uso privado (RFC 1918)
IPV4_PROHIBIDAS.addSubnet("198.18.0.0", 15, "ipv4"); // pruebas de rendimiento
IPV4_PROHIBIDAS.addSubnet("198.51.100.0", 24, "ipv4"); // documentación (TEST-NET-2)
IPV4_PROHIBIDAS.addSubnet("203.0.113.0", 24, "ipv4"); // documentación (TEST-NET-3)
IPV4_PROHIBIDAS.addSubnet("224.0.0.0", 4, "ipv4"); // multicast
IPV4_PROHIBIDAS.addSubnet("240.0.0.0", 4, "ipv4"); // reservado, incluye 255.255.255.255 (difusión)
// IPv6 va con LISTA BLANCA: solo 2000::/3, el unicast global. Así quedan fuera de un golpe ::1, ::,
// fc00::/7, fe80::/10, el multicast ff00::/8, 100::/64 y 64:ff9b:1::/48 (traducción local, RFC 8215),
// sin nombrarlos uno por uno. Dentro de 2000::/3 se rechazan los bloques que no son de internet.
const IPV6_GLOBAL = new BlockList();
IPV6_GLOBAL.addSubnet("2000::", 3, "ipv6");
const IPV6_PROHIBIDAS = new BlockList();
IPV6_PROHIBIDAS.addSubnet("2001::", 23, "ipv6"); // protocolos del IETF: Teredo (2001::/32), pruebas, ORCHID…
IPV6_PROHIBIDAS.addSubnet("2001:db8::", 32, "ipv6"); // documentación
IPV6_PROHIBIDAS.addSubnet("3fff::", 20, "ipv6"); // documentación
IPV6_PROHIBIDAS.addSubnet("5f00::", 16, "ipv6"); // segment routing (SRv6)
// Cuatro formatos IPv6 llevan una IPv4 adentro. La IPv4 se saca y se revisa contra la lista IPv4:
// [64:ff9b::a9fe:a9fe] es 169.254.169.254 por dentro. Se revisan ANTES que la lista blanca, porque
// ::ffff:8.8.8.8 o 2002:808:808:: llevan una dirección pública y deben pasar.
type Embebida = { prefijo: BlockList; ipv4: (g: number[]) => number[] };
function prefijo(direccion: string, bits: number): BlockList {
const lista = new BlockList();
lista.addSubnet(direccion, bits, "ipv6");
return lista;
}
const ultimos32 = (g: number[]): number[] => [g[6], g[7]];
const EMBEBIDAS: Embebida[] = [
{ prefijo: prefijo("::ffff:0:0", 96), ipv4: ultimos32 }, // IPv4 mapeada (RFC 4291)
{ prefijo: prefijo("::", 96), ipv4: ultimos32 }, // IPv4 compatible, obsoleta; incluye :: y ::1 (0.0.0.0 y 0.0.0.1)
{ prefijo: prefijo("64:ff9b::", 96), ipv4: ultimos32 }, // NAT64 (RFC 6052)
{ prefijo: prefijo("2002::", 16), ipv4: (g) => [g[1], g[2]] }, // 6to4 (RFC 3056): la IPv4 va en los bits 16 a 47
];
// Los ocho grupos de 16 bits de una IPv6 («::» rellena con ceros; acepta la IPv4 escrita al final).
function grupos(ipv6: string): number[] {
let texto = ipv6;
const cola = /(\d+)\.(\d+)\.(\d+)\.(\d+)$/.exec(texto);
if (cola !== null) {
const [a, b, c, d] = cola.slice(1).map(Number);
texto = texto.slice(0, cola.index) + ((a << 8) | b).toString(16) + ":" + ((c << 8) | d).toString(16);
}
const [izquierda, derecha] = texto.split("::");
const antes = izquierda === "" ? [] : izquierda.split(":");
const despues = derecha === undefined || derecha === "" ? [] : derecha.split(":");
const ceros = derecha === undefined ? [] : Array<string>(8 - antes.length - despues.length).fill("0");
return [...antes, ...ceros, ...despues].map((grupo) => parseInt(grupo, 16));
}
function privada(direccion: string): boolean {
if (isIP(direccion) === 4) return IPV4_PROHIBIDAS.check(direccion, "ipv4");
const ipv6 = direccion.replace(/%.*$/, ""); // sin el índice de zona (fe80::1%en0)
for (const { prefijo, ipv4 } of EMBEBIDAS) {
if (prefijo.check(ipv6, "ipv6")) {
const [alto, bajo] = ipv4(grupos(ipv6));
return IPV4_PROHIBIDAS.check([alto >> 8, alto & 0xff, bajo >> 8, bajo & 0xff].join("."), "ipv4");
}
}
return !IPV6_GLOBAL.check(ipv6, "ipv6") || IPV6_PROHIBIDAS.check(ipv6, "ipv6");
}
export type Veredicto = { permitido: true } | { permitido: false; motivo: string };
export async function revisarDestino(texto: string): Promise<Veredicto> {
let url: URL;
try {
url = new URL(texto);
} catch {
return { permitido: false, motivo: "no es una URL" };
}
if (url.protocol !== "http:" && url.protocol !== "https:") return { permitido: false, motivo: "solo http o https" };
const puerto = url.port !== "" ? url.port : url.protocol === "https:" ? "443" : "80";
const nombre = url.hostname.replace(/^\[|\]$/g, "");
let direcciones: string[];
try {
direcciones = isIP(nombre) !== 0 ? [nombre] : (await lookup(nombre, { all: true })).map((d) => d.address);
} catch {
return { permitido: false, motivo: `el nombre ${nombre} no se pudo resolver` };
}
// Basta con que UNA de las direcciones caiga en un rango prohibido para rechazar el destino.
const prohibidas = direcciones
.map((direccion) => (isIP(direccion) === 6 ? `[${direccion}]:${puerto}` : `${direccion}:${puerto}`))
.filter((destino, i) => !PERMITIDOS.has(destino) && privada(direcciones[i]));
if (prohibidas.length > 0) {
// Una dirección escrita por el cliente se repite tal cual (es suya). Un NOMBRE no enumera lo que resolvió:
// la lista cambia según el sistema (macOS devuelve IPv6 e IPv4, Linux a veces solo IPv4) y revelarla
// le da al cliente un mapa de la red interna. Para depurar, la dirección concreta (`prohibidas`) puede
// registrarla el servidor en su bitácora; aquí no se imprime para que la salida de la matriz sea igual en todo sistema.
if (isIP(nombre) !== 0) return { permitido: false, motivo: `${prohibidas.join(", ")}: dirección privada o reservada` };
return { permitido: false, motivo: `${nombre} resuelve a una dirección privada o reservada` };
}
return { permitido: true };
}
// fig02_01/etiquetas.ts — leer y comparar etiquetas (ETag) como pide el RFC 9110.
//
// Una etiqueta es un texto entre comillas, con «W/» delante si es débil: "1-1" o W/"1-1" (sección 8.8.3).
// If-None-Match e If-Match no traen una etiqueta: traen «*» o una LISTA separada por comas
// (secciones 13.1.1 y 13.1.2). Por eso comparar la cabecera entera con la etiqueta, como texto, falla:
// W/"1-1" y "9-9", "1-1" no son iguales a "1-1", y las dos deben coincidir en If-None-Match.
//
// Hay dos maneras de comparar (sección 8.8.3.2):
// débil — If-None-Match (304): basta con que el texto entre comillas sea igual, con W/ o sin él;
// fuerte — If-Match (412): además, ninguna de las dos puede ser débil.
// «*» aparte; si no, la lista de etiquetas tal como vienen (W/"1-1", "1-1"…).
export type Lista = "*" | string[];
// Lo que puede ir entre las comillas: visibles menos la comilla, y los bytes de 0x80 en adelante.
const OPACA = /^"[\x21\x23-\x7E\x80-\xFF]*"$/;
// Lee la cabecera respetando las comillas: una coma DENTRO de las comillas es parte de la etiqueta,
// no un separador. Los elementos vacíos («"1-1", , "2-1"») se saltan (sección 5.6.1).
// Si algo no es una etiqueta, la lista entera no vale y se devuelve vacía: así no coincide con nada,
// que es lo más prudente (If-None-Match responde completo; If-Match responde 412).
// «*» va sin compañía: mezclado con etiquetas es inválido (secciones 13.1.1 y 13.1.2).
export function interpretarLista(cabecera: string): Lista {
const texto = cabecera.trim();
if (texto === "*") return "*";
const etiquetas: string[] = [];
let i = 0;
while (i < texto.length) {
const letra = texto[i];
if (letra === " " || letra === "\t" || letra === ",") {
i++;
continue;
}
const inicio = i;
if (texto.startsWith("W/", i)) i += 2;
if (texto[i] !== '"') return [];
const cierre = texto.indexOf('"', i + 1);
if (cierre === -1) return [];
const etiqueta = texto.slice(inicio, cierre + 1);
if (!OPACA.test(etiqueta.replace(/^W\//, ""))) return [];
etiquetas.push(etiqueta);
i = cierre + 1;
// Después de una etiqueta solo pueden venir espacios y luego una coma o el final.
while (texto[i] === " " || texto[i] === "\t") i++;
if (i < texto.length && texto[i] !== ",") return [];
}
return etiquetas;
}
function esDebil(etiqueta: string): boolean {
return etiqueta.startsWith("W/");
}
function opaca(etiqueta: string): string {
return esDebil(etiqueta) ? etiqueta.slice(2) : etiqueta;
}
// Débil: W/"1" y "1" coinciden; W/"1" y W/"2" no.
export function coincideDebil(a: string, b: string): boolean {
return opaca(a) === opaca(b);
}
// Fuerte: solo "1" con "1". Si cualquiera de las dos es débil, no coinciden.
export function coincideFuerte(a: string, b: string): boolean {
return !esDebil(a) && !esDebil(b) && a === b;
}
// ¿Alguna etiqueta de la cabecera coincide con la vigente? «*» coincide si el recurso existe, y quien
// llama ya lo encontró antes de preguntar. Se elige la comparación: débil para If-None-Match,
// fuerte para If-Match.
export function hayCoincidencia(cabecera: string, vigente: string, comparar: (a: string, b: string) => boolean): boolean {
const lista = interpretarLista(cabecera);
if (lista === "*") return true;
return lista.some((etiqueta) => comparar(etiqueta, vigente));
}
// fig02_01/laboratorio.ts
// El banco de pruebas de la lección 0, en el puerto 18081: los servicios que el revisor consulta.
import { createServer, type IncomingMessage, type ServerResponse } from "node:http";
const PUERTO = 18081;
// Los nombres salen del registro de la IANA, no de Node, que guarda algunos viejos.
const NOMBRES: Partial<Record<number, string>> = {
200: "OK", 201: "Created", 202: "Accepted", 204: "No Content",
301: "Moved Permanently", 302: "Found", 304: "Not Modified",
307: "Temporary Redirect", 308: "Permanent Redirect",
400: "Bad Request", 401: "Unauthorized", 403: "Forbidden", 404: "Not Found",
405: "Method Not Allowed", 406: "Not Acceptable", 409: "Conflict", 410: "Gone",
412: "Precondition Failed", 413: "Content Too Large", 415: "Unsupported Media Type",
422: "Unprocessable Content", 428: "Precondition Required", 429: "Too Many Requests",
500: "Internal Server Error", 501: "Not Implemented", 502: "Bad Gateway",
503: "Service Unavailable", 504: "Gateway Timeout",
};
// Las cabeceras que cada código exige o suele llevar, con valores de ejemplo.
const CABECERAS: Partial<Record<number, Record<string, string>>> = {
201: { Location: "/servicios/demo" },
301: { Location: "/codigo/200" },
302: { Location: "/codigo/200" },
307: { Location: "/codigo/200" },
308: { Location: "/codigo/200" },
304: { ETag: '"v1"' },
401: { "WWW-Authenticate": 'Bearer realm="revisor"' },
405: { Allow: "GET, HEAD" },
429: { "Retry-After": "30" },
503: { "Retry-After": "120" },
};
// Los códigos finales que este banco puede producir y que no pueden llevar cuerpo:
// 204 y 304 (RFC 9112, sección 6.3) y 205 (RFC 9110, sección 15.3.6: «MUST NOT generate content»).
// Un 1xx tampoco puede llevarlo, pero este banco ya se niega a producir uno; y a un HEAD
// Node no le manda el cuerpo aunque el programa lo escriba.
const SIN_CUERPO = new Set([204, 205, 304]);
function enviar(respuesta: ServerResponse, codigo: number, explicacion: string): void {
const nombre = NOMBRES[codigo];
const cabeceras: Record<string, string> = { ...CABECERAS[codigo] };
let cuerpo = "";
if (!SIN_CUERPO.has(codigo)) {
cuerpo = `${codigo} ${nombre ?? "(sin nombre en este banco)"}\n${explicacion}\n`;
cabeceras["Content-Type"] = "text/plain; charset=utf-8";
cabeceras["Content-Length"] = String(Buffer.byteLength(cuerpo));
}
// El 205 no se enmarca solo como el 204 y el 304 (RFC 9112, sección 6.3): sin Content-Length ni Transfer-Encoding
// terminaría al cerrar la conexión, y Node mandaría «Transfer-Encoding: chunked» con un cuerpo vacío.
// «Content-Length: 0» dice dónde termina sin mandar contenido.
if (codigo === 205) cabeceras["Content-Length"] = "0";
if (nombre === undefined) {
respuesta.writeHead(codigo, cabeceras);
} else {
respuesta.writeHead(codigo, nombre, cabeceras);
}
respuesta.end(cuerpo);
}
function atender(peticion: IncomingMessage, respuesta: ServerResponse): void {
console.log(`${peticion.method} ${peticion.url}`);
if (peticion.method !== "GET" && peticion.method !== "HEAD") {
enviar(respuesta, 405, "el banco solo atiende GET y HEAD");
return;
}
const ruta = new URL(peticion.url ?? "/", "http://banco").pathname;
const pedido = /^\/codigo\/(\d{3})$/.exec(ruta);
if (pedido === null) {
enviar(respuesta, 404, "la única ruta es /codigo/NNN; prueba con /codigo/503");
return;
}
const codigo = Number(pedido[1]);
if (codigo < 100) {
enviar(respuesta, 400, "no existe un código de estado menor que 100");
} else if (codigo < 200) {
enviar(respuesta, 400, "un 1xx es una respuesta intermedia: no puede ser la última");
} else if (codigo > 599) {
enviar(respuesta, codigo, "un código mayor que 599 es inválido; el banco lo produce para que veas qué hace el cliente");
} else {
enviar(respuesta, codigo, `lo pediste en ${ruta}`);
}
}
createServer(atender).listen(PUERTO, "127.0.0.1", () => {
console.log(`laboratorio en http://127.0.0.1:${PUERTO}/codigo/NNN`);
});
// fig02_01/respuestas.ts — las tres formas de responder del revisor y la lectura del cuerpo.
import type { IncomingMessage, ServerResponse } from "node:http";
export type Cabeceras = Record<string, string>;
// Node trae los nombres viejos de estos dos códigos; aquí van los del registro de la IANA.
const FRASES: Partial<Record<number, string>> = { 413: "Content Too Large", 422: "Unprocessable Content" };
function escribirCabecera(respuesta: ServerResponse, codigo: number, cabeceras: Cabeceras): void {
const frase = FRASES[codigo];
if (frase === undefined) respuesta.writeHead(codigo, cabeceras);
else respuesta.writeHead(codigo, frase, cabeceras);
}
export function enviarJson(respuesta: ServerResponse, codigo: number, datos: unknown, cabeceras: Cabeceras = {}): void {
const cuerpo = JSON.stringify(datos) + "\n";
escribirCabecera(respuesta, codigo, {
...cabeceras,
"Content-Type": "application/json",
"Content-Length": String(Buffer.byteLength(cuerpo)),
});
respuesta.end(cuerpo);
}
// Para 204, 304 y las redirecciones: código y cabeceras, sin cuerpo.
export function enviarVacio(respuesta: ServerResponse, codigo: number, cabeceras: Cabeceras = {}): void {
// 204 y 304 no pueden llevar Content-Length de un cuerpo; los demás dicen «cero bytes».
const largo: Cabeceras = codigo === 204 || codigo === 304 ? {} : { "Content-Length": "0" };
escribirCabecera(respuesta, codigo, { ...cabeceras, ...largo });
respuesta.end();
}
// Un error: por ahora, una línea de texto. La lección 5 lo cambia por Problem Details.
export function fallar(respuesta: ServerResponse, codigo: number, detalle: string, cabeceras: Cabeceras = {}): void {
const cuerpo = detalle + "\n";
escribirCabecera(respuesta, codigo, {
...cabeceras,
"Content-Type": "text/plain; charset=utf-8",
"Content-Length": String(Buffer.byteLength(cuerpo)),
});
respuesta.end(cuerpo);
}
// Tope del cuerpo: 16 KiB. Un servicio es un nombre de hasta 40 caracteres, una URL y un número; cabe
// de sobra. Sin tope, leerCuerpo guardaba en memoria todo lo que llegara: un POST de 50 MB subía la
// memoria del revisor unos 150 MB, y cualquiera con la dirección podía llenarla.
export const TOPE_CUERPO = 16 * 1024;
// La señal de que el cuerpo pasó del tope. Quien llama responde 413 (RFC 9110, sección 15.5.14).
export class CuerpoDemasiadoGrande extends Error {}
// Cuando el cuerpo ya pasó del tope, el cliente puede seguir mandando. Si el servidor responde el 413 y destruye la
// conexión en ese momento, lo que todavía llega a una conexión cerrada se contesta con un RST del sistema operativo,
// y ese RST puede borrar el 413 antes de que el cliente lo lea: el cliente ve EPIPE o ECONNRESET en vez de la
// respuesta. El RFC 9112, sección 9.6, aconseja un cierre en etapas: responder, cerrar solo la escritura (media cerrada),
// seguir leyendo y tirando lo que llegue hasta que el cliente cierre o pase un plazo (el RFC no fija ninguno; el de 2 s es del curso), y cerrar del todo.
// Node, con «Connection: close», llama a socket.destroySoon() al terminar de responder, que cierra y destruye; aquí
// se cambia ese método, solo en esta conexión, para que únicamente cierre la escritura.
const ESPERA_CIERRE_MS = 2000;
function cerrarConCalma(peticion: IncomingMessage): void {
const socket = peticion.socket;
socket.destroySoon = (): void => {
socket.end();
};
peticion.resume();
const reloj = setTimeout(() => socket.destroy(), ESPERA_CIERRE_MS);
socket.once("close", () => clearTimeout(reloj));
}
// Lee el cuerpo contando los bytes. Si Content-Length ya anuncia más del tope, rechaza sin guardar nada;
// si no lo anuncia (cuerpo por partes) o miente, deja de guardar en cuanto se pasa. En los dos casos no
// guarda más de TOPE_CUERPO bytes. No se destruye la petición, porque con ella se iría la conexión y
// no se podría responder: se prepara el cierre en etapas (ver arriba) y el 413 sale de inmediato, con
// «Connection: close».
export function leerCuerpo(peticion: IncomingMessage): Promise<string> {
return new Promise((resolver, rechazar) => {
const demasiado = (): void => {
cerrarConCalma(peticion);
rechazar(new CuerpoDemasiadoGrande(`el cuerpo pasa de ${TOPE_CUERPO} bytes`));
};
if (Number(peticion.headers["content-length"] ?? 0) > TOPE_CUERPO) {
demasiado();
return;
}
const trozos: Buffer[] = [];
let total = 0;
const alLlegar = (trozo: Buffer): void => {
total += trozo.length;
if (total > TOPE_CUERPO) {
peticion.off("data", alLlegar);
peticion.off("end", alTerminar);
demasiado();
return;
}
trozos.push(trozo);
};
const alTerminar = (): void => resolver(Buffer.concat(trozos).toString("utf8"));
peticion.on("data", alLlegar);
peticion.on("end", alTerminar);
peticion.on("error", rechazar);
});
}
// fig02_01/servidor.ts — la cara servidor del revisor: la API que administra los servicios observados.
import { createServer, type IncomingMessage, type ServerResponse } from "node:http";
import { fallar } from "./respuestas.ts";
import { atender } from "./rutas.ts";
const PUERTO = 18080;
// Red de seguridad: un error que se escape de un manejador no debe tumbar el proceso. Sin este catch,
// la promesa rechazada no la espera nadie y Node termina el revisor para todos. Se responde 500 sin
// pila y el mensaje queda en la terminal. La lección 4 explica el 500 y le agrega una referencia.
async function atenderSinCaerse(peticion: IncomingMessage, respuesta: ServerResponse): Promise<void> {
try {
await atender(peticion, respuesta);
} catch (error) {
console.error(`error en ${peticion.method} ${peticion.url}: ${error instanceof Error ? error.message : String(error)}`);
if (!respuesta.headersSent) fallar(respuesta, 500, "error interno");
else respuesta.destroy();
}
}
createServer((peticion, respuesta) => void atenderSinCaerse(peticion, respuesta)).listen(PUERTO, "127.0.0.1", () => {
console.log(`revisor en http://127.0.0.1:${PUERTO}`);
});
{
"type": "module"
}
2.7.3 Пълното изпълнение
Отвори два терминала в ~/curso-http/fig02_01/. В първия — лабораторията, която е тестовият стенд от Урок 0 на порт 18081; във втория — revisor:
$ node laboratorio.ts
laboratorio en http://127.0.0.1:18081/codigo/NNN
$ node servidor.ts
revisor en http://127.0.0.1:18080
В трети терминал ето заявките от урока, по ред, с онова, което отговориха. Основните от предишните раздели са отново тук, заедно, за да можеш да ги сравниш последователно със своите:
$ curl -i http://127.0.0.1:18080/servicios
HTTP/1.1 401 Unauthorized
WWW-Authenticate: Bearer realm="revisor"
Content-Type: text/plain; charset=utf-8
Content-Length: 20
Date: Wed, 21 Oct 2026 13:18:28 GMT
Connection: keep-alive
Keep-Alive: timeout=5
falta la credencial
$ curl -i http://127.0.0.1:18080/servicios -H 'Authorization: Bearer inventada'
HTTP/1.1 401 Unauthorized
WWW-Authenticate: Bearer realm="revisor", error="invalid_token"
Content-Type: text/plain; charset=utf-8
Content-Length: 28
Date: Wed, 21 Oct 2026 13:18:28 GMT
Connection: keep-alive
Keep-Alive: timeout=5
la credencial no es válida
$ curl -i http://127.0.0.1:18080/servicios -H 'Authorization: Bearer constructor'
HTTP/1.1 401 Unauthorized
WWW-Authenticate: Bearer realm="revisor", error="invalid_token"
Content-Type: text/plain; charset=utf-8
Content-Length: 28
Date: Wed, 21 Oct 2026 13:18:28 GMT
Connection: keep-alive
Keep-Alive: timeout=5
la credencial no es válida
$ curl -i http://127.0.0.1:18080/servicios -H 'Authorization: Bearer lectura-a'
HTTP/1.1 200 OK
Content-Type: application/json
Content-Length: 189
Date: Wed, 21 Oct 2026 13:18:28 GMT
Connection: keep-alive
Keep-Alive: timeout=5
{"servicios":[{"id":1,"nombre":"catalogo","url":"http://127.0.0.1:18081/codigo/200","limite_ms":2000},{"id":2,"nombre":"pagos","url":"http://127.0.0.1:18081/codigo/503","limite_ms":2000}]}
$ curl -i http://127.0.0.1:18080/servicios/3 -H 'Authorization: Bearer lectura-a'
HTTP/1.1 404 Not Found
Content-Type: text/plain; charset=utf-8
Content-Length: 24
Date: Wed, 21 Oct 2026 13:18:28 GMT
Connection: keep-alive
Keep-Alive: timeout=5
no existe el servicio 3
$ curl -i http://127.0.0.1:18080/servicios/99 -H 'Authorization: Bearer lectura-a'
HTTP/1.1 404 Not Found
Content-Type: text/plain; charset=utf-8
Content-Length: 25
Date: Wed, 21 Oct 2026 13:18:28 GMT
Connection: keep-alive
Keep-Alive: timeout=5
no existe el servicio 99
$ curl -i http://127.0.0.1:18080/servicios/3 -H 'Authorization: Bearer escritura-b'
HTTP/1.1 200 OK
ETag: "3-1-a06a8084"
Content-Type: application/json
Content-Length: 86
Date: Wed, 21 Oct 2026 13:18:28 GMT
Connection: keep-alive
Keep-Alive: timeout=5
{"id":3,"nombre":"nomina","url":"http://127.0.0.1:18081/codigo/200","limite_ms":2000}
$ curl -i -X DELETE http://127.0.0.1:18080/servicios/1 -H 'Authorization: Bearer lectura-a'
HTTP/1.1 403 Forbidden
Content-Type: text/plain; charset=utf-8
Content-Length: 74
Date: Wed, 21 Oct 2026 13:18:28 GMT
Connection: keep-alive
Keep-Alive: timeout=5
tu credencial solo puede leer; para escribir pide una con rol de escritor
$ curl -i -X PATCH http://127.0.0.1:18080/servicios/1 -H 'Authorization: Bearer escritura-a'
HTTP/1.1 405 Method Not Allowed
Allow: GET, HEAD, DELETE
Content-Type: text/plain; charset=utf-8
Content-Length: 35
Date: Wed, 21 Oct 2026 13:18:28 GMT
Connection: keep-alive
Keep-Alive: timeout=5
PATCH no se admite en /servicios/1
$ curl -i http://127.0.0.1:18080/servicioss -H 'Authorization: Bearer escritura-a'
HTTP/1.1 404 Not Found
Content-Type: text/plain; charset=utf-8
Content-Length: 19
Date: Wed, 21 Oct 2026 13:18:28 GMT
Connection: keep-alive
Keep-Alive: timeout=5
no existe esa ruta
$ curl -i -X POST http://127.0.0.1:18080/servicios -H 'Authorization: Bearer escritura-a' -d 'nombre=inventario'
HTTP/1.1 415 Unsupported Media Type
Accept: application/json
Content-Type: text/plain; charset=utf-8
Content-Length: 74
Date: Wed, 21 Oct 2026 13:18:28 GMT
Connection: keep-alive
Keep-Alive: timeout=5
el cuerpo debe ser application/json, no application/x-www-form-urlencoded
$ curl -i -X POST http://127.0.0.1:18080/servicios -H 'Authorization: Bearer escritura-a' -H 'Content-Type: text/plain' -d 'inventario'
HTTP/1.1 415 Unsupported Media Type
Accept: application/json
Content-Type: text/plain; charset=utf-8
Content-Length: 51
Date: Wed, 21 Oct 2026 13:18:28 GMT
Connection: keep-alive
Keep-Alive: timeout=5
el cuerpo debe ser application/json, no text/plain
$ curl -i -X POST http://127.0.0.1:18080/servicios -H 'Authorization: Bearer escritura-a' -H 'Content-Type: application/json' -d '{"nombre": "inventario",'
HTTP/1.1 400 Bad Request
Content-Type: text/plain; charset=utf-8
Content-Length: 31
Date: Wed, 21 Oct 2026 13:18:28 GMT
Connection: keep-alive
Keep-Alive: timeout=5
el cuerpo no es un objeto JSON
$ curl -i -X POST http://127.0.0.1:18080/servicios -H 'Authorization: Bearer escritura-a' -H 'Content-Type: application/json' -d '{"nombre":"inventario","url":"http://127.0.0.1:18081/codigo/200"}'
HTTP/1.1 201 Created
Location: /servicios/4
ETag: "4-1-a06a8084"
Content-Type: application/json
Content-Length: 90
Date: Wed, 21 Oct 2026 13:18:28 GMT
Connection: keep-alive
Keep-Alive: timeout=5
{"id":4,"nombre":"inventario","url":"http://127.0.0.1:18081/codigo/200","limite_ms":2000}
$ curl -i -X POST http://127.0.0.1:18080/servicios -H 'Authorization: Bearer escritura-a' -H 'Content-Type: application/json' -d '{"nombre":"metadatos","url":"http://169.254.169.254/latest/meta-data/"}'
HTTP/1.1 400 Bad Request
Content-Type: text/plain; charset=utf-8
Content-Length: 70
Date: Wed, 21 Oct 2026 13:18:28 GMT
Connection: keep-alive
Keep-Alive: timeout=5
destino prohibido: 169.254.169.254:80: dirección privada o reservada
$ curl -i -X POST http://127.0.0.1:18080/servicios -H 'Authorization: Bearer escritura-a' -H 'Content-Type: application/json' -d '{"nombre":"yo-mismo","url":"http://localhost:18080/mantenimiento"}'
HTTP/1.1 400 Bad Request
Content-Type: text/plain; charset=utf-8
Content-Length: 75
Date: Wed, 21 Oct 2026 13:18:28 GMT
Connection: keep-alive
Keep-Alive: timeout=5
destino prohibido: localhost resuelve a una dirección privada o reservada
$ curl -i -X POST http://127.0.0.1:18080/servicios -H 'Authorization: Bearer escritura-a' -H 'Content-Type: application/json' -d '{"nombre":"eterno","url":"http://127.0.0.1:18081/codigo/200","limite_ms":3600000}'
HTTP/1.1 400 Bad Request
Content-Type: text/plain; charset=utf-8
Content-Length: 47
Date: Wed, 21 Oct 2026 13:18:28 GMT
Connection: keep-alive
Keep-Alive: timeout=5
limite_ms debe ser un entero entre 100 y 10000
Седемнадесет заявки и осем различни кода: [401 401 401 200 404 404 200 403 405 404 415 415 400 201 400 400 400]. Всяка грешка е „не“-то на един от седемте въпроса, а всеки успех — заявка, която ги е преминала всичките.
2.7.4 Етикетът продължава да работи зад входната точка
Входната точка не промени нищо от онова, което Урок 1 изгради: условните заявки с If-None-Match отговарят по същия начин, сега с идентификационни данни. ETag на revisor има три части, "id-версия-стартиране": последната са осем случайни цифри, които revisor генерира при всяко стартиране, за да не съвпадне на практика етикет отпреди рестартиране с такъв след него (това са 32 случайни бита: вероятност едно на 4 295 милиона за всяко предишно стартиране, а не математическа гаранция; Урок 1 обяснява защо: RFC 9110, раздел 8.8.1, не позволява етикет да се преизползва за друго представяне). Затова, за да повториш тези заявки, първо трябва да прочетеш текущия етикет. Изпълнението го запази в променлива на терминала, ETAG, с опцията -w '%header{etag}' на curl, която отпечатва само тази заглавка:
$ ETAG=$(curl -sS -o /dev/null -w '%header{etag}' http://127.0.0.1:18080/servicios/1 -H 'Authorization: Bearer lectura-a'); echo "ETAG=$ETAG"
ETAG="1-1-a06a8084"
$ curl -i http://127.0.0.1:18080/servicios/1 -H 'Authorization: Bearer lectura-a' -H "If-None-Match: W/$ETAG"
HTTP/1.1 304 Not Modified
ETag: "1-1-a06a8084"
Date: Wed, 21 Oct 2026 13:18:28 GMT
Connection: keep-alive
Keep-Alive: timeout=5
$ curl -i http://127.0.0.1:18080/servicios/1 -H 'Authorization: Bearer lectura-a' -H "If-None-Match: \"9-9\", $ETAG"
HTTP/1.1 304 Not Modified
ETag: "1-1-a06a8084"
Date: Wed, 21 Oct 2026 13:18:28 GMT
Connection: keep-alive
Keep-Alive: timeout=5
$ curl -i http://127.0.0.1:18080/servicios/1 -H 'Authorization: Bearer lectura-a' -H 'If-None-Match: *'
HTTP/1.1 304 Not Modified
ETag: "1-1-a06a8084"
Date: Wed, 21 Oct 2026 13:18:28 GMT
Connection: keep-alive
Keep-Alive: timeout=5
$ curl -i http://127.0.0.1:18080/servicios/1 -H 'Authorization: Bearer lectura-a' -H 'If-None-Match: "9-9", W/"8-8"'
HTTP/1.1 200 OK
ETag: "1-1-a06a8084"
Content-Type: application/json
Content-Length: 88
Date: Wed, 21 Oct 2026 13:18:28 GMT
Connection: keep-alive
Keep-Alive: timeout=5
{"id":1,"nombre":"catalogo","url":"http://127.0.0.1:18081/codigo/200","limite_ms":2000}
Първите три са 304: If-None-Match приема слабия етикет (W/), списък и *, както те научи Урок 1. Четвъртата, списък без текущия етикет, получава цялата услуга. А сега проверката с рестартирането: прочетен беше етикетът на услуга 3, revisor беше спрян и стартиран отново (в твоя терминал — с Ctrl+C и node servidor.ts), и услуга 3 беше поискана с етикета отпреди:
$ ANTES=$(curl -sS -o /dev/null -w '%header{etag}' http://127.0.0.1:18080/servicios/3 -H 'Authorization: Bearer escritura-b'); echo "ANTES=$ANTES"
ANTES="3-1-a06a8084"
$ curl -i http://127.0.0.1:18080/servicios/3 -H 'Authorization: Bearer escritura-b' -H "If-None-Match: $ANTES"
HTTP/1.1 200 OK
ETag: "3-1-ae8e4dbe"
Content-Type: application/json
Content-Length: 86
Date: Wed, 21 Oct 2026 13:18:29 GMT
Connection: keep-alive
Keep-Alive: timeout=5
{"id":3,"nombre":"nomina","url":"http://127.0.0.1:18081/codigo/200","limite_ms":2000}
200, с нов етикет: услуга 3 има същия id и същата версия като преди рестартирането, но осемте цифри в края се промениха и старото копие на клиента вече не се смята за валидно.
Терминалът на revisor записа всяка заявка, която му пристигна при изпълнението, в този ред: седемнайсетте от 2.7.3; тази от 2.2.3 и тази от 2.4.2; тези за етикета; тази от 2.3.1 с малки букви и тази с Basic (методът constructor от 2.3.3 не се появява, защото Node го отхвърли преди това); трите регистрации с големи тела от 2.6.4 и заявката, която проверява, че revisor още работи; регистрация с -d 'null', последвана от заявка, за да се провери, че revisor още работи (упражнение 4 го повтаря); дванайсетте регистрации на диапазони от 2.6.5 и двете от рестартирането, една преди и една след. Обърни внимание на второто revisor en …, това от втория живот на процеса:
revisor en http://127.0.0.1:18080
GET /servicios
GET /servicios
GET /servicios
GET /servicios
GET /servicios/3
GET /servicios/99
GET /servicios/3
DELETE /servicios/1
PATCH /servicios/1
GET /servicioss
POST /servicios
POST /servicios
POST /servicios
POST /servicios
POST /servicios
POST /servicios
POST /servicios
HEAD /servicios/1/revisiones
DELETE /servicios/99
GET /servicios/1
GET /servicios/1
GET /servicios/1
GET /servicios/1
GET /servicios/1
GET /servicios
GET /servicios
POST /servicios
POST /servicios
POST /servicios
GET /servicios/1
POST /servicios
GET /servicios/1
POST /servicios
POST /servicios
POST /servicios
POST /servicios
POST /servicios
POST /servicios
POST /servicios
POST /servicios
POST /servicios
POST /servicios
POST /servicios
POST /servicios
GET /servicios/3
revisor en http://127.0.0.1:18080
GET /servicios/3
Грешката, която ще видиш
403, който издава
Това е кодът, който почти всеки пише първия път, когато разделя услугите по екипи, защото се чете много естествено: търся услугата; ако не съществува — 404; ако съществува и не е твоя — 403. За да го предизвикаш, копирай папката fig02_01/ като rota/ и замени началото на consultar в rota/servicios.ts с това (това е фрагмент; пълната версия е в defectos/403/servicios.ts):
export function consultar({ peticion, respuesta, parametros, credencial }: Contexto): void {
const servicio = buscarServicio(Number(parametros[0]));
if (servicio === undefined) {
noExiste(respuesta, parametros[0]);
return;
}
if (credencial.equipo !== "*" && servicio.equipo !== credencial.equipo) {
fallar(respuesta, 403, `el servicio ${parametros[0]} es de otro equipo`);
return;
}
Стартирай лабораторията и revisor от rota/ и повтори двойката от 2.5.2:
$ curl -i http://127.0.0.1:18080/servicios/3 -H 'Authorization: Bearer lectura-a'
HTTP/1.1 403 Forbidden
Content-Type: text/plain; charset=utf-8
Content-Length: 32
Date: Wed, 21 Oct 2026 13:18:14 GMT
Connection: keep-alive
Keep-Alive: timeout=5
el servicio 3 es de otro equipo
$ curl -i http://127.0.0.1:18080/servicios/99 -H 'Authorization: Bearer lectura-a'
HTTP/1.1 404 Not Found
Content-Type: text/plain; charset=utf-8
Content-Length: 25
Date: Wed, 21 Oct 2026 13:18:14 GMT
Connection: keep-alive
Keep-Alive: timeout=5
no existe el servicio 99
Всеки отговор поотделно изглежда безупречен: 403 е позволен код за забранен ресурс, обяснява в тялото защо е отказал, а 404 казва истината. Проблемът е в двойката. Пусни програмата за изброяване:
$ node fig02_02.ts lectura-a
servicio 1: 200 {"id":1,"nombre":"catalogo","url":"http://127.0.0.1:18081/codigo/200","limite_ms":2000}
servicio 2: 200 {"id":2,"nombre":"pagos","url":"http://127.0.0.1:18081/codigo/503","limite_ms":2000}
servicio 3: 403 el servicio 3 es de otro equipo
servicio 4: 404 no existe el servicio 4
servicio 5: 404 no existe el servicio 5
servicio 6: 404 no existe el servicio 6
respuestas distintas: 3
1, 2 → 200 con datos
3 → 403 el servicio N es de otro equipo
4, 5, 6 → 404 no existe el servicio N
más de dos respuestas: lo que no es tuyo ya no se ve todo igual
Три групи, и програмата предупреждава за това. Услуга 3 получи отговор, който нито един несъществуващ идентификатор не получава, така че lectura-a вече знае, че услуга 3 съществува и че е на друг екип, без да е видял нито една от нейните данни. С идентификатори от 1 до 100 000 би знаел колко услуги има компанията и кои са на други екипи. Това е изброяването от карето в 2.5, предизвикано от четири реда, които никой не е написал със зъл умисъл.
Поправката е да се върнеш към visible: една-единствена функция, която връща undefined за чуждото и за несъществуващото, и един-единствен клон, който отговаря. Не е достатъчно да смениш 403 с 404, като оставиш двата клона: в деня, в който някой редактира текста на единия, но не и на другия, разликата се връща, този път в тялото. Спри програмите от rota/, когато приключиш.
Идентификационните данни, които никой не е издал
Втората грешка е онази от раздел 2.3.3. Копирай отново fig02_01/ като rota2/ и в rota2/autenticacion.ts замени таблицата и търсенето с формата с обект (пълната версия е в defectos/prototipo/autenticacion.ts):
const CREDENCIALES: Record<string, Credencial> = {
"lectura-a": { nombre: "lectura-a", equipo: "a", rol: "lector" },
"escritura-a": { nombre: "escritura-a", equipo: "a", rol: "escritor" },
"escritura-b": { nombre: "escritura-b", equipo: "b", rol: "escritor" },
"admin": { nombre: "admin", equipo: "*", rol: "admin" },
};
const credencial = encontrada === null ? undefined : CREDENCIALES[encontrada[1]];
TypeScript не се оплаква от нито една от двете промени: за него CREDENCIALES[token] е Credencial. Стартирай лабораторията и revisor от rota2/ и опитай с две думи, които никой не е регистрирал:
$ curl -i http://127.0.0.1:18080/servicios -H 'Authorization: Bearer constructor'
HTTP/1.1 200 OK
Content-Type: application/json
Content-Length: 17
Date: Wed, 21 Oct 2026 13:18:15 GMT
Connection: keep-alive
Keep-Alive: timeout=5
{"servicios":[]}
$ curl -i http://127.0.0.1:18080/servicios -H 'Authorization: Bearer toString'
HTTP/1.1 200 OK
Content-Type: application/json
Content-Length: 17
Date: Wed, 21 Oct 2026 13:18:15 GMT
Connection: keep-alive
Keep-Alive: timeout=5
{"servicios":[]}
200 OK. Входната точка пропусна constructor и toString, сякаш са идентификационни данни. CREDENCIALES["constructor"] намери функцията Object, която всеки обект наследява; CREDENCIALES["toString"] — функцията със същото име. Нито едната не е undefined, така че identificar отговори „валидни“. Това, че списъкът излиза празен, е чист късмет: тези „идентификационни данни“ нямат equipo, а филтърът на listar не намира нито една услуга, чийто екип е undefined. Виж какво става при опит за запис и при проверка:
$ curl -i -X DELETE http://127.0.0.1:18080/servicios/1 -H 'Authorization: Bearer constructor'
HTTP/1.1 403 Forbidden
Content-Type: text/plain; charset=utf-8
Content-Length: 74
Date: Wed, 21 Oct 2026 13:18:15 GMT
Connection: keep-alive
Keep-Alive: timeout=5
tu credencial solo puede leer; para escribir pide una con rol de escritor
$ curl -i http://127.0.0.1:18080/servicios/1 -H 'Authorization: Bearer constructor'
HTTP/1.1 404 Not Found
Content-Type: text/plain; charset=utf-8
Content-Length: 24
Date: Wed, 21 Oct 2026 13:18:15 GMT
Connection: keep-alive
Keep-Alive: timeout=5
no existe el servicio 1
403 казва „tu credencial solo puede leer“ („идентификационните ти данни позволяват само четене“): входната точка вярва, че знае кой си, макар зад това да няма никого; не му позволи да пише само защото функцията Object няма rol. А при справката — 404: неговият equipo е undefined, така че visible не му позволява да види дори услуга 1, която е на екип a. Този път нищо не се счупи, за щастие. Но третият въпрос на входната точка вече отговори „да“ на някого, който не съществува, и всяко правило, което пита само „идентифицирал ли се е?“, би го пропуснало: одитен запис би отбелязал потребител undefined, ограничение на заявките по идентификационни данни би го третирало като още един, а път, който изисква само да си идентифициран, би му отговорил. С Map от fig02_01/ същите заявки дават 401 с error="invalid_token", както показва третата заявка от пълното изпълнение в 2.7. Спри програмите от rota2/, когато приключиш.
Какво се прави погрешно
401 без WWW-Authenticate. Това е най-честата грешка с този код и спецификацията я забранява с трябва. Без предизвикателството клиентът знае, че трябва да се идентифицира, но не и как; някои HTTP клиенти освен това не се опитват да се автентикират, ако не видят предизвикателството.
405 без Allow или с Allow, който лъже. Първото оставя клиента да пробва методи на сляпо. Второто е по-лошо: ако Allow казва PUT, а PUT отговаря с 405, клиентът вече не може да вярва на нищо от онова, което казва API-то. В revisor Allow се изчислява от същата таблица, която решава кой метод се обслужва, така че не може да се разминава; да я пишеш ръчно във всеки отговор е начинът да се получи разминаване.
404 за метод, който не се поддържа. Това правеше revisor от Урок 1 и праща клиента да търси адрес, който е бил правилен.
403 за онзи, който не се е идентифицирал. Ако не знам кой си, не мога да кажа, че нямаш право: кодът е 401, с неговото предизвикателство. 403 за клиент без идентификационни данни му казва „не настоявай“, докато онова, което е трябвало да направи, е да се идентифицира.
401 за онзи, който се е идентифицирал правилно, но няма разрешение. Обратната грешка. Клиентът опитва отново със същите идентификационни данни или иска нови и получава същото завинаги.
Валидиране на тялото преди идентификационните данни. Учи непознат, с помощта на 400, какво очаква твоето API и какви стойности приема, и хаби работа на сървъра за заявки, които така или иначе щяха да бъдат отхвърлени.
Различен отговор за чуждото и за несъществуващото. 403 срещу 404, или 404 с „не съществува“ срещу 404 с „на друг екип е“, или един и същ текст с различна дължина. Всяка от трите разлики е достатъчна за изброяване. Карето в 2.5 обяснява цената.
400 за всичко. Клиент, който получава един и същ 400, когато форматът е грешен, когато JSON-ът е прекъснат или когато липсва поле, не може да реши по кода какво да поправи; остава му само да тълкува текста, а програма не бива да взема решения по изречения. Всеки въпрос на входната точка има свой код, за да знае клиентът кой се е провалил.
Четене на тялото без ограничение. Всеки, който стигне до пътя, може да напълни паметта на сървъра с един-единствен POST, а раздел 2.6.4 измери колко: около 150 MB на заявка. Ограничението се определя според това, което пътят се нуждае да получи, и се проверява докато се чете, а не след това.
Повтаряне на идентификационните данни в съобщението за грешка. Клиентът вече знае какви идентификационни данни е изпратил; логовете, кешовете и екранните снимки, през които минава отговорът, не трябва да ги знаят.
Да позволиш изключение от четеца на JSON да се превърне в 500. JSON.parse хвърля изключение при неправилно оформен JSON. Ако никой не го прихване, сървърът отговаря с 500 (или, в Node, се срива): обвинява сървъра за грешка на клиента. leerJson прихваща изключението и го превръща в 400, какъвто всъщност е.
Упражнения
И четирите се проверяват сами: всяко завършва с изпълнение, което ти казва дали си познал. Запиши своята прогноза в дневника преди да изпълниш каквото и да е. За всички ти трябват работещи лаборатория и revisor от 2.7, току-що стартирани (revisor пази всичко в паметта, а предишна регистрация или премахване променя отговорите).
Упражнение 1 — При кой въпрос спира?
Без да изпълняваш нищо, предвиди кода на всяка от тези седем заявки и при кой въпрос на входната точка спира всяка:
curl -i -X PATCH http://127.0.0.1:18080/servicios/1
curl -i -X DELETE http://127.0.0.1:18080/servicios/99
curl -i -X DELETE http://127.0.0.1:18080/servicios/99 -H 'Authorization: Bearer lectura-a'
curl -i -X POST http://127.0.0.1:18080/servicios -H 'Authorization: Bearer escritura-a' -H 'Content-Type: text/plain' -d 'catalogo'
curl -i http://127.0.0.1:18080/servicios/1 -H 'Authorization: Bearer escritura-b'
curl -i -X POST http://127.0.0.1:18080/servicios/1/revisiones -H 'Authorization: Bearer lectura-a'
curl -i http://127.0.0.1:18080/nada
След това ги изпълни и сравни. Ако си сбъркал някоя, подсказката е в реда: търси първия въпрос, чийто отговор е „не“.
Упражнение 2 — Какво казва Allow
Предвиди заглавката Allow на тези три отговора и дали наистина са 405:
curl -i -X DELETE http://127.0.0.1:18080/revisiones/1 -H 'Authorization: Bearer lectura-a'
curl -I http://127.0.0.1:18080/servicios
curl -i -X PUT http://127.0.0.1:18080/servicios -H 'Authorization: Bearer escritura-a'
-I изпраща HEAD, а -X сменя метода. Изпълни трите и сравни.
Упражнение 3 — Програмата за изброяване с други идентификационни данни
Предвиди какво отпечатва fig02_02.ts с escritura-b, с admin и с измислени идентификационни данни, nadie. При кое от трите програмата „открива“ най-много услуги и защо това не е изтичане? Изпълни трите:
node fig02_02.ts escritura-b
node fig02_02.ts admin
node fig02_02.ts nadie
Упражнение 4 — JSON, който не е услуга
Предвиди кода и тялото на всяка от тези пет регистрации. Всички носят идентификационни данни за запис и твърдят, че са application/json, и всички се провалят, но не на едно и също място:
curl -i -X POST http://127.0.0.1:18080/servicios -H 'Authorization: Bearer escritura-a' -H 'Content-Type: application/json' -d 'null'
curl -i -X POST http://127.0.0.1:18080/servicios -H 'Authorization: Bearer escritura-a' -H 'Content-Type: application/json' -d '7'
curl -i -X POST http://127.0.0.1:18080/servicios -H 'Authorization: Bearer escritura-a' -H 'Content-Type: application/json' -d '[]'
curl -i -X POST http://127.0.0.1:18080/servicios -H 'Authorization: Bearer escritura-a' -H 'Content-Type: application/json' -d '{}'
curl -i -X POST http://127.0.0.1:18080/servicios -H 'Authorization: Bearer escritura-a' -H 'Content-Type: application/json; charset=utf-8' -d '{"nombre":"agenda","url":"http://127.0.0.1:18081/codigo/200","limite_ms":"rapido"}'
Обърни внимание на Content-Type на последната: носи параметър, charset=utf-8. Приема ли го leerJson? Изпълни петте и сравни. За всяка кажи кой от седемте въпроса я е спрял или дали е преминала седемте и я е спряло правило на регистрацията.
Решения
Решение 1
$ curl -i -X PATCH http://127.0.0.1:18080/servicios/1
HTTP/1.1 405 Method Not Allowed
Allow: GET, HEAD, DELETE
Content-Type: text/plain; charset=utf-8
Content-Length: 35
Date: Wed, 21 Oct 2026 13:18:13 GMT
Connection: keep-alive
Keep-Alive: timeout=5
PATCH no se admite en /servicios/1
$ curl -i -X DELETE http://127.0.0.1:18080/servicios/99
HTTP/1.1 401 Unauthorized
WWW-Authenticate: Bearer realm="revisor"
Content-Type: text/plain; charset=utf-8
Content-Length: 20
Date: Wed, 21 Oct 2026 13:18:13 GMT
Connection: keep-alive
Keep-Alive: timeout=5
falta la credencial
$ curl -i -X DELETE http://127.0.0.1:18080/servicios/99 -H 'Authorization: Bearer lectura-a'
HTTP/1.1 403 Forbidden
Content-Type: text/plain; charset=utf-8
Content-Length: 74
Date: Wed, 21 Oct 2026 13:18:13 GMT
Connection: keep-alive
Keep-Alive: timeout=5
tu credencial solo puede leer; para escribir pide una con rol de escritor
$ curl -i -X POST http://127.0.0.1:18080/servicios -H 'Authorization: Bearer escritura-a' -H 'Content-Type: text/plain' -d 'catalogo'
HTTP/1.1 415 Unsupported Media Type
Accept: application/json
Content-Type: text/plain; charset=utf-8
Content-Length: 51
Date: Wed, 21 Oct 2026 13:18:13 GMT
Connection: keep-alive
Keep-Alive: timeout=5
el cuerpo debe ser application/json, no text/plain
$ curl -i http://127.0.0.1:18080/servicios/1 -H 'Authorization: Bearer escritura-b'
HTTP/1.1 404 Not Found
Content-Type: text/plain; charset=utf-8
Content-Length: 24
Date: Wed, 21 Oct 2026 13:18:13 GMT
Connection: keep-alive
Keep-Alive: timeout=5
no existe el servicio 1
$ curl -i -X POST http://127.0.0.1:18080/servicios/1/revisiones -H 'Authorization: Bearer lectura-a'
HTTP/1.1 403 Forbidden
Content-Type: text/plain; charset=utf-8
Content-Length: 74
Date: Wed, 21 Oct 2026 13:18:13 GMT
Connection: keep-alive
Keep-Alive: timeout=5
tu credencial solo puede leer; para escribir pide una con rol de escritor
$ curl -i http://127.0.0.1:18080/nada
HTTP/1.1 404 Not Found
Content-Type: text/plain; charset=utf-8
Content-Length: 19
Date: Wed, 21 Oct 2026 13:18:13 GMT
Connection: keep-alive
Keep-Alive: timeout=5
no existe esa ruta
По ред: 405 (въпрос 2: маршрутът съществува и не поддържа PATCH, и спира, преди да попита кой си); 401 (въпрос 3: маршрутът и методът съществуват, липсват идентификационни данни, а 99 дори не е проверена); 403 (въпрос 4: читател не трие, независимо дали 99 съществува); 415 (въпрос 6: редакторът може да регистрира, но тялото не е JSON); 404 (въпрос 5: услуга 1 е на екип a и за escritura-b не съществува); 403 (въпрос 4: заявяването на инспекция е запис, а lectura-a само чете); 404 (въпрос 1: /nada не е в таблицата и не бяха нужни идентификационни данни). Ако си сбъркал втората или третата, прегледай отново 2.1.2: това са двата реда, в които редът на входната точка скрива дали 99 съществува.
Решение 2
$ curl -i -X DELETE http://127.0.0.1:18080/revisiones/1 -H 'Authorization: Bearer lectura-a'
HTTP/1.1 405 Method Not Allowed
Allow: GET, HEAD
Content-Type: text/plain; charset=utf-8
Content-Length: 37
Date: Wed, 21 Oct 2026 13:18:13 GMT
Connection: keep-alive
Keep-Alive: timeout=5
DELETE no se admite en /revisiones/1
$ curl -I http://127.0.0.1:18080/servicios
HTTP/1.1 401 Unauthorized
WWW-Authenticate: Bearer realm="revisor"
Content-Type: text/plain; charset=utf-8
Content-Length: 20
Date: Wed, 21 Oct 2026 13:18:13 GMT
Connection: keep-alive
Keep-Alive: timeout=5
$ curl -i -X PUT http://127.0.0.1:18080/servicios -H 'Authorization: Bearer escritura-a'
HTTP/1.1 405 Method Not Allowed
Allow: GET, HEAD, POST
Content-Type: text/plain; charset=utf-8
Content-Length: 31
Date: Wed, 21 Oct 2026 13:18:13 GMT
Connection: keep-alive
Keep-Alive: timeout=5
PUT no se admite en /servicios
Първият е 405 с Allow: GET, HEAD: /revisiones/1 само се проверява. Вторият не е 405: HEAD се поддържа там, където се поддържа GET, а /servicios поддържа GET, така че входната точка премина към въпрос 3 и отговори с 401, с неговото предизвикателство и без тяло, защото беше HEAD (Content-Length: 20 казва колко би било тялото при GET, както в Урок 0). Третият е 405 с Allow: GET, HEAD, POST, методите на колекцията. Забележи, че трите Allow излязоха от една и съща таблица в rutas.ts, без нито един да е написан ръчно.
Решение 3
$ node fig02_02.ts escritura-b
servicio 1: 404 no existe el servicio 1
servicio 2: 404 no existe el servicio 2
servicio 3: 200 {"id":3,"nombre":"nomina","url":"http://127.0.0.1:18081/codigo/200","limite_ms":2000}
servicio 4: 404 no existe el servicio 4
servicio 5: 404 no existe el servicio 5
servicio 6: 404 no existe el servicio 6
respuestas distintas: 2
1, 2, 4, 5, 6 → 404 no existe el servicio N
3 → 200 con datos
$ node fig02_02.ts admin
servicio 1: 200 {"id":1,"nombre":"catalogo","url":"http://127.0.0.1:18081/codigo/200","limite_ms":2000}
servicio 2: 200 {"id":2,"nombre":"pagos","url":"http://127.0.0.1:18081/codigo/503","limite_ms":2000}
servicio 3: 200 {"id":3,"nombre":"nomina","url":"http://127.0.0.1:18081/codigo/200","limite_ms":2000}
servicio 4: 404 no existe el servicio 4
servicio 5: 404 no existe el servicio 5
servicio 6: 404 no existe el servicio 6
respuestas distintas: 2
1, 2, 3 → 200 con datos
4, 5, 6 → 404 no existe el servicio N
$ node fig02_02.ts nadie
servicio 1: 401 la credencial no es válida
servicio 2: 401 la credencial no es válida
servicio 3: 401 la credencial no es válida
servicio 4: 401 la credencial no es válida
servicio 5: 401 la credencial no es válida
servicio 6: 401 la credencial no es válida
respuestas distintas: 1
1, 2, 3, 4, 5, 6 → 401 la credencial no es válida
escritura-b вижда само 3, а за него 1 и 2 са толкова несъществуващи, колкото 6. admin „открива“ три услуги, но това не е изтичане: администраторът има екип * и може да вижда всички, така че да вижда трите е негово право, а не извод. А nadie не научава нищо: шестте идентификатора получават един и същ 401, защото за идентификационните данни се пита преди съществуването. И в трите случая има най-много две групи и предупреждението на програмата не се появява.
Решение 4
$ curl -i -X POST http://127.0.0.1:18080/servicios -H 'Authorization: Bearer escritura-a' -H 'Content-Type: application/json' -d 'null'
HTTP/1.1 400 Bad Request
Content-Type: text/plain; charset=utf-8
Content-Length: 31
Date: Wed, 21 Oct 2026 13:18:13 GMT
Connection: keep-alive
Keep-Alive: timeout=5
el cuerpo no es un objeto JSON
$ curl -i -X POST http://127.0.0.1:18080/servicios -H 'Authorization: Bearer escritura-a' -H 'Content-Type: application/json' -d '7'
HTTP/1.1 400 Bad Request
Content-Type: text/plain; charset=utf-8
Content-Length: 31
Date: Wed, 21 Oct 2026 13:18:13 GMT
Connection: keep-alive
Keep-Alive: timeout=5
el cuerpo no es un objeto JSON
$ curl -i -X POST http://127.0.0.1:18080/servicios -H 'Authorization: Bearer escritura-a' -H 'Content-Type: application/json' -d '[]'
HTTP/1.1 400 Bad Request
Content-Type: text/plain; charset=utf-8
Content-Length: 31
Date: Wed, 21 Oct 2026 13:18:13 GMT
Connection: keep-alive
Keep-Alive: timeout=5
el cuerpo no es un objeto JSON
$ curl -i -X POST http://127.0.0.1:18080/servicios -H 'Authorization: Bearer escritura-a' -H 'Content-Type: application/json' -d '{}'
HTTP/1.1 400 Bad Request
Content-Type: text/plain; charset=utf-8
Content-Length: 20
Date: Wed, 21 Oct 2026 13:18:13 GMT
Connection: keep-alive
Keep-Alive: timeout=5
faltan nombre y url
$ curl -i -X POST http://127.0.0.1:18080/servicios -H 'Authorization: Bearer escritura-a' -H 'Content-Type: application/json; charset=utf-8' -d '{"nombre":"agenda","url":"http://127.0.0.1:18081/codigo/200","limite_ms":"rapido"}'
HTTP/1.1 400 Bad Request
Content-Type: text/plain; charset=utf-8
Content-Length: 47
Date: Wed, 21 Oct 2026 13:18:13 GMT
Connection: keep-alive
Keep-Alive: timeout=5
limite_ms debe ser un entero entre 100 y 10000
Петте преминаха първите шест въпроса: маршрутът съществува, поддържа POST, идентификационните данни са валидни и могат да записват, колекцията няма ресурс, който да може да не съществува, а типът е JSON. Последната също, макар нейният Content-Type да носи ; charset=utf-8: leerJson взема онова, което е преди ;, както видяхме в 2.6.1. Първите три спират при въпрос 7: null, 7 и [] се четат като JSON, но не са обект, и leerJson ги отхвърля с el cuerpo no es un objeto JSON. Последните две наистина са обекти и преминават седемте въпроса; спират ги правилата на регистрацията: {} не носи нито nombre, нито url, а "rapido" не е цяло число между 100 и 10000. Забележи, че текстът на всеки 400 е различен: при един и същ код тялото казва какво да се поправи. И в онова, което не се случи: нито една от петте не срина revisor, който продължава да отговаря на следващата заявка.
Как разбирам, че съм успял
- Казваш наизуст седемте въпроса на входната точка по ред и за всеки — неговия код.
-
curl -i http://127.0.0.1:18080/serviciosбез идентификационни данни дава401сWWW-Authenticate: Bearer realm="revisor", а сAuthorization: Bearer inventadaдава401сerror="invalid_token"в предизвикателството. - С
Authorization: Basic …отговорът е401с предизвикателството безerror, а сbearer lectura-a, с малки букви, е200. -
curl -i -X PATCHкъм/servicios/1дава405сAllow: GET, HEAD, DELETE, а/serviciossдава404. -
curl -i -X DELETEкъм/servicios/1и към/servicios/99сlectura-aдават и двата403, и обясняваш защо вторият не е404. -
lectura-aиска/servicios/3и/servicios/99, и двата отговора са идентични с изключение на номера;escritura-bвижда 3. -
node fig02_02.ts lectura-aзаключава, че съществуват само услуги 1 и 2. -
POSTсcurl -dбезContent-Typeдава415сAccept: application/json, а такъв с прекъснат JSON дава400. -
POSTот 20 000 байта дава413сConnection: close, съсContent-Lengthи на части, а такъв от 16 384 байта дава400. - Обясняваш защо таблицата с идентификационни данни е
Map, а не обект, с изпълнението наBearer constructor. - При завършване не остава програма, която слуша:
curl -i http://127.0.0.1:18080/дава грешка 7.
Обобщение
Отговори, без да гледаш урока:
- Кои са седемте въпроса на входната точка, по ред, и с какъв код отговаря всеки, когато отговорът е „не“?
- Каква заглавка изисква
401и каква —405? Кой раздел на RFC 9110 го казва? - Кой въпрос решава между
401и403? - Защо идентификационните данни са преди съществуването? И защо разрешението по роля е преди съществуването?
- Какво позволява RFC 9110 да се направи със забранен ресурс, чието съществуване искаш да скриеш?
- Какво е изброяването на ресурси и кои три пътя затваря
revisor, за да го предотврати? - Защо
415е преди400? Коя заглавка помага на клиента при415и задължителна ли е? - Защо
CREDENCIALES[token]върху JavaScript обект може да приеме идентификационни данни, които никой не е регистрирал? - Какво общо имат
413и415? ЗащоleerCuerpoсе нуждае от две защити за размера, а не от една?
Източници
Всички бяха консултирани на 21 октомври 2026 г.
- RFC 9110, HTTP Semantics, раздел 15.5.1:
400и неговата широка дефиниция — „нещо, което се възприема като грешка на клиента“. - RFC 9110, раздел 15.5.2:
401и задължителната заглавкаWWW-Authenticateс поне едно предизвикателство; и раздел 8.8.1 — защоETagне се преизползва за друго представяне. - RFC 9110, раздел 15.5.4:
403, правилото да не се повтаря със същите идентификационни данни, и404, позволен, за да се „скрие“ забранен ресурс. - RFC 9110, раздел 15.5.5:
404— „не е намерил текущо представяне или не желае да разкрие, че то съществува“. - RFC 9110, раздели 15.5.6 и 10.2.1:
405и задължителната в него заглавкаAllow. - RFC 9110, раздел 9.1: границата между
405(познат метод, който не се поддържа за ресурса) и501(метод, който сървърът не имплементира). - RFC 9110, раздели 15.5.16 и 15.5.14:
415иAcceptв отговора, за да се каже кои типове биха били приети;413и това, че сървърът може да затвори връзката. - RFC 9110, раздели 11.1, 11.4 и 11.6.1: името на схемата за автентикация не различава главни и малки букви, формата на идентификационните данни (
схемаи интервал) и формата на предизвикателството вWWW-Authenticate. - RFC 9110, раздел 8.3.1: типът и подтипът на
Content-Typeне различават главни и малки букви; онова, което следва след;, са параметри. - RFC 6750, The OAuth 2.0 Authorization Framework: Bearer Token Usage, раздели 3 и 3.1: схемата
Bearer, предизвикателството сrealm, кодътinvalid_tokenи правилото да не се изпраща код за грешка, когато заявката не носи автентикация или използва схема, която не се поддържа. - OWASP API Security Top 10, API1:2023 Broken Object Level Authorization: проверка при всеки достъп дали онзи, който иска, може да използва този запис, и идентификатори, трудни за отгатване.
- OWASP Web Security Testing Guide, WSTG-IDNT-04: изброяването чрез разлики между
403и404и общите отговори като защита. - Документация на REST API на GitHub, Troubleshooting: отговаря с
404, а не с403, на частен ресурс, за да не потвърди, че съществува. - Ръководство за
curl, опции-d,-H,-X,-Iи-w:-dизпращаapplication/x-www-form-urlencoded, ако не е посочен друг тип, а-w '%header{etag}'отпечатва заглавка от отговора. - Node.js 24: HTTP:
message.headersс имената на заглавките с малки букви, както ги четатidentificar(peticion.headers.authorization) иleerJson(peticion.headers["content-type"]).
Термини от този урок
| автентикация | установяване на това кой прави заявката; при неуспех — 401 |
| авторизация | решаване дали онзи, който прави заявката, може да направи това; ако не може — 403 |
Bearer |
схема за автентикация от RFC 6750: който носи идентификационните данни, е онзи, за когото се представя |
| изброяване на ресурси | установяване на кои ресурси съществуват чрез изпробване на идентификатори и сравняване на отговорите |
| входната точка | въпросите, през които минава една заявка, по ред, преди да стигне до своя манипулатор |
| предизвикателство (challenge) | стойността на WWW-Authenticate: схемата, с която трябва да се автентикираш, и нейните параметри |
| роля | онова, което дадени идентификационни данни могат да правят: в revisor — читател, редактор или администратор |
| ограничение на тялото | максималният размер, който един път приема да прочете; ако бъде надхвърлен — 413 |
| прототип | обектът, от който даден JavaScript обект наследява свойства, като constructor или toString |
Предпочитате имейл? Пишете ни на hola@habil.mx