@atelic-action/ui 0.12.0 → 0.12.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -141,7 +141,7 @@ export { default } from "@atelic-action/ui/gate/middleware";
141
141
  export const config = { matcher: ["/((?!api/auth/).*)"] };
142
142
  ```
143
143
 
144
- The default middleware holds back everything `needsSession` does: all of a host but the login page, what that page loads (`/assets/` apart from `/assets/doc/`, `/fonts/`, `/brand/`, the favicon and logo), the two functions under `/api/auth/`, `robots.txt`, `health.json`, and a website build proxied under `/proto`. A site whose matcher already leaves its open paths out passes its own test, and one whose link opens something other than a document names it:
144
+ The default middleware holds back everything `needsSession` does: all of a host but the login page, what that page loads (`/assets/` apart from `/assets/doc/`, `/fonts/`, `/brand/`, the favicon and logo), everything under `/api/auth/` (the two sign in functions, and the one place under `/api/` where a host must add nothing private), `robots.txt`, `health.json`, and a website build proxied under `/proto`. A site whose matcher already leaves its open paths out passes its own test, and one whose link opens something other than a document names it:
145
145
 
146
146
  ```ts
147
147
  import { createMiddleware } from "@atelic-action/ui/gate/middleware";
@@ -156,9 +156,9 @@ export const POST = createRequestLink({ noun: "preview" });
156
156
  | `GATE_SESSION_SECRET` | all three | The signing secret, and the switch: unset, the gate is off. Use 32 random bytes or more |
157
157
  | `GATE_ALLOWLIST` | all three | Comma separated emails that may receive a link. Taking an address off, then redeploying, refuses its outstanding link and ends its session |
158
158
  | `RESEND_API_KEY` | request link | The sending only key the link goes out on |
159
- | `GATE_BASE_URL` | request link | The host the link points at; a bare host is read as https. Unset, production uses the project's own domain and a preview uses the deployment it was asked on |
160
- | `GATE_FROM`, `GATE_SUBJECT`, `GATE_LINK_TTL_MIN` | request link | Sender, subject, and link lifetime in minutes (15) |
161
- | `GATE_SESSION_DAYS`, `GATE_WATERMARK_MODE` | verify | Session lifetime in days (7), and `pill` or `tiled` |
159
+ | `GATE_BASE_URL` | request link | The host the link points at; a bare host is read as https, and plain http is refused unless it is local. Unset, production uses the project's own domain and a preview uses the deployment it was asked on |
160
+ | `GATE_FROM`, `GATE_SUBJECT`, `GATE_LINK_TTL_MIN` | request link | Sender, subject, and link lifetime in minutes (15, a day at most) |
161
+ | `GATE_SESSION_DAYS`, `GATE_WATERMARK_MODE` | verify | Session lifetime in days (7, a year at most), and `pill` or `tiled` |
162
162
 
163
163
  What it holds to, each with a test in `tst/gate/`:
164
164
 
@@ -167,7 +167,7 @@ What it holds to, each with a test in `tst/gate/`:
167
167
  - The emailed link is never built from a forwarded header, since a caller can name any host there.
168
168
  - A link request answers the same way after the same two second wait whether or not the address was on the allowlist, and a send still running shortly before then is given up. A send that fails says so in the function's log and nowhere else.
169
169
  - The post sign in destination is held to a path on the same host, 512 characters at most (`safeNext`).
170
- - An open path opens only as written; the held corner is held in any case. Only the two sign in functions are open under `/api/`.
170
+ - An open path opens only as written; the held corner is held in any case. Under `/api/`, only `/api/auth/` is open.
171
171
 
172
172
  What it does not do, by having no store: a link can be used more than once until it expires, and link requests are not rate limited. Rotating the secret ends every session at once. The `gate_email` and `gate_wm` cookies that feed the watermark are readable and unsigned on purpose: the watermark is drawn in the reader's own browser, so it deters and attributes nothing a reader set on removing it could not already remove.
173
173
 
@@ -8,6 +8,8 @@ export declare function isAllowed(email: string, allowlist: string | undefined):
8
8
  * A setting that has to be a positive, finite number, or its default. A
9
9
  * lifetime of zero, a negative one, or Infinity would mint a token that is
10
10
  * already dead or that cannot be signed at all. A fraction is allowed (half
11
- * a day), so a caller rounds what it derives to whole seconds.
11
+ * a day), so a caller rounds what it derives to whole seconds. Anything past
12
+ * the ceiling is held to it: a lifetime of 1e308 days is a typo, and the
13
+ * seconds it works out to cannot be signed.
12
14
  */
13
- export declare function positive(value: string | undefined, fallback: number): number;
15
+ export declare function positive(value: string | undefined, fallback: number, ceiling: number): number;
@@ -16,9 +16,13 @@ export function isAllowed(email, allowlist) {
16
16
  * A setting that has to be a positive, finite number, or its default. A
17
17
  * lifetime of zero, a negative one, or Infinity would mint a token that is
18
18
  * already dead or that cannot be signed at all. A fraction is allowed (half
19
- * a day), so a caller rounds what it derives to whole seconds.
19
+ * a day), so a caller rounds what it derives to whole seconds. Anything past
20
+ * the ceiling is held to it: a lifetime of 1e308 days is a typo, and the
21
+ * seconds it works out to cannot be signed.
20
22
  */
21
- export function positive(value, fallback) {
23
+ export function positive(value, fallback, ceiling) {
22
24
  const parsed = Number(value);
23
- return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
25
+ if (!Number.isFinite(parsed) || parsed <= 0)
26
+ return fallback;
27
+ return Math.min(parsed, ceiling);
24
28
  }
@@ -5,8 +5,9 @@ export interface RequestLinkOptions {
5
5
  /**
6
6
  * Where the emailed link points. Never a forwarded header: a caller can name
7
7
  * any host there, and the link carries a signed token. The configured base
8
- * wins, read as a host (a bare one is taken as https); on production the
9
- * project's own domain is next; otherwise the host the platform routed this
8
+ * wins, read as a host (a bare one is taken as https, and plain http is
9
+ * accepted only for a local one); on production the project's own domain is
10
+ * next; otherwise the host the platform routed this
10
11
  * request to, which is the deployment itself.
11
12
  */
12
13
  export declare function linkBase(request: Request, env: Record<string, string | undefined>): string;
@@ -15,10 +15,13 @@
15
15
  * - GATE_LINK_TTL_MIN link lifetime in minutes (optional, default 15)
16
16
  * - GATE_BASE_URL the host the link points at (optional, see linkBase)
17
17
  */
18
+ import { escapeHTML } from "../lib/escapeHTML.js";
18
19
  import { isAllowed, positive } from "./config.js";
19
20
  import { safeNext } from "./next.js";
20
21
  import { signToken } from "./tokens.js";
21
22
  const DEFAULT_FROM = "Atelic <forms@atelic.me>";
23
+ /** A day: a sign in link that outlives it is a standing key, not a link. */
24
+ const LONGEST_LINK_MIN = 24 * 60;
22
25
  /** How long every valid request takes to answer, whatever it did. */
23
26
  const ANSWER_AFTER_MS = 2000;
24
27
  /**
@@ -36,16 +39,20 @@ function json(status, body) {
36
39
  /**
37
40
  * Where the emailed link points. Never a forwarded header: a caller can name
38
41
  * any host there, and the link carries a signed token. The configured base
39
- * wins, read as a host (a bare one is taken as https); on production the
40
- * project's own domain is next; otherwise the host the platform routed this
42
+ * wins, read as a host (a bare one is taken as https, and plain http is
43
+ * accepted only for a local one); on production the project's own domain is
44
+ * next; otherwise the host the platform routed this
41
45
  * request to, which is the deployment itself.
42
46
  */
43
47
  export function linkBase(request, env) {
44
48
  const configured = env.GATE_BASE_URL?.trim();
45
49
  if (configured) {
46
50
  try {
47
- return new URL(/^https?:\/\//i.test(configured) ? configured : `https://${configured}`)
48
- .origin;
51
+ const base = new URL(/^https?:\/\//i.test(configured) ? configured : `https://${configured}`);
52
+ const local = base.hostname === "localhost" || base.hostname === "127.0.0.1";
53
+ if (base.protocol === "https:" || local)
54
+ return base.origin;
55
+ console.error("gate: GATE_BASE_URL is plain http, falling back to the request's own host");
49
56
  }
50
57
  catch {
51
58
  console.error("gate: GATE_BASE_URL is not a URL, falling back to the request's own host");
@@ -56,18 +63,9 @@ export function linkBase(request, env) {
56
63
  }
57
64
  return new URL(request.url).origin;
58
65
  }
59
- /** Text made safe to set inside an HTML element or a quoted attribute. */
60
- function escapeHtml(text) {
61
- return text
62
- .replaceAll("&", "&amp;")
63
- .replaceAll("<", "&lt;")
64
- .replaceAll(">", "&gt;")
65
- .replaceAll('"', "&quot;")
66
- .replaceAll("'", "&#39;");
67
- }
68
- function linkEmailHtml(rawLink, rawNoun) {
69
- const link = escapeHtml(rawLink);
70
- const noun = escapeHtml(rawNoun);
66
+ function linkEmailHTML(rawLink, rawNoun) {
67
+ const link = escapeHTML(rawLink);
68
+ const noun = escapeHTML(rawNoun);
71
69
  // Gmail renders inline styles only, no style blocks.
72
70
  return `
73
71
  <div style="font-family: Helvetica, Arial, sans-serif; color: #211c17; line-height: 1.6; max-width: 520px;">
@@ -102,7 +100,7 @@ async function sendLink(to, link, noun) {
102
100
  from: process.env.GATE_FROM || DEFAULT_FROM,
103
101
  to: [to],
104
102
  subject: process.env.GATE_SUBJECT || `Your private link to the ${noun}`,
105
- html: linkEmailHtml(link, noun),
103
+ html: linkEmailHTML(link, noun),
106
104
  text: `Your private link to the ${noun}:\n\n${link}\n\nIf you did not expect this, you can ignore it.`,
107
105
  }),
108
106
  });
@@ -138,7 +136,7 @@ export function createRequestLink(options = {}) {
138
136
  // reads no differently from no send at all.
139
137
  const answered = wait(ANSWER_AFTER_MS);
140
138
  if (secret && isAllowed(email, process.env.GATE_ALLOWLIST)) {
141
- const ttlMinutes = positive(process.env.GATE_LINK_TTL_MIN, 15);
139
+ const ttlMinutes = positive(process.env.GATE_LINK_TTL_MIN, 15, LONGEST_LINK_MIN);
142
140
  try {
143
141
  const token = await signToken({
144
142
  e: email,
@@ -14,6 +14,8 @@
14
14
  import { isAllowed, positive } from "./config.js";
15
15
  import { safeNext } from "./next.js";
16
16
  import { signToken, verifyToken } from "./tokens.js";
17
+ /** A year: past it a session is no longer something anyone remembers granting. */
18
+ const LONGEST_SESSION_DAYS = 365;
17
19
  function cookie(name, value, maxAge, httpOnly) {
18
20
  const flags = ["Path=/", "Secure", "SameSite=Lax", `Max-Age=${maxAge}`];
19
21
  if (httpOnly)
@@ -39,7 +41,7 @@ export async function GET(request) {
39
41
  if (claims?.p !== "link" || !isAllowed(claims.e, process.env.GATE_ALLOWLIST)) {
40
42
  return redirect("/login?e=expired");
41
43
  }
42
- const days = positive(process.env.GATE_SESSION_DAYS, 7);
44
+ const days = positive(process.env.GATE_SESSION_DAYS, 7, LONGEST_SESSION_DAYS);
43
45
  // Whole seconds: a browser ignores a Max-Age that is not an integer.
44
46
  const maxAge = Math.max(1, Math.round(days * 24 * 60 * 60));
45
47
  const session = await signToken({ e: claims.e, x: Math.floor(Date.now() / 1000) + maxAge, p: "session" }, secret);
@@ -37,7 +37,9 @@
37
37
  * The path is read the way a file server would read it (decoded, repeated
38
38
  * slashes collapsed) so an encoded or doubled slash cannot walk a held file
39
39
  * out through an open prefix; anything that will not decode, or still climbs
40
- * a directory once decoded, is held. Case is folded for the held corner
40
+ * a directory once decoded, is held, and so is one that still carries a
41
+ * percent sign, since a second decode somewhere downstream would make it a
42
+ * different path than the one judged here. Case is folded for the held corner
41
43
  * alone, so no spelling of it slips out, while an open path opens only as
42
44
  * written: the host serves files by exact case, and a path that merely looks
43
45
  * like an open one names some other file.
package/dist/gate/wall.js CHANGED
@@ -49,7 +49,9 @@ const HELD_PREFIXES = ["/assets/doc/"];
49
49
  * The path is read the way a file server would read it (decoded, repeated
50
50
  * slashes collapsed) so an encoded or doubled slash cannot walk a held file
51
51
  * out through an open prefix; anything that will not decode, or still climbs
52
- * a directory once decoded, is held. Case is folded for the held corner
52
+ * a directory once decoded, is held, and so is one that still carries a
53
+ * percent sign, since a second decode somewhere downstream would make it a
54
+ * different path than the one judged here. Case is folded for the held corner
53
55
  * alone, so no spelling of it slips out, while an open path opens only as
54
56
  * written: the host serves files by exact case, and a path that merely looks
55
57
  * like an open one names some other file.
@@ -63,7 +65,7 @@ export function needsSession(pathname) {
63
65
  return true;
64
66
  }
65
67
  path = path.replace(/\/{2,}/g, "/");
66
- if (path.includes("..") || path.includes("\\"))
68
+ if (path.includes("..") || path.includes("\\") || path.includes("%"))
67
69
  return true;
68
70
  const folded = path.toLowerCase();
69
71
  if (HELD_PREFIXES.some((prefix) => folded.startsWith(prefix)))
@@ -0,0 +1,6 @@
1
+ /**
2
+ * Text made safe to set inside an HTML element or a quoted attribute. Plain
3
+ * TypeScript with no imports, so the email renderer and the access gate's
4
+ * server side (which ships compiled) both use the one copy.
5
+ */
6
+ export declare function escapeHTML(value: string): string;
@@ -0,0 +1,13 @@
1
+ /**
2
+ * Text made safe to set inside an HTML element or a quoted attribute. Plain
3
+ * TypeScript with no imports, so the email renderer and the access gate's
4
+ * server side (which ships compiled) both use the one copy.
5
+ */
6
+ export function escapeHTML(value) {
7
+ return value
8
+ .replace(/&/g, "&amp;")
9
+ .replace(/</g, "&lt;")
10
+ .replace(/>/g, "&gt;")
11
+ .replace(/"/g, "&quot;")
12
+ .replace(/'/g, "&#39;");
13
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@atelic-action/ui",
3
- "version": "0.12.0",
3
+ "version": "0.12.1",
4
4
  "description": "Shared UI for the Atelic templates: site chrome, base styles, the component library, and the access gate they install",
5
5
  "type": "module",
6
6
  "license": "UNLICENSED",
@@ -1,5 +1,6 @@
1
1
  import type { ReactNode } from "react";
2
2
  import { renderToStaticMarkup } from "react-dom/server";
3
+ import { escapeHTML } from "../lib/escapeHTML";
3
4
  import { atelicFonts, atelicPalette, type Fonts, type Palette } from "../tokens";
4
5
  import { FailurePage } from "./frame";
5
6
  import { EmailThemeProvider } from "./theme";
@@ -11,16 +12,6 @@ import { EmailThemeProvider } from "./theme";
11
12
  * inside the 680 column go through React.
12
13
  */
13
14
 
14
- /** jq's `esc`, in the same order, for the strings that land in the shell. */
15
- function esc(value: string): string {
16
- return value
17
- .replace(/&/g, "&amp;")
18
- .replace(/</g, "&lt;")
19
- .replace(/>/g, "&gt;")
20
- .replace(/"/g, "&quot;")
21
- .replace(/'/g, "&#39;");
22
- }
23
-
24
15
  const WRAPPER_OPEN = "<table><tbody>";
25
16
  const WRAPPER_CLOSE = "</tbody></table>";
26
17
 
@@ -65,13 +56,13 @@ export function renderEmail({
65
56
  return (
66
57
  `<!doctype html><html lang="en"><head><meta charset="utf-8">` +
67
58
  `<meta name="viewport" content="width=device-width, initial-scale=1">` +
68
- `<meta name="color-scheme" content="light"><title>${esc(title)}</title>` +
59
+ `<meta name="color-scheme" content="light"><title>${escapeHTML(title)}</title>` +
69
60
  `<style>body{margin:0;padding:0;background:${ground}}a{color:${palette.ink}}` +
70
61
  `details>summary{list-style:none}details>summary::-webkit-details-marker{display:none}` +
71
62
  `@media (max-width:700px){.wrap{width:100%!important}}</style></head>` +
72
63
  `<body style="margin:0;padding:0;background:${ground};-webkit-text-size-adjust:100%">` +
73
64
  (preheader
74
- ? `<span style="display:none;font-size:1px;color:${ground};max-height:0;overflow:hidden">${esc(preheader)}</span>`
65
+ ? `<span style="display:none;font-size:1px;color:${ground};max-height:0;overflow:hidden">${escapeHTML(preheader)}</span>`
75
66
  : "") +
76
67
  `<table role="presentation" cellpadding="0" cellspacing="0" border="0" width="100%" style="background:${ground}">` +
77
68
  `<tr><td align="center" style="padding:28px 12px 48px">` +
@@ -17,9 +17,12 @@ export function isAllowed(email: string, allowlist: string | undefined): boolean
17
17
  * A setting that has to be a positive, finite number, or its default. A
18
18
  * lifetime of zero, a negative one, or Infinity would mint a token that is
19
19
  * already dead or that cannot be signed at all. A fraction is allowed (half
20
- * a day), so a caller rounds what it derives to whole seconds.
20
+ * a day), so a caller rounds what it derives to whole seconds. Anything past
21
+ * the ceiling is held to it: a lifetime of 1e308 days is a typo, and the
22
+ * seconds it works out to cannot be signed.
21
23
  */
22
- export function positive(value: string | undefined, fallback: number): number {
24
+ export function positive(value: string | undefined, fallback: number, ceiling: number): number {
23
25
  const parsed = Number(value);
24
- return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
26
+ if (!Number.isFinite(parsed) || parsed <= 0) return fallback;
27
+ return Math.min(parsed, ceiling);
25
28
  }
@@ -15,12 +15,16 @@
15
15
  * - GATE_LINK_TTL_MIN link lifetime in minutes (optional, default 15)
16
16
  * - GATE_BASE_URL the host the link points at (optional, see linkBase)
17
17
  */
18
+ import { escapeHTML } from "../lib/escapeHTML.js";
18
19
  import { isAllowed, positive } from "./config.js";
19
20
  import { safeNext } from "./next.js";
20
21
  import { signToken } from "./tokens.js";
21
22
 
22
23
  const DEFAULT_FROM = "Atelic <forms@atelic.me>";
23
24
 
25
+ /** A day: a sign in link that outlives it is a standing key, not a link. */
26
+ const LONGEST_LINK_MIN = 24 * 60;
27
+
24
28
  /** How long every valid request takes to answer, whatever it did. */
25
29
  const ANSWER_AFTER_MS = 2000;
26
30
 
@@ -46,16 +50,19 @@ function json(status: number, body: Record<string, unknown>): Response {
46
50
  /**
47
51
  * Where the emailed link points. Never a forwarded header: a caller can name
48
52
  * any host there, and the link carries a signed token. The configured base
49
- * wins, read as a host (a bare one is taken as https); on production the
50
- * project's own domain is next; otherwise the host the platform routed this
53
+ * wins, read as a host (a bare one is taken as https, and plain http is
54
+ * accepted only for a local one); on production the project's own domain is
55
+ * next; otherwise the host the platform routed this
51
56
  * request to, which is the deployment itself.
52
57
  */
53
58
  export function linkBase(request: Request, env: Record<string, string | undefined>): string {
54
59
  const configured = env.GATE_BASE_URL?.trim();
55
60
  if (configured) {
56
61
  try {
57
- return new URL(/^https?:\/\//i.test(configured) ? configured : `https://${configured}`)
58
- .origin;
62
+ const base = new URL(/^https?:\/\//i.test(configured) ? configured : `https://${configured}`);
63
+ const local = base.hostname === "localhost" || base.hostname === "127.0.0.1";
64
+ if (base.protocol === "https:" || local) return base.origin;
65
+ console.error("gate: GATE_BASE_URL is plain http, falling back to the request's own host");
59
66
  } catch {
60
67
  console.error("gate: GATE_BASE_URL is not a URL, falling back to the request's own host");
61
68
  }
@@ -66,19 +73,9 @@ export function linkBase(request: Request, env: Record<string, string | undefine
66
73
  return new URL(request.url).origin;
67
74
  }
68
75
 
69
- /** Text made safe to set inside an HTML element or a quoted attribute. */
70
- function escapeHtml(text: string): string {
71
- return text
72
- .replaceAll("&", "&amp;")
73
- .replaceAll("<", "&lt;")
74
- .replaceAll(">", "&gt;")
75
- .replaceAll('"', "&quot;")
76
- .replaceAll("'", "&#39;");
77
- }
78
-
79
- function linkEmailHtml(rawLink: string, rawNoun: string): string {
80
- const link = escapeHtml(rawLink);
81
- const noun = escapeHtml(rawNoun);
76
+ function linkEmailHTML(rawLink: string, rawNoun: string): string {
77
+ const link = escapeHTML(rawLink);
78
+ const noun = escapeHTML(rawNoun);
82
79
  // Gmail renders inline styles only, no style blocks.
83
80
  return `
84
81
  <div style="font-family: Helvetica, Arial, sans-serif; color: #211c17; line-height: 1.6; max-width: 520px;">
@@ -114,7 +111,7 @@ async function sendLink(to: string, link: string, noun: string): Promise<void> {
114
111
  from: process.env.GATE_FROM || DEFAULT_FROM,
115
112
  to: [to],
116
113
  subject: process.env.GATE_SUBJECT || `Your private link to the ${noun}`,
117
- html: linkEmailHtml(link, noun),
114
+ html: linkEmailHTML(link, noun),
118
115
  text: `Your private link to the ${noun}:\n\n${link}\n\nIf you did not expect this, you can ignore it.`,
119
116
  }),
120
117
  });
@@ -155,7 +152,7 @@ export function createRequestLink(options: RequestLinkOptions = {}) {
155
152
  const answered = wait(ANSWER_AFTER_MS);
156
153
 
157
154
  if (secret && isAllowed(email, process.env.GATE_ALLOWLIST)) {
158
- const ttlMinutes = positive(process.env.GATE_LINK_TTL_MIN, 15);
155
+ const ttlMinutes = positive(process.env.GATE_LINK_TTL_MIN, 15, LONGEST_LINK_MIN);
159
156
  try {
160
157
  const token = await signToken(
161
158
  {
@@ -15,6 +15,9 @@ import { isAllowed, positive } from "./config.js";
15
15
  import { safeNext } from "./next.js";
16
16
  import { signToken, verifyToken } from "./tokens.js";
17
17
 
18
+ /** A year: past it a session is no longer something anyone remembers granting. */
19
+ const LONGEST_SESSION_DAYS = 365;
20
+
18
21
  function cookie(name: string, value: string, maxAge: number, httpOnly: boolean): string {
19
22
  const flags = ["Path=/", "Secure", "SameSite=Lax", `Max-Age=${maxAge}`];
20
23
  if (httpOnly) flags.unshift("HttpOnly");
@@ -42,7 +45,7 @@ export async function GET(request: Request): Promise<Response> {
42
45
  return redirect("/login?e=expired");
43
46
  }
44
47
 
45
- const days = positive(process.env.GATE_SESSION_DAYS, 7);
48
+ const days = positive(process.env.GATE_SESSION_DAYS, 7, LONGEST_SESSION_DAYS);
46
49
  // Whole seconds: a browser ignores a Max-Age that is not an integer.
47
50
  const maxAge = Math.max(1, Math.round(days * 24 * 60 * 60));
48
51
  const session = await signToken(
package/src/gate/wall.ts CHANGED
@@ -53,7 +53,9 @@ const HELD_PREFIXES = ["/assets/doc/"];
53
53
  * The path is read the way a file server would read it (decoded, repeated
54
54
  * slashes collapsed) so an encoded or doubled slash cannot walk a held file
55
55
  * out through an open prefix; anything that will not decode, or still climbs
56
- * a directory once decoded, is held. Case is folded for the held corner
56
+ * a directory once decoded, is held, and so is one that still carries a
57
+ * percent sign, since a second decode somewhere downstream would make it a
58
+ * different path than the one judged here. Case is folded for the held corner
57
59
  * alone, so no spelling of it slips out, while an open path opens only as
58
60
  * written: the host serves files by exact case, and a path that merely looks
59
61
  * like an open one names some other file.
@@ -66,7 +68,7 @@ export function needsSession(pathname: string): boolean {
66
68
  return true;
67
69
  }
68
70
  path = path.replace(/\/{2,}/g, "/");
69
- if (path.includes("..") || path.includes("\\")) return true;
71
+ if (path.includes("..") || path.includes("\\") || path.includes("%")) return true;
70
72
  const folded = path.toLowerCase();
71
73
  if (HELD_PREFIXES.some((prefix) => folded.startsWith(prefix))) return true;
72
74
  if (OPEN_EXACT.has(path)) return false;
@@ -0,0 +1,13 @@
1
+ /**
2
+ * Text made safe to set inside an HTML element or a quoted attribute. Plain
3
+ * TypeScript with no imports, so the email renderer and the access gate's
4
+ * server side (which ships compiled) both use the one copy.
5
+ */
6
+ export function escapeHTML(value: string): string {
7
+ return value
8
+ .replace(/&/g, "&amp;")
9
+ .replace(/</g, "&lt;")
10
+ .replace(/>/g, "&gt;")
11
+ .replace(/"/g, "&quot;")
12
+ .replace(/'/g, "&#39;");
13
+ }