@magland/mochi 0.3.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/LICENSE +201 -0
- package/README.md +108 -0
- package/dist/ansi.js +174 -0
- package/dist/api/admin.js +416 -0
- package/dist/api/auth.js +166 -0
- package/dist/api/backup.js +598 -0
- package/dist/api/ci.js +336 -0
- package/dist/api/contents.js +339 -0
- package/dist/api/issues.js +165 -0
- package/dist/api/pulls.js +244 -0
- package/dist/api/releases.js +83 -0
- package/dist/api/repos.js +156 -0
- package/dist/api/write.js +518 -0
- package/dist/api.js +326 -0
- package/dist/assets.js +29 -0
- package/dist/atom.js +32 -0
- package/dist/atomic.js +171 -0
- package/dist/avatar.js +81 -0
- package/dist/browse.js +630 -0
- package/dist/build-info.json +4 -0
- package/dist/ci/actionref.js +86 -0
- package/dist/ci/api.js +829 -0
- package/dist/ci/artifacts.js +201 -0
- package/dist/ci/dispatch.js +30 -0
- package/dist/ci/engine.js +1321 -0
- package/dist/ci/expr.js +526 -0
- package/dist/ci/manual.js +199 -0
- package/dist/ci/present.js +82 -0
- package/dist/ci/protocol.js +6 -0
- package/dist/ci/runners.js +256 -0
- package/dist/ci/runs.js +208 -0
- package/dist/ci/trigger.js +28 -0
- package/dist/ci/views.js +441 -0
- package/dist/ci/wake.js +194 -0
- package/dist/ci/web.js +617 -0
- package/dist/ci/workflow.js +436 -0
- package/dist/cli/admin-cmd.js +324 -0
- package/dist/cli/api-cmd.js +128 -0
- package/dist/cli/backup-cmd.js +1500 -0
- package/dist/cli/exit.js +69 -0
- package/dist/cli/input.js +64 -0
- package/dist/cli/issue-cmd.js +243 -0
- package/dist/cli/output.js +93 -0
- package/dist/cli/parse.js +317 -0
- package/dist/cli/pr-cmd.js +289 -0
- package/dist/cli/release-cmd.js +171 -0
- package/dist/cli/repo-cmd.js +763 -0
- package/dist/cli/repo.js +101 -0
- package/dist/cli/run-cmd.js +438 -0
- package/dist/cli/target.js +54 -0
- package/dist/cli-api.js +84 -0
- package/dist/compare.js +111 -0
- package/dist/config.js +212 -0
- package/dist/credentials.js +235 -0
- package/dist/deploy-cli.js +859 -0
- package/dist/deploy-runner-cli.js +592 -0
- package/dist/diff.js +171 -0
- package/dist/discussion.js +253 -0
- package/dist/egress.js +559 -0
- package/dist/filecache.js +68 -0
- package/dist/find.js +162 -0
- package/dist/forms.js +737 -0
- package/dist/git.js +547 -0
- package/dist/githttp.js +428 -0
- package/dist/html.js +87 -0
- package/dist/icons.js +101 -0
- package/dist/import-cli.js +316 -0
- package/dist/index.js +752 -0
- package/dist/issues.js +308 -0
- package/dist/issueweb.js +447 -0
- package/dist/job-cli.js +197 -0
- package/dist/jobtoken.js +96 -0
- package/dist/languages.js +383 -0
- package/dist/layout.js +100 -0
- package/dist/lfs.js +438 -0
- package/dist/lfsstore.js +425 -0
- package/dist/limit.js +259 -0
- package/dist/logo.js +61 -0
- package/dist/markdown.js +382 -0
- package/dist/migrate.js +334 -0
- package/dist/multipart.js +90 -0
- package/dist/ops.js +869 -0
- package/dist/pagescript.js +465 -0
- package/dist/perms.js +370 -0
- package/dist/pointer.js +55 -0
- package/dist/profile.js +106 -0
- package/dist/pulls.js +320 -0
- package/dist/pullweb.js +461 -0
- package/dist/redirects.js +455 -0
- package/dist/releases.js +435 -0
- package/dist/render.js +233 -0
- package/dist/runner/actions.js +448 -0
- package/dist/runner/client.js +428 -0
- package/dist/runner/context.js +247 -0
- package/dist/runner/docker.js +197 -0
- package/dist/runner/externals.js +175 -0
- package/dist/runner/job.js +290 -0
- package/dist/runner/manual-run.js +272 -0
- package/dist/runner/overrides.js +554 -0
- package/dist/runner/steps.js +571 -0
- package/dist/runner/wake.js +84 -0
- package/dist/runner-cli.js +405 -0
- package/dist/scan.js +231 -0
- package/dist/server.js +424 -0
- package/dist/session.js +267 -0
- package/dist/site.js +259 -0
- package/dist/siteshost.js +94 -0
- package/dist/source.js +90 -0
- package/dist/style.js +1295 -0
- package/dist/themes.js +369 -0
- package/dist/vault.js +442 -0
- package/dist/version.js +88 -0
- package/dist/views.js +1007 -0
- package/dist/web.js +182 -0
- package/dist/webops.js +1402 -0
- package/package.json +71 -0
package/dist/session.js
ADDED
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
14
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
15
|
+
}) : function(o, v) {
|
|
16
|
+
o["default"] = v;
|
|
17
|
+
});
|
|
18
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
19
|
+
var ownKeys = function(o) {
|
|
20
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
21
|
+
var ar = [];
|
|
22
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
23
|
+
return ar;
|
|
24
|
+
};
|
|
25
|
+
return ownKeys(o);
|
|
26
|
+
};
|
|
27
|
+
return function (mod) {
|
|
28
|
+
if (mod && mod.__esModule) return mod;
|
|
29
|
+
var result = {};
|
|
30
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
31
|
+
__setModuleDefault(result, mod);
|
|
32
|
+
return result;
|
|
33
|
+
};
|
|
34
|
+
})();
|
|
35
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
+
exports.getSecret = getSecret;
|
|
37
|
+
exports.setSessionCookie = setSessionCookie;
|
|
38
|
+
exports.clearSessionCookie = clearSessionCookie;
|
|
39
|
+
exports.getViewer = getViewer;
|
|
40
|
+
exports.viewerIsAdmin = viewerIsAdmin;
|
|
41
|
+
exports.checkCsrf = checkCsrf;
|
|
42
|
+
exports.csrfMatches = csrfMatches;
|
|
43
|
+
const crypto = __importStar(require("crypto"));
|
|
44
|
+
const fs = __importStar(require("fs"));
|
|
45
|
+
const path = __importStar(require("path"));
|
|
46
|
+
const config_1 = require("./config");
|
|
47
|
+
const filecache_1 = require("./filecache");
|
|
48
|
+
const siteshost_1 = require("./siteshost");
|
|
49
|
+
const perms_1 = require("./perms");
|
|
50
|
+
const vault_1 = require("./vault");
|
|
51
|
+
// Stateless signed-cookie sessions on top of the token model. The payload is
|
|
52
|
+
// base64url JSON plus an HMAC keyed by <vault>/.secret. There is no server-side
|
|
53
|
+
// session store: permissions are re-derived from live vault.json on every
|
|
54
|
+
// request, so deleting a user's tokens cuts them off, and rotating .secret
|
|
55
|
+
// invalidates every session at once.
|
|
56
|
+
//
|
|
57
|
+
// A session is bound to the single token it was minted from, by recording that
|
|
58
|
+
// token's SHA-256 hash (the same value vault.json stores, so no new secret is
|
|
59
|
+
// put in the cookie, and the hash is useless as a credential). Resolving a
|
|
60
|
+
// session looks that token up in live vault.json, so deleting one token ends
|
|
61
|
+
// the sessions it started and leaves the user's other sessions alone. The
|
|
62
|
+
// token record found this way is the real one, which is why the session carries
|
|
63
|
+
// no copy of the token's scope: the role checks in src/perms.ts read it from
|
|
64
|
+
// the vault.
|
|
65
|
+
// Cookies are not scoped by origin, so any document on any host under a shared
|
|
66
|
+
// parent domain can set a cookie named mochi_session with
|
|
67
|
+
// Domain=<parent>, which the browser then also sends here. With several vaults
|
|
68
|
+
// on sibling subdomains of one domain, that lets one of them shadow another's
|
|
69
|
+
// session. Browsers refuse a __Host--prefixed cookie that carries a Domain
|
|
70
|
+
// attribute at all, which closes it.
|
|
71
|
+
//
|
|
72
|
+
// The prefix also requires Secure and Path=/, so the name is conditional on
|
|
73
|
+
// exactly when the prefix is legal: setSessionCookie already sets path '/' and
|
|
74
|
+
// sets secure from req.protocol, and a plain-http vault keeps the bare name.
|
|
75
|
+
const HOST_COOKIE_NAME = '__Host-mochi_session';
|
|
76
|
+
const COOKIE_NAME = 'mochi_session';
|
|
77
|
+
const cookieName = (req) => (req.protocol === 'https' ? HOST_COOKIE_NAME : COOKIE_NAME);
|
|
78
|
+
const SESSION_MS = 30 * 24 * 60 * 60 * 1000;
|
|
79
|
+
/** Write a fresh signing key where one is missing or unusable. */
|
|
80
|
+
function mintSecret(file) {
|
|
81
|
+
const secret = crypto.randomBytes(32);
|
|
82
|
+
fs.writeFileSync(file, secret, { mode: 0o600 });
|
|
83
|
+
return secret;
|
|
84
|
+
}
|
|
85
|
+
// Stat-cached rather than held forever, so that rotating the file does what
|
|
86
|
+
// the documentation says it does -- ends every session at once -- on the
|
|
87
|
+
// running server, not on the next restart. The stat per check is the same
|
|
88
|
+
// price every other state file pays.
|
|
89
|
+
const secretCache = (0, filecache_1.fileCache)({
|
|
90
|
+
read: (file) => {
|
|
91
|
+
try {
|
|
92
|
+
const secret = fs.readFileSync(file);
|
|
93
|
+
if (secret.length >= 32)
|
|
94
|
+
return secret;
|
|
95
|
+
}
|
|
96
|
+
catch {
|
|
97
|
+
// unreadable; replaced below
|
|
98
|
+
}
|
|
99
|
+
return mintSecret(file);
|
|
100
|
+
},
|
|
101
|
+
missing: (file) => mintSecret(file),
|
|
102
|
+
});
|
|
103
|
+
function getSecret(root) {
|
|
104
|
+
return secretCache.get(path.join(root, '.secret'));
|
|
105
|
+
}
|
|
106
|
+
function b64url(buf) {
|
|
107
|
+
return buf.toString('base64url');
|
|
108
|
+
}
|
|
109
|
+
function sign(root, data) {
|
|
110
|
+
return b64url(crypto.createHmac('sha256', getSecret(root)).update(data).digest());
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* Whether this request arrived on a hostname that serves sites. No session is
|
|
114
|
+
* resolved and none is minted there. The sites middleware never asks for a
|
|
115
|
+
* viewer, so this is defence in depth: it makes structurally true what would
|
|
116
|
+
* otherwise be true only by inspection.
|
|
117
|
+
*/
|
|
118
|
+
function onSitesHost(req, root) {
|
|
119
|
+
return (0, siteshost_1.isUnderSitesHost)((0, config_1.loadConfig)(root).sites.host, req.hostname);
|
|
120
|
+
}
|
|
121
|
+
function setSessionCookie(req, res, root, auth) {
|
|
122
|
+
if (onSitesHost(req, root))
|
|
123
|
+
return;
|
|
124
|
+
const payload = {
|
|
125
|
+
u: auth.username,
|
|
126
|
+
exp: Date.now() + SESSION_MS,
|
|
127
|
+
csrf: crypto.randomBytes(16).toString('hex'),
|
|
128
|
+
t: auth.token.hash,
|
|
129
|
+
};
|
|
130
|
+
const body = b64url(Buffer.from(JSON.stringify(payload), 'utf8'));
|
|
131
|
+
res.cookie(cookieName(req), `${body}.${sign(root, body)}`, {
|
|
132
|
+
httpOnly: true,
|
|
133
|
+
sameSite: 'lax',
|
|
134
|
+
secure: req.protocol === 'https',
|
|
135
|
+
maxAge: SESSION_MS,
|
|
136
|
+
path: '/',
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
// Both names, since a session minted before the prefix existed, or over http
|
|
140
|
+
// on a vault that has since gained TLS, is still a session to clear.
|
|
141
|
+
function clearSessionCookie(res) {
|
|
142
|
+
res.clearCookie(HOST_COOKIE_NAME, { path: '/' });
|
|
143
|
+
res.clearCookie(COOKIE_NAME, { path: '/' });
|
|
144
|
+
}
|
|
145
|
+
function parseCookies(header) {
|
|
146
|
+
const out = {};
|
|
147
|
+
if (!header)
|
|
148
|
+
return out;
|
|
149
|
+
for (const part of header.split(';')) {
|
|
150
|
+
const i = part.indexOf('=');
|
|
151
|
+
if (i === -1)
|
|
152
|
+
continue;
|
|
153
|
+
try {
|
|
154
|
+
out[part.slice(0, i).trim()] = decodeURIComponent(part.slice(i + 1).trim());
|
|
155
|
+
}
|
|
156
|
+
catch {
|
|
157
|
+
// malformed cookie value; skip
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
return out;
|
|
161
|
+
}
|
|
162
|
+
function readSession(req, root) {
|
|
163
|
+
// The prefixed name first, then the bare one, so a session minted before this
|
|
164
|
+
// existed survives. A sibling subdomain can still set the bare name, which is
|
|
165
|
+
// why the prefixed one wins when both arrive.
|
|
166
|
+
const cookies = parseCookies(req.headers.cookie);
|
|
167
|
+
const raw = cookies[HOST_COOKIE_NAME] ?? cookies[COOKIE_NAME];
|
|
168
|
+
if (!raw)
|
|
169
|
+
return null;
|
|
170
|
+
const dot = raw.lastIndexOf('.');
|
|
171
|
+
if (dot === -1)
|
|
172
|
+
return null;
|
|
173
|
+
const body = raw.slice(0, dot);
|
|
174
|
+
const sig = raw.slice(dot + 1);
|
|
175
|
+
const expected = sign(root, body);
|
|
176
|
+
const a = Buffer.from(sig);
|
|
177
|
+
const b = Buffer.from(expected);
|
|
178
|
+
if (a.length !== b.length || !crypto.timingSafeEqual(a, b))
|
|
179
|
+
return null;
|
|
180
|
+
let payload;
|
|
181
|
+
try {
|
|
182
|
+
payload = JSON.parse(Buffer.from(body, 'base64url').toString('utf8'));
|
|
183
|
+
}
|
|
184
|
+
catch {
|
|
185
|
+
return null;
|
|
186
|
+
}
|
|
187
|
+
if (typeof payload !== 'object' || payload === null)
|
|
188
|
+
return null;
|
|
189
|
+
const p = payload;
|
|
190
|
+
if (typeof p.u !== 'string' || typeof p.exp !== 'number' || typeof p.csrf !== 'string')
|
|
191
|
+
return null;
|
|
192
|
+
if (p.exp < Date.now())
|
|
193
|
+
return null;
|
|
194
|
+
// A cookie from before sessions were bound to a token has no `t`, and is
|
|
195
|
+
// rejected rather than accepted unbound: the holder is simply signed out and
|
|
196
|
+
// signs in again, which is a smaller cost than leaving unrevocable sessions
|
|
197
|
+
// alive for the rest of their thirty days.
|
|
198
|
+
if (typeof p.t !== 'string' || p.t === '')
|
|
199
|
+
return null;
|
|
200
|
+
return { u: p.u, exp: p.exp, csrf: p.csrf, t: p.t };
|
|
201
|
+
}
|
|
202
|
+
function getViewer(req, root) {
|
|
203
|
+
if (onSitesHost(req, root))
|
|
204
|
+
return null;
|
|
205
|
+
const session = readSession(req, root);
|
|
206
|
+
if (!session)
|
|
207
|
+
return null;
|
|
208
|
+
const state = (0, vault_1.loadVault)(root);
|
|
209
|
+
if (state.status !== 'ok')
|
|
210
|
+
return null;
|
|
211
|
+
const user = state.vault.users[session.u];
|
|
212
|
+
if (!user)
|
|
213
|
+
return null;
|
|
214
|
+
const token = user.tokens.find((t) => t.hash === session.t);
|
|
215
|
+
if (!token)
|
|
216
|
+
return null;
|
|
217
|
+
return { auth: { username: session.u, user, token }, csrf: session.csrf };
|
|
218
|
+
}
|
|
219
|
+
function viewerIsAdmin(viewer) {
|
|
220
|
+
return viewer !== null && (0, perms_1.isSiteAdmin)(viewer.auth);
|
|
221
|
+
}
|
|
222
|
+
function checkCsrf(req, viewer) {
|
|
223
|
+
const presented = req.body?.csrf;
|
|
224
|
+
return typeof presented === 'string' && csrfMatches(req, presented, viewer);
|
|
225
|
+
}
|
|
226
|
+
/**
|
|
227
|
+
* A state-changing request whose Origin names somewhere else is refused. This
|
|
228
|
+
* does nothing against same-origin script, which is what the site sandbox in
|
|
229
|
+
* src/site.ts closes; it is a few lines that catch a misconfigured proxy, and it
|
|
230
|
+
* belongs next to the CSRF check so the two are read together. An absent Origin
|
|
231
|
+
* is not a failure: plenty of legitimate clients send none.
|
|
232
|
+
*/
|
|
233
|
+
function originOk(req) {
|
|
234
|
+
const origin = req.get('origin');
|
|
235
|
+
// Absent is allowed. Literal "null" is not: that is an opaque origin, which
|
|
236
|
+
// is what a sandboxed document has, and nothing in the forge's own interface
|
|
237
|
+
// sends it.
|
|
238
|
+
if (!origin)
|
|
239
|
+
return true;
|
|
240
|
+
if (origin === 'null')
|
|
241
|
+
return false;
|
|
242
|
+
let u;
|
|
243
|
+
try {
|
|
244
|
+
u = new URL(origin);
|
|
245
|
+
}
|
|
246
|
+
catch {
|
|
247
|
+
return false;
|
|
248
|
+
}
|
|
249
|
+
// The hostname only, and not the scheme or the port. req.hostname honours
|
|
250
|
+
// X-Forwarded-Host, so it is the name the browser used; req.protocol is
|
|
251
|
+
// http on a vault behind a TLS proxy that is not trusted, and comparing it
|
|
252
|
+
// would then refuse every legitimate form on a merely misconfigured vault.
|
|
253
|
+
// An attacker who can answer for our hostname over plain http is already
|
|
254
|
+
// between the browser and the server.
|
|
255
|
+
return u.hostname.toLowerCase() === req.hostname.toLowerCase();
|
|
256
|
+
}
|
|
257
|
+
/**
|
|
258
|
+
* The comparison behind checkCsrf, for a handler that has the value in hand
|
|
259
|
+
* rather than in req.body - a multipart form, which express does not parse.
|
|
260
|
+
*/
|
|
261
|
+
function csrfMatches(req, presented, viewer) {
|
|
262
|
+
if (!originOk(req))
|
|
263
|
+
return false;
|
|
264
|
+
const a = Buffer.from(presented);
|
|
265
|
+
const b = Buffer.from(viewer.csrf);
|
|
266
|
+
return a.length === b.length && crypto.timingSafeEqual(a, b);
|
|
267
|
+
}
|
package/dist/site.js
ADDED
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
14
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
15
|
+
}) : function(o, v) {
|
|
16
|
+
o["default"] = v;
|
|
17
|
+
});
|
|
18
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
19
|
+
var ownKeys = function(o) {
|
|
20
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
21
|
+
var ar = [];
|
|
22
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
23
|
+
return ar;
|
|
24
|
+
};
|
|
25
|
+
return ownKeys(o);
|
|
26
|
+
};
|
|
27
|
+
return function (mod) {
|
|
28
|
+
if (mod && mod.__esModule) return mod;
|
|
29
|
+
var result = {};
|
|
30
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
31
|
+
__setModuleDefault(result, mod);
|
|
32
|
+
return result;
|
|
33
|
+
};
|
|
34
|
+
})();
|
|
35
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
+
exports.setSiteHeaders = setSiteHeaders;
|
|
37
|
+
exports.serveSite = serveSite;
|
|
38
|
+
exports.siteHostUrl = siteHostUrl;
|
|
39
|
+
exports.registerSiteHost = registerSiteHost;
|
|
40
|
+
const fs = __importStar(require("fs"));
|
|
41
|
+
const path = __importStar(require("path"));
|
|
42
|
+
const config_1 = require("./config");
|
|
43
|
+
const ops_1 = require("./ops");
|
|
44
|
+
const redirects_1 = require("./redirects");
|
|
45
|
+
const render_1 = require("./render");
|
|
46
|
+
const scan_1 = require("./scan");
|
|
47
|
+
const siteshost_1 = require("./siteshost");
|
|
48
|
+
const web_1 = require("./web");
|
|
49
|
+
/**
|
|
50
|
+
* The headers a site response carries. Set immediately before the body is sent,
|
|
51
|
+
* on every path that sends one, the site's own 404.html included: a response
|
|
52
|
+
* that escapes this is a response running with the forge's authority.
|
|
53
|
+
*/
|
|
54
|
+
function setSiteHeaders(res, mode) {
|
|
55
|
+
// nosniff in both modes. A file whose declared type is wrong is not licence
|
|
56
|
+
// to guess something more dangerous.
|
|
57
|
+
res.setHeader('X-Content-Type-Options', 'nosniff');
|
|
58
|
+
if (mode === 'host')
|
|
59
|
+
return;
|
|
60
|
+
// Deliberately without allow-same-origin, which is what puts the document in
|
|
61
|
+
// an opaque origin: no cookie jar, no document.cookie, no localStorage or
|
|
62
|
+
// IndexedDB, no service worker registration, and every request it makes to
|
|
63
|
+
// the vault is cross-origin and credential-less. Script still runs, so the
|
|
64
|
+
// feature survives.
|
|
65
|
+
res.setHeader('Content-Security-Policy', 'sandbox allow-scripts allow-forms allow-popups allow-popups-to-escape-sandbox allow-downloads');
|
|
66
|
+
// Load-bearing, not redundant. A page in an opaque origin cannot fetch its
|
|
67
|
+
// own sibling files without it, which breaks any single-page app, wasm
|
|
68
|
+
// loader, or data-driven site. Those fetches still carry no credentials, so
|
|
69
|
+
// nothing is returned to an attacker. Site responses only: never on a forge
|
|
70
|
+
// page and never on an API route.
|
|
71
|
+
res.setHeader('Access-Control-Allow-Origin', '*');
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* How a site request refuses.
|
|
75
|
+
*
|
|
76
|
+
* On a site's own hostname the forge's error page is the wrong answer for the
|
|
77
|
+
* same reason it is wrong for the sites host's other responses: it renders forge
|
|
78
|
+
* chrome and links a stylesheet this origin does not serve, so it arrives
|
|
79
|
+
* unstyled and points the visitor at a vault the site knows nothing about. A
|
|
80
|
+
* repository with no site directory at all is the ordinary way to reach this,
|
|
81
|
+
* so it is worth answering plainly.
|
|
82
|
+
*/
|
|
83
|
+
function sendSite404(res, mode, message = 'Not found') {
|
|
84
|
+
if (mode !== 'host') {
|
|
85
|
+
(0, web_1.send404)(res, message);
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
setSiteHeaders(res, mode);
|
|
89
|
+
res.status(404).type('html').send(minimalPage(404, 'Not found', (0, render_1.esc)(message)));
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* Serve one path out of a repository's site directory.
|
|
93
|
+
*
|
|
94
|
+
* The site-relative path depends on the mode, because the two surfaces disagree
|
|
95
|
+
* about where the site starts: under /:collection/:repo/site/ it is the
|
|
96
|
+
* wildcard, and on a site's own hostname the repository is the origin root, so
|
|
97
|
+
* it is the whole of req.path.
|
|
98
|
+
*/
|
|
99
|
+
function serveSite(root, collection, repo, req, res, mode) {
|
|
100
|
+
const found = (0, scan_1.findRepo)(root, collection, repo);
|
|
101
|
+
if (!found) {
|
|
102
|
+
sendSite404(res, mode, `Repository ${collection}/${repo} not found`);
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
const dir = (0, scan_1.siteDir)(root, found.collection, found.name);
|
|
106
|
+
if (!dir) {
|
|
107
|
+
sendSite404(res, mode, `No site for ${found.collection}/${found.name}. Create a ${found.name}.site directory next to the repository, with an index.html at its root.`);
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
// The real path of the site directory, which every path we serve has to
|
|
111
|
+
// sit under. It is resolved once here so that a vault reached through a
|
|
112
|
+
// symlinked root still compares equal.
|
|
113
|
+
let dirReal;
|
|
114
|
+
try {
|
|
115
|
+
dirReal = fs.realpathSync(dir);
|
|
116
|
+
}
|
|
117
|
+
catch {
|
|
118
|
+
sendSite404(res, mode);
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
const relative = mode === 'host' ? req.path : (0, web_1.wildcard)(req);
|
|
122
|
+
const segs = relative.split('/').filter((s) => s !== '' && s !== '.');
|
|
123
|
+
if (segs.some((s) => s === '..' || s.includes('\0'))) {
|
|
124
|
+
sendSite404(res, mode);
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
// Site content is written by whatever published it, which may be a
|
|
128
|
+
// workflow unpacking a tar, so it can contain symlinks. Refusing `..`
|
|
129
|
+
// segments stops a traversal spelled in the URL but not a link on disk
|
|
130
|
+
// pointing at /etc/passwd, at the vault's own files, or into another
|
|
131
|
+
// repository's site, which has a different owner. Every path we are
|
|
132
|
+
// about to read is therefore resolved and required to land strictly
|
|
133
|
+
// under dirReal; containedIn refuses the directory itself as well as
|
|
134
|
+
// anything outside it, so only files below the site root are servable.
|
|
135
|
+
//
|
|
136
|
+
// Resolving and then opening is two syscalls, so a link swapped in
|
|
137
|
+
// between them would still be followed. Closing that window means
|
|
138
|
+
// holding an open handle and checking what we hold, which is more
|
|
139
|
+
// machinery than this is worth; the interesting case is content that
|
|
140
|
+
// was already on disk when the request arrived.
|
|
141
|
+
const statInside = (p) => {
|
|
142
|
+
if (!(0, ops_1.containedIn)(dirReal, p))
|
|
143
|
+
return null;
|
|
144
|
+
try {
|
|
145
|
+
return fs.statSync(p);
|
|
146
|
+
}
|
|
147
|
+
catch {
|
|
148
|
+
return null;
|
|
149
|
+
}
|
|
150
|
+
};
|
|
151
|
+
const requested = path.join(dir, ...segs);
|
|
152
|
+
// The site root is the directory itself, which statInside refuses; it is
|
|
153
|
+
// still a directory request, and resolves to the index.html below it.
|
|
154
|
+
const isDir = segs.length === 0 || statInside(requested)?.isDirectory() === true;
|
|
155
|
+
let target = requested;
|
|
156
|
+
if (isDir) {
|
|
157
|
+
if (!req.path.endsWith('/')) {
|
|
158
|
+
res.redirect(`${req.path}/`);
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
target = path.join(requested, 'index.html');
|
|
162
|
+
}
|
|
163
|
+
// A refused path answers exactly as a missing one does, so a prober
|
|
164
|
+
// learns nothing about what is really there from the difference.
|
|
165
|
+
const stat = statInside(target);
|
|
166
|
+
if (!stat || !stat.isFile()) {
|
|
167
|
+
const notFound = path.join(dir, '404.html');
|
|
168
|
+
const notFoundStat = statInside(notFound);
|
|
169
|
+
if (notFoundStat && notFoundStat.isFile()) {
|
|
170
|
+
setSiteHeaders(res, mode);
|
|
171
|
+
res.status(404).sendFile(notFound);
|
|
172
|
+
}
|
|
173
|
+
else {
|
|
174
|
+
sendSite404(res, mode, 'Page not found in this site');
|
|
175
|
+
}
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
setSiteHeaders(res, mode);
|
|
179
|
+
res.sendFile(target);
|
|
180
|
+
}
|
|
181
|
+
// ---- serving a site from its own hostname ----
|
|
182
|
+
/**
|
|
183
|
+
* A self-contained page, for the responses a sites hostname gives that are not
|
|
184
|
+
* site content. views.errorPage is wrong here: it renders forge chrome and links
|
|
185
|
+
* a stylesheet that is not reachable on this hostname, so it would arrive
|
|
186
|
+
* unstyled and mention a vault this origin knows nothing about.
|
|
187
|
+
*/
|
|
188
|
+
function minimalPage(status, title, body) {
|
|
189
|
+
return `<!doctype html>
|
|
190
|
+
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
|
|
191
|
+
<title>${status} ${title}</title>
|
|
192
|
+
<style>body{font:16px/1.5 system-ui,sans-serif;margin:4rem auto;max-width:34rem;padding:0 1rem}</style>
|
|
193
|
+
</head><body><h1>${status} ${title}</h1><p>${body}</p></body></html>
|
|
194
|
+
`;
|
|
195
|
+
}
|
|
196
|
+
/**
|
|
197
|
+
* The site's own URL for a repository, when it has an origin of its own, or null
|
|
198
|
+
* when it is served on the forge host. The scheme mirrors the request, so a
|
|
199
|
+
* local http vault redirects to http and stays usable.
|
|
200
|
+
*/
|
|
201
|
+
function siteHostUrl(root, req, collection, repo) {
|
|
202
|
+
const sitesHost = (0, config_1.loadConfig)(root).sites.host;
|
|
203
|
+
const host = (0, siteshost_1.siteHostFor)(sitesHost, collection, repo);
|
|
204
|
+
return host ? `${req.protocol}://${host}` : null;
|
|
205
|
+
}
|
|
206
|
+
/**
|
|
207
|
+
* The site handler for requests that arrive on a sites hostname. It must run
|
|
208
|
+
* before every other route, the /assets/* and /favicon.svg routes included, or
|
|
209
|
+
* the forge's stylesheet would shadow a site's own assets/style.css and the
|
|
210
|
+
* favicon route would shadow a site's icon.
|
|
211
|
+
*
|
|
212
|
+
* Host-based gating is safe in this direction. A request arriving with a forged
|
|
213
|
+
* Host reaches only the less privileged surface, and forging the forge's own
|
|
214
|
+
* hostname gains an attacker nothing they would not get by visiting it, because
|
|
215
|
+
* the session is resolved only there and only from a cookie the browser sends to
|
|
216
|
+
* the real host.
|
|
217
|
+
*/
|
|
218
|
+
function registerSiteHost(app, root) {
|
|
219
|
+
app.use((req, res, next) => {
|
|
220
|
+
const sitesHost = (0, config_1.loadConfig)(root).sites.host;
|
|
221
|
+
if (!sitesHost)
|
|
222
|
+
return next();
|
|
223
|
+
if (!(0, siteshost_1.isUnderSitesHost)(sitesHost, req.hostname))
|
|
224
|
+
return next();
|
|
225
|
+
// Sites are files. Nothing on this hostname writes anything, so nothing
|
|
226
|
+
// needs a method that would.
|
|
227
|
+
if (req.method !== 'GET' && req.method !== 'HEAD') {
|
|
228
|
+
res.status(405).set('Allow', 'GET, HEAD').type('html').send(minimalPage(405, 'Method not allowed', 'This hostname serves static files, so it answers only GET and HEAD.'));
|
|
229
|
+
return;
|
|
230
|
+
}
|
|
231
|
+
const named = (0, siteshost_1.parseSiteHost)(sitesHost, req.hostname);
|
|
232
|
+
if (!named) {
|
|
233
|
+
// A request to the bare sites host, or to a deeper name under it, gets a
|
|
234
|
+
// minimal 404 rather than falling through to the forge, so the forge is
|
|
235
|
+
// reachable only on its own hostname.
|
|
236
|
+
setSiteHeaders(res, 'sandbox');
|
|
237
|
+
res.status(404).type('html').send(minimalPage(404, 'No site here', 'This hostname does not name a repository site in this vault.'));
|
|
238
|
+
return;
|
|
239
|
+
}
|
|
240
|
+
// A site has a hostname of its own, built from the repository's name, so a
|
|
241
|
+
// rename moves the site to a different hostname and every published link
|
|
242
|
+
// to it stops resolving to anything. The redirect the forge host does for
|
|
243
|
+
// paths is done here for hostnames, on the same terms: only when no
|
|
244
|
+
// repository answers to the name in this one.
|
|
245
|
+
if (!(0, scan_1.findRepo)(root, named.collection, named.repo)) {
|
|
246
|
+
const moved = (0, redirects_1.resolveRepoRedirect)(root, named.collection, named.repo);
|
|
247
|
+
const host = moved && (0, siteshost_1.siteHostFor)(sitesHost, moved.collection, moved.repo);
|
|
248
|
+
if (host) {
|
|
249
|
+
res
|
|
250
|
+
.status(301)
|
|
251
|
+
.set('Cache-Control', 'no-store')
|
|
252
|
+
.set('Location', `${req.protocol}://${host}${req.originalUrl}`)
|
|
253
|
+
.end();
|
|
254
|
+
return;
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
serveSite(root, named.collection, named.repo, req, res, 'host');
|
|
258
|
+
});
|
|
259
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.isSiteLabelSafe = isSiteLabelSafe;
|
|
4
|
+
exports.normalizeHostname = normalizeHostname;
|
|
5
|
+
exports.siteHostFor = siteHostFor;
|
|
6
|
+
exports.parseSiteHost = parseSiteHost;
|
|
7
|
+
exports.isUnderSitesHost = isUnderSitesHost;
|
|
8
|
+
const scan_1 = require("./scan");
|
|
9
|
+
// The grammar of a per-site hostname, in both directions, so that the parser
|
|
10
|
+
// and the link builder cannot drift apart. A site served from
|
|
11
|
+
// <repo>--<collection>.<sitesHost> has an origin of its own, which is what gives
|
|
12
|
+
// it back the cookies, storage, and service workers that the sandbox on the
|
|
13
|
+
// forge host takes away.
|
|
14
|
+
/**
|
|
15
|
+
* Whether a collection or repository name may appear in a site hostname. The
|
|
16
|
+
* regex is the whole definition: lowercase letters, digits, and single interior
|
|
17
|
+
* hyphens, which by construction rejects uppercase, '.', '_', a leading or
|
|
18
|
+
* trailing hyphen, a doubled hyphen anywhere, and the empty string.
|
|
19
|
+
*/
|
|
20
|
+
function isSiteLabelSafe(name) {
|
|
21
|
+
return /^[a-z0-9]+(-[a-z0-9]+)*$/.test(name);
|
|
22
|
+
}
|
|
23
|
+
/** The DNS limit on one label, which <repo>--<collection> has to fit inside. */
|
|
24
|
+
const MAX_LABEL = 63;
|
|
25
|
+
/**
|
|
26
|
+
* The one normalization a hostname gets, wherever one is read. Exported because
|
|
27
|
+
* the configured sites host and the request's own hostname are compared against
|
|
28
|
+
* each other: if the two were normalized differently, a trailing dot or a
|
|
29
|
+
* capital letter on one side would stop every site being found.
|
|
30
|
+
*/
|
|
31
|
+
function normalizeHostname(hostname) {
|
|
32
|
+
return hostname.trim().toLowerCase().replace(/\.$/, '');
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* The full hostname for a repository's site, or null if it is not eligible.
|
|
36
|
+
*
|
|
37
|
+
* Repository and collection names are more permissive than a hostname label
|
|
38
|
+
* (isValidName allows '.', '_', and uppercase), so not every repository is
|
|
39
|
+
* eligible, and an ineligible one keeps being served on the forge host. Refusing
|
|
40
|
+
* rather than mangling is deliberate: lowercasing `Webapp1` would collide with a
|
|
41
|
+
* `webapp1` beside it, since hostnames are case-insensitive and both names are
|
|
42
|
+
* legal on disk.
|
|
43
|
+
*/
|
|
44
|
+
function siteHostFor(sitesHost, collection, repo) {
|
|
45
|
+
if (!sitesHost)
|
|
46
|
+
return null;
|
|
47
|
+
const name = (0, scan_1.displayName)(repo);
|
|
48
|
+
if (!isSiteLabelSafe(name) || !isSiteLabelSafe(collection))
|
|
49
|
+
return null;
|
|
50
|
+
const label = `${name}--${collection}`;
|
|
51
|
+
if (label.length > MAX_LABEL)
|
|
52
|
+
return null;
|
|
53
|
+
return `${label}.${sitesHost}`;
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* The collection and repository a request's hostname names, or null.
|
|
57
|
+
*
|
|
58
|
+
* The double hyphen is an unambiguous separator precisely because neither half
|
|
59
|
+
* may contain one, so `a--b` in collection `c` cannot be confused with `a` in
|
|
60
|
+
* collection `b--c`. It also means no label can begin `xn--`, which a browser
|
|
61
|
+
* would read as punycode. No filesystem access happens here; the caller resolves
|
|
62
|
+
* the names with findRepo as usual.
|
|
63
|
+
*/
|
|
64
|
+
function parseSiteHost(sitesHost, hostname) {
|
|
65
|
+
if (!sitesHost)
|
|
66
|
+
return null;
|
|
67
|
+
const host = normalizeHostname(hostname);
|
|
68
|
+
const suffix = `.${sitesHost}`;
|
|
69
|
+
if (!host.endsWith(suffix))
|
|
70
|
+
return null;
|
|
71
|
+
const label = host.slice(0, -suffix.length);
|
|
72
|
+
// Exactly one remaining label. A deeper name such as a.b.<sitesHost> is
|
|
73
|
+
// refused, and is not covered by a single wildcard certificate anyway.
|
|
74
|
+
if (label === '' || label.includes('.'))
|
|
75
|
+
return null;
|
|
76
|
+
const parts = label.split('--');
|
|
77
|
+
if (parts.length !== 2)
|
|
78
|
+
return null;
|
|
79
|
+
const [repo, collection] = parts;
|
|
80
|
+
if (!isSiteLabelSafe(repo) || !isSiteLabelSafe(collection))
|
|
81
|
+
return null;
|
|
82
|
+
return { collection, repo };
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* Whether a hostname belongs to the sites host at all, including the bare name
|
|
86
|
+
* and names too deep to be a site. The forge must not answer on any of them, so
|
|
87
|
+
* this is what the middleware checks before falling through.
|
|
88
|
+
*/
|
|
89
|
+
function isUnderSitesHost(sitesHost, hostname) {
|
|
90
|
+
if (!sitesHost)
|
|
91
|
+
return false;
|
|
92
|
+
const host = normalizeHostname(hostname);
|
|
93
|
+
return host === sitesHost || host.endsWith(`.${sitesHost}`);
|
|
94
|
+
}
|