Skip to content

Commit 1581346

Browse files
committed
fix: safe JSON serialization for inline scripts, env-aware template cache
1 parent 6823039 commit 1581346

4 files changed

Lines changed: 40 additions & 20 deletions

File tree

src/controllers/invoices/get-invoice-controller.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { Request, Response } from 'express'
33
import { createSettings } from '../../factories/settings-factory'
44
import { FeeSchedule } from '../../@types/settings'
55
import { IController } from '../../@types/controllers'
6-
import { escapeHtml } from '../../utils/html'
6+
import { escapeHtml, safeJsonForScript } from '../../utils/html'
77
import { getTemplate } from '../../utils/template-cache'
88

99

@@ -21,7 +21,7 @@ export class GetInvoiceController implements IController {
2121
const feeSchedule = path<FeeSchedule>(['payments', 'feeSchedules', 'admission', '0'], settings)
2222
const page = getTemplate('./resources/get-invoice.html')
2323
.replaceAll('{{name}}', escapeHtml(name))
24-
.replaceAll('{{processor_json}}', JSON.stringify(settings.payments.processor))
24+
.replaceAll('{{processor_json}}', safeJsonForScript(settings.payments.processor))
2525
.replaceAll('{{amount}}', (BigInt(feeSchedule.amount) / 1000n).toString())
2626
.replaceAll('{{nonce}}', res.locals.nonce)
2727

src/controllers/invoices/post-invoice-controller.ts

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { fromBech32, toBech32 } from '../../utils/transform'
33
import { getPublicKey, getRelayPrivateKey } from '../../utils/event'
44
import { Request, Response } from 'express'
55

6-
import { escapeHtml } from '../../utils/html'
6+
import { escapeHtml, safeJsonForScript } from '../../utils/html'
77
import { createLogger } from '../../factories/logger-factory'
88
import { getRemoteAddress } from '../../utils/http'
99
import { IController } from '../../@types/controllers'
@@ -171,14 +171,14 @@ export class PostInvoiceController implements IController {
171171
.replaceAll('{{invoice_html}}', escapeHtml(invoice.bolt11))
172172
.replaceAll('{{pubkey_html}}', escapeHtml(pubkey))
173173
.replaceAll('{{amount}}', (amount / 1000n).toString())
174-
// JS string contexts — JSON.stringify handles all escaping (quotes, backslashes, </script>)
175-
.replaceAll('{{reference_json}}', JSON.stringify(invoice.id))
176-
.replaceAll('{{relay_url_json}}', JSON.stringify(relayUrl))
177-
.replaceAll('{{relay_pubkey_json}}', JSON.stringify(relayPubkey))
178-
.replaceAll('{{invoice_json}}', JSON.stringify(invoice.bolt11))
179-
.replaceAll('{{pubkey_json}}', JSON.stringify(pubkey))
180-
.replaceAll('{{expires_at_json}}', JSON.stringify(expiresAt))
181-
.replaceAll('{{processor_json}}', JSON.stringify(currentSettings.payments.processor))
174+
// JS contexts — safeJsonForScript serializes and escapes < to prevent </script> injection
175+
.replaceAll('{{reference_json}}', safeJsonForScript(invoice.id))
176+
.replaceAll('{{relay_url_json}}', safeJsonForScript(relayUrl))
177+
.replaceAll('{{relay_pubkey_json}}', safeJsonForScript(relayPubkey))
178+
.replaceAll('{{invoice_json}}', safeJsonForScript(invoice.bolt11))
179+
.replaceAll('{{pubkey_json}}', safeJsonForScript(pubkey))
180+
.replaceAll('{{expires_at_json}}', safeJsonForScript(expiresAt))
181+
.replaceAll('{{processor_json}}', safeJsonForScript(currentSettings.payments.processor))
182182
// nonce is crypto-random base64 — safe in both attribute and script contexts
183183
.replaceAll('{{nonce}}', response.locals.nonce)
184184

src/utils/html.ts

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,17 @@ const HTML_ESCAPES: Record<string, string> = {
88

99
/**
1010
* Escape a string for safe interpolation into HTML text or attribute values.
11-
* Always use this (or JSON.stringify for JS contexts) on any value before
12-
* inserting it into an HTML template via string replacement.
1311
*/
1412
export const escapeHtml = (value: string): string =>
1513
value.replace(/[&<>"']/g, (ch) => HTML_ESCAPES[ch])
14+
15+
/**
16+
* Serialize a value for safe embedding inside an inline <script> block.
17+
*
18+
* JSON.stringify alone is NOT sufficient: it leaves `<` unescaped, so a value
19+
* containing `</script>` would terminate the script block and allow injection.
20+
* After serializing, replace every `<` with the Unicode escape `\u003C`, which
21+
* is valid JSON and prevents the browser from treating the character as markup.
22+
*/
23+
export const safeJsonForScript = (value: unknown): string =>
24+
JSON.stringify(value).replace(/</g, '\\u003C')

src/utils/template-cache.ts

Lines changed: 18 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,28 @@
11
import { readFileSync } from 'fs'
22

33
const cache = new Map<string, string>()
4+
const isProd = process.env.NODE_ENV === 'production'
45

56
/**
67
* Return the raw content of a template file.
7-
* The file is read from disk exactly once; subsequent calls return the cached
8-
* string without any I/O, keeping template reads off the hot request path.
8+
*
9+
* In production (NODE_ENV=production) the file is read from disk once and
10+
* cached for the lifetime of the process — no per-request I/O. Operators who
11+
* edit files under resources/ must restart the process for changes to take
12+
* effect.
13+
*
14+
* Outside of production the cache is bypassed so template edits are reflected
15+
* immediately without a restart.
916
*/
1017
export const getTemplate = (path: string): string => {
11-
let template = cache.get(path)
12-
if (template === undefined) {
13-
template = readFileSync(path, 'utf8')
14-
cache.set(path, template)
18+
if (isProd) {
19+
let template = cache.get(path)
20+
if (template === undefined) {
21+
template = readFileSync(path, 'utf8')
22+
cache.set(path, template)
23+
}
24+
return template
1525
}
16-
return template
26+
27+
return readFileSync(path, 'utf8')
1728
}

0 commit comments

Comments
 (0)