@transclude/core 0.1.0
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/LICENSE +21 -0
- package/README.md +121 -0
- package/bin/build.js +469 -0
- package/bin/check.js +78 -0
- package/bin/dev.js +348 -0
- package/bin/release.js +176 -0
- package/bin/serve.bun.js +15 -0
- package/bin/serve.deno.js +15 -0
- package/bin/serve.js +12 -0
- package/editor/server.js +172 -0
- package/editor/vscode/extension.js +49 -0
- package/editor/vscode/package.json +32 -0
- package/editor/vscode/syntaxes/transclude.injection.json +41 -0
- package/package.json +82 -0
- package/src/address.js +183 -0
- package/src/app.js +492 -0
- package/src/cache.js +137 -0
- package/src/compiler/bind.js +496 -0
- package/src/compiler/codegen.js +1061 -0
- package/src/compiler/expr.js +221 -0
- package/src/compiler/index.js +964 -0
- package/src/compiler/interp.js +82 -0
- package/src/compiler/script.js +620 -0
- package/src/compiler/shim.js +756 -0
- package/src/compiler/sourcemap.js +140 -0
- package/src/compiler/types.js +163 -0
- package/src/compress.js +104 -0
- package/src/cookies.js +157 -0
- package/src/csp.js +192 -0
- package/src/document.js +604 -0
- package/src/extract.js +339 -0
- package/src/feed.js +194 -0
- package/src/include.js +89 -0
- package/src/lookup.js +49 -0
- package/src/negotiate.js +95 -0
- package/src/plugin.js +423 -0
- package/src/pool.js +29 -0
- package/src/precache.js +68 -0
- package/src/production.js +159 -0
- package/src/project.js +110 -0
- package/src/proxy.js +319 -0
- package/src/public-files.js +77 -0
- package/src/rewrite.js +281 -0
- package/src/routes.js +199 -0
- package/src/runtime/index.js +1345 -0
- package/src/server.js +183 -0
- package/src/sitemap.js +124 -0
- package/src/static-cache.js +170 -0
- package/src/typecheck.js +492 -0
- package/src/worker.js +87 -0
package/src/csp.js
ADDED
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
// A Content-Security-Policy for the document, built from what the document
|
|
2
|
+
// actually inlines.
|
|
3
|
+
//
|
|
4
|
+
// Hashes rather than nonces, which is the opposite of what most frameworks do,
|
|
5
|
+
// and the right way round here. A nonce has to be fresh per request, so a page
|
|
6
|
+
// carrying one cannot be cached, and every prerendered page here is a file that
|
|
7
|
+
// is written once, compressed once, and sent as bytes. Swapping a nonce into the
|
|
8
|
+
// body on the way out would mean decompressing and recompressing every response.
|
|
9
|
+
//
|
|
10
|
+
// A hash needs none of that. The inline content is fixed when the page is
|
|
11
|
+
// rendered, so the policy is fixed too: computed once, written into the file,
|
|
12
|
+
// and correct forever after with no server involved. A static host serves it
|
|
13
|
+
// unchanged.
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* What a page gets when the config says `true`.
|
|
17
|
+
*
|
|
18
|
+
* `script-src` is hashed and `style-src` is not, which looks inconsistent and is
|
|
19
|
+
* the only combination that works. A hash never covers an attribute: `style="…"`
|
|
20
|
+
* on an element is checked against `style-src`, and the spec says a hash there
|
|
21
|
+
* applies to `<style>` blocks only. Worse, `'unsafe-inline'` is *ignored* in a
|
|
22
|
+
* directive that carries any hash, so listing both allows nothing extra.
|
|
23
|
+
*
|
|
24
|
+
* So hashing styles means no element may carry a `style` attribute, and
|
|
25
|
+
* `style="view-transition-name: …"` is an ordinary thing to write here. Script
|
|
26
|
+
* is where the protection matters: CSS cannot run code, and `script-src` stays
|
|
27
|
+
* strict. Put `'hashes'` in `style-src` yourself if your pages have no inline
|
|
28
|
+
* style attributes at all.
|
|
29
|
+
*/
|
|
30
|
+
export const CSP_DEFAULTS = {
|
|
31
|
+
'default-src': ["'self'"],
|
|
32
|
+
'script-src': ["'self'", "'hashes'"],
|
|
33
|
+
'style-src': ["'self'", "'unsafe-inline'"],
|
|
34
|
+
'img-src': ["'self'", 'data:'],
|
|
35
|
+
'object-src': ["'none'"],
|
|
36
|
+
'base-uri': ["'self'"],
|
|
37
|
+
'form-action': ["'self'"],
|
|
38
|
+
// Clickjacking. A meta tag cannot carry it, so it rides in the header the
|
|
39
|
+
// server sets, which is the reason there is a header at all.
|
|
40
|
+
'frame-ancestors': ["'self'"],
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* `<script>` and `<style>` bodies in a document, in source order.
|
|
45
|
+
*
|
|
46
|
+
* Both are raw text elements: the browser ends them at the first closing tag
|
|
47
|
+
* whatever the content, so matching that way is not an approximation of the
|
|
48
|
+
* parser, it is the same rule. A `<script src>` runs a file rather than a body
|
|
49
|
+
* and is covered by `'self'`, so it is skipped.
|
|
50
|
+
*
|
|
51
|
+
* @param {string} html the rendered document
|
|
52
|
+
* @returns {Array<{ kind: string, body: string }>} every inline block, in source
|
|
53
|
+
* order, each saying which kind it is
|
|
54
|
+
*/
|
|
55
|
+
export function inlineSources(html) {
|
|
56
|
+
const found = [];
|
|
57
|
+
|
|
58
|
+
for (const [, attrs, body] of html.matchAll(/<script([^>]*)>([\s\S]*?)<\/script>/g)) {
|
|
59
|
+
if (!/\ssrc\s*=/.test(attrs)) found.push({ kind: 'script', body });
|
|
60
|
+
}
|
|
61
|
+
for (const [, , body] of html.matchAll(/<style([^>]*)>([\s\S]*?)<\/style>/g)) {
|
|
62
|
+
found.push({ kind: 'style', body });
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
return found;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* `'sha256-…'`, the form a policy takes.
|
|
70
|
+
*
|
|
71
|
+
* WebCrypto rather than the injected `hash`: that one is an ETag, documented as
|
|
72
|
+
* a cache key and not a signature, and a test hands it a fake. This has to be a
|
|
73
|
+
* real digest or the browser refuses the script. `crypto.subtle` and `btoa` are
|
|
74
|
+
* globals on all four runtimes, so neither costs the core a `node:` import.
|
|
75
|
+
*/
|
|
76
|
+
async function sha256(source) {
|
|
77
|
+
const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(source));
|
|
78
|
+
|
|
79
|
+
let binary = '';
|
|
80
|
+
for (const byte of new Uint8Array(digest)) binary += String.fromCharCode(byte);
|
|
81
|
+
|
|
82
|
+
return `'sha256-${btoa(binary)}'`;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* The policy for one document.
|
|
87
|
+
*
|
|
88
|
+
* `'hashes'` in a source list is where this page's own digests go, so an author
|
|
89
|
+
* replacing the defaults decides which directives get them. Empty means the
|
|
90
|
+
* literal is dropped rather than left in, because `script-src 'self' 'hashes'`
|
|
91
|
+
* with nothing to substitute is a policy naming a source that does not exist.
|
|
92
|
+
*
|
|
93
|
+
* @param {string} html
|
|
94
|
+
* @param {{ directives?: Record<string, string[]> }} [options]
|
|
95
|
+
* @returns {Promise<string>} the policy, with every `'hashes'` replaced
|
|
96
|
+
*/
|
|
97
|
+
export async function policyFor(html, { directives = CSP_DEFAULTS } = {}) {
|
|
98
|
+
const inline = inlineSources(html);
|
|
99
|
+
|
|
100
|
+
const scripts = await Promise.all(
|
|
101
|
+
inline.filter((one) => one.kind === 'script').map((one) => sha256(one.body)),
|
|
102
|
+
);
|
|
103
|
+
const styles = await Promise.all(
|
|
104
|
+
inline.filter((one) => one.kind === 'style').map((one) => sha256(one.body)),
|
|
105
|
+
);
|
|
106
|
+
|
|
107
|
+
const parts = [];
|
|
108
|
+
for (const [name, sources] of Object.entries(directives)) {
|
|
109
|
+
const hashes = name === 'script-src' ? scripts : name === 'style-src' ? styles : [];
|
|
110
|
+
const resolved = sources.flatMap((source) => (source === "'hashes'" ? hashes : [source]));
|
|
111
|
+
|
|
112
|
+
// A directive left with nothing is dropped. An empty source list means
|
|
113
|
+
// "allow nothing", which is `'none'`, and saying that by accident would
|
|
114
|
+
// break the page rather than protect it.
|
|
115
|
+
if (resolved.length) parts.push(`${name} ${[...new Set(resolved)].join(' ')}`);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
return parts.join('; ');
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* The policy as a `<meta>`, inserted just before `</head>`.
|
|
123
|
+
*
|
|
124
|
+
* A meta tag rather than a header, because a prerendered page is a file and
|
|
125
|
+
* `dist/static` is meant to be servable by a host that knows nothing about this
|
|
126
|
+
* framework. `frame-ancestors`, `report-uri`, `report-to` and `sandbox` cannot
|
|
127
|
+
* be delivered this way and are dropped; set those as real headers at the host.
|
|
128
|
+
*/
|
|
129
|
+
const META_ONLY = new Set(['frame-ancestors', 'report-uri', 'report-to', 'sandbox']);
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* The directives a meta tag cannot carry, as a header.
|
|
133
|
+
*
|
|
134
|
+
* None of them names a hash, so this string is the same for every page: it can
|
|
135
|
+
* be set once as middleware, ahead of any route, and it costs a prerendered page
|
|
136
|
+
* nothing. That is why the split is worth having rather than reading the meta
|
|
137
|
+
* back out of a body that is already compressed.
|
|
138
|
+
*
|
|
139
|
+
* `null` when there are none, which is the default. Adding a header nobody asked
|
|
140
|
+
* for is a header to explain later.
|
|
141
|
+
*
|
|
142
|
+
* @param {object|boolean|null} config
|
|
143
|
+
* @returns {{ name: string, value: string }|null} the directives a meta tag
|
|
144
|
+
* cannot carry, and which header carries them
|
|
145
|
+
*/
|
|
146
|
+
export function headerPolicy(config) {
|
|
147
|
+
if (!config) return null;
|
|
148
|
+
|
|
149
|
+
const options = config === true ? {} : config;
|
|
150
|
+
const directives = options.directives ?? CSP_DEFAULTS;
|
|
151
|
+
|
|
152
|
+
const parts = Object.entries(directives)
|
|
153
|
+
.filter(([name]) => META_ONLY.has(name))
|
|
154
|
+
.map(([name, sources]) => `${name} ${[...new Set(sources)].join(' ')}`);
|
|
155
|
+
|
|
156
|
+
if (!parts.length) return null;
|
|
157
|
+
|
|
158
|
+
return {
|
|
159
|
+
name: options.reportOnly
|
|
160
|
+
? 'Content-Security-Policy-Report-Only'
|
|
161
|
+
: 'Content-Security-Policy',
|
|
162
|
+
value: parts.join('; '),
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* @param {string} html
|
|
168
|
+
* @param {object|boolean|null} config
|
|
169
|
+
* @returns {Promise<string>} the document with its meta tag, or unchanged when off
|
|
170
|
+
*/
|
|
171
|
+
export async function withPolicy(html, config) {
|
|
172
|
+
if (!config) return html;
|
|
173
|
+
|
|
174
|
+
const options = config === true ? {} : config;
|
|
175
|
+
const directives = options.directives ?? CSP_DEFAULTS;
|
|
176
|
+
|
|
177
|
+
const deliverable = Object.fromEntries(
|
|
178
|
+
Object.entries(directives).filter(([name]) => !META_ONLY.has(name)),
|
|
179
|
+
);
|
|
180
|
+
|
|
181
|
+
const policy = await policyFor(html, { directives: deliverable });
|
|
182
|
+
if (!policy) return html;
|
|
183
|
+
|
|
184
|
+
const name = options.reportOnly
|
|
185
|
+
? 'content-security-policy-report-only'
|
|
186
|
+
: 'content-security-policy';
|
|
187
|
+
const meta = `<meta http-equiv="${name}" content="${policy.replace(/"/g, '"')}">`;
|
|
188
|
+
|
|
189
|
+
// One `</head>` in a document this built. Inserted last so a hash covers every
|
|
190
|
+
// inline block above it, and the meta itself carries none to cover.
|
|
191
|
+
return html.replace('</head>', `${meta}\n</head>`);
|
|
192
|
+
}
|