diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..6142d1b --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,18 @@ +# Changelog + +All notable changes to this project are documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## Unreleased + +## 0.4.1 - 2026-09-30 + +### Fixed + +- Encode non-Latin-1 route metadata before placing it in the `x-askr-head` + response header, preventing HTTP 500 responses for Unicode titles and + descriptions. +- Percent-encode Unicode redirect locations while preserving existing percent + escapes, so internationalized paths produce valid `Location` headers. diff --git a/package-lock.json b/package-lock.json index cff42bc..1f15347 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@askrjs/server", - "version": "0.4.0", + "version": "0.4.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@askrjs/server", - "version": "0.4.0", + "version": "0.4.1", "license": "Apache-2.0", "dependencies": { "@askrjs/auth": ">=0.4.0 <0.5.0", diff --git a/package.json b/package.json index ca79175..ba6abbe 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@askrjs/server", - "version": "0.4.0", + "version": "0.4.1", "description": "Transport-neutral HTTP application core built on Web Request and Response", "keywords": [ "askr", diff --git a/src/askr/page-handler.ts b/src/askr/page-handler.ts index 71e7b4e..f5e8c48 100644 --- a/src/askr/page-handler.ts +++ b/src/askr/page-handler.ts @@ -33,6 +33,22 @@ function headerValue(value: string): string { return output; } +function encodeHeaderText(value: string): string { + const bytes = new TextEncoder().encode(value); + let binary = ""; + for (let offset = 0; offset < bytes.length; offset += 0x8000) { + binary += String.fromCharCode(...bytes.subarray(offset, offset + 0x8000)); + } + return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, ""); +} + +function requiresUtf8HeaderEncoding(...values: (string | undefined)[]): boolean { + return values.some( + (value) => + value !== undefined && [...value].some((character) => character.charCodeAt(0) > 0xff), + ); +} + function escapeStyleRawText(value: string): string { return value.replace(/<\/style/gi, "<\\/style"); } @@ -88,12 +104,14 @@ export async function translateAskrPageResult( const headers = new Headers({ "content-type": "text/html; charset=utf-8; askr-fragment=1" }); if (result.record) { const metadata = await resolveRouteMeta(result.record, routeContext(context, result.params)); - const head = headerValue(serializeRouteMeta(metadata)); - if (head) headers.set("x-askr-head", head); - if (metadata.html?.lang) { - headers.set("x-askr-html-lang", headerValue(metadata.html.lang)); - } - if (metadata.html?.dir) headers.set("x-askr-html-dir", metadata.html.dir); + const head = serializeRouteMeta(metadata); + const lang = metadata.html?.lang; + const dir = metadata.html?.dir; + const encode = requiresUtf8HeaderEncoding(head, lang, dir); + if (encode) headers.set("x-askr-encoding", "base64url-v1"); + if (head) headers.set("x-askr-head", encode ? encodeHeaderText(head) : headerValue(head)); + if (lang) headers.set("x-askr-html-lang", encode ? encodeHeaderText(lang) : headerValue(lang)); + if (dir) headers.set("x-askr-html-dir", encode ? encodeHeaderText(dir) : dir); } const carrier = styleCarrier(result.styles, cspNonce); const body = result.stream ?? result.html; diff --git a/src/http/responses.ts b/src/http/responses.ts index 43c4e8f..633c526 100644 --- a/src/http/responses.ts +++ b/src/http/responses.ts @@ -37,7 +37,10 @@ export function text(value: string, init?: ResponseInit): Response { /** Builds a redirect response with an empty body and a `Location` header. Defaults to `302 Found`. */ export function redirect(location: string, status: 301 | 302 | 303 | 307 | 308 = 302): Response { - return new Response(null, { status, headers: { location } }); + const headerLocation = location.replace(/[\u0080-\u{10FFFF}]/gu, (character) => + encodeURIComponent(character), + ); + return new Response(null, { status, headers: { location: headerLocation } }); } const statusTitles: Record = { diff --git a/tests/pack-result.d.ts b/tests/pack-result.d.ts new file mode 100644 index 0000000..f815594 --- /dev/null +++ b/tests/pack-result.d.ts @@ -0,0 +1,5 @@ +export interface PackRecord { + files: Array<{ path: string }>; +} + +export function readPackRecord(result: unknown): PackRecord; diff --git a/tests/pack-result.js b/tests/pack-result.js new file mode 100644 index 0000000..286f720 --- /dev/null +++ b/tests/pack-result.js @@ -0,0 +1,27 @@ +/** @typedef {{ files: Array<{ path: string }> }} PackRecord */ + +/** Normalize the array and name-keyed object shapes used by npm pack --json. */ +export function readPackRecord(result) { + const records = Array.isArray(result) + ? result + : result !== null && typeof result === "object" + ? Object.values(result) + : []; + + if (records.length !== 1) + throw new Error(`Expected one packed artifact, received ${records.length}.`); + + const record = records[0]; + if ( + record === null || + typeof record !== "object" || + !Array.isArray(record.files) || + !record.files.every( + (file) => file !== null && typeof file === "object" && typeof file.path === "string", + ) + ) { + throw new Error("npm pack returned an invalid artifact record."); + } + + return /** @type {PackRecord} */ (record); +} diff --git a/tests/pack-result.test.ts b/tests/pack-result.test.ts new file mode 100644 index 0000000..cb4135a --- /dev/null +++ b/tests/pack-result.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from "vitest"; +import { readPackRecord } from "./pack-result.js"; + +const record = { filename: "package.tgz", files: [{ path: "dist/index.js" }] }; + +describe("npm pack JSON normalization", () => { + it.each([[record], { "@askrjs/server": record }])( + "should read one artifact from the supported npm result shape", + (result) => { + expect(readPackRecord(result)).toBe(record); + }, + ); + + it.each([[], [record, record], {}, null, "invalid"])( + "should reject a result without exactly one artifact", + (result) => { + expect(() => readPackRecord(result)).toThrow(/Expected one packed artifact/); + }, + ); + + it("should reject malformed artifact records", () => { + expect(() => readPackRecord([{ files: [{ path: 1 }] }])).toThrow( + "npm pack returned an invalid artifact record.", + ); + }); +}); diff --git a/tests/package-artifacts.js b/tests/package-artifacts.js index 44464d1..33ac3fc 100644 --- a/tests/package-artifacts.js +++ b/tests/package-artifacts.js @@ -1,13 +1,16 @@ import { execFileSync } from "node:child_process"; import { readFileSync } from "node:fs"; import { dirname, join, normalize } from "node:path"; +import { readPackRecord } from "./pack-result.js"; const npmCli = process.env.npm_execpath; if (!npmCli) throw new Error("npm_execpath is unavailable; run this check through npm"); -const result = JSON.parse( - execFileSync(process.execPath, [npmCli, "pack", "--ignore-scripts", "--dry-run", "--json"], { - encoding: "utf8", - }), +const result = readPackRecord( + JSON.parse( + execFileSync(process.execPath, [npmCli, "pack", "--ignore-scripts", "--dry-run", "--json"], { + encoding: "utf8", + }), + ), ); const manifest = JSON.parse(readFileSync("package.json", "utf8")); @@ -17,11 +20,7 @@ if (JSON.stringify(dependencies) !== JSON.stringify(allowedDependencies)) { throw new Error(`Unexpected production dependencies: ${dependencies.join(", ")}`); } -if (result.length !== 1) { - throw new Error(`Expected one packed artifact, received ${result.length}.`); -} - -const packedFiles = new Set(result[0].files.map(({ path }) => normalize(path))); +const packedFiles = new Set(result.files.map(({ path }) => normalize(path))); for (const expected of ["dist/testing.js", "dist/testing.d.ts"]) { if (!packedFiles.has(normalize(expected))) { throw new Error(`Packed artifact is missing ${expected}.`); @@ -46,7 +45,7 @@ for (const file of packedFiles) { } const sourceMappingPattern = /[#@]\s*sourceMappingURL=([^\s*]+)/gu; -for (const file of result[0].files) { +for (const file of result.files) { if (!/\.(?:css|d\.ts|js)$/u.test(file.path)) continue; const source = readFileSync(file.path, "utf8"); diff --git a/tests/server.test.ts b/tests/server.test.ts index 0123387..b66df62 100644 --- a/tests/server.test.ts +++ b/tests/server.test.ts @@ -4,6 +4,7 @@ import { createRouter, defineRoutes, json, + redirect, setCookie, text, type Middleware, @@ -21,6 +22,15 @@ const authenticated: AuthContext = { }; describe("HTTP responses", () => { + it("should encode Unicode redirect locations as valid header values", () => { + const response = redirect("/tags/日本?q=東京#詳細"); + + expect(response.status).toBe(302); + expect(response.headers.get("location")).toBe( + "/tags/%E6%97%A5%E6%9C%AC?q=%E6%9D%B1%E4%BA%AC#%E8%A9%B3%E7%B4%B0", + ); + }); + it.each([ { headers: { "x-value": "object" } }, { headers: [["x-value", "tuple"]] as [string, string][] },