Codex source gate: urn:ump:iw2tbbe6mssyl5divymmwo42ael65sbfrciztvyowo3zhrbtircq Generated assets gate: urn:ump:hintflviuxfpeidth42ry5fi4lwqsjhkxipzspfmk7vrvfc2cnqq
196 lines
7.6 KiB
JavaScript
196 lines
7.6 KiB
JavaScript
'use strict';
|
|
|
|
/**
|
|
* DC-131/133 deploys Install tab tests.
|
|
*
|
|
* Pins the DOM-XSS escaping on the Gitea repo picker: remote repo fields
|
|
* (url, id, full_name, description) are UNTRUSTED — a hostile Gitea
|
|
* instance controls them — so they must be HTML-escaped before they reach
|
|
* innerHTML. We load status/js/deploys.js in a sandboxed VM with a minimal
|
|
* mocked DOM (same pattern as share-modal.test.js) and drive the exposed
|
|
* window.__dc133_buildRepoOptions() with hostile payloads.
|
|
*
|
|
* Also verifies the token input is type="password" (not echoed to screen)
|
|
* and that the modal carries no github.com-only assumptions.
|
|
*/
|
|
|
|
const fs = require('fs');
|
|
const path = require('path');
|
|
const vm = require('vm');
|
|
const test = require('node:test');
|
|
const assert = require('node:assert/strict');
|
|
|
|
function findTarget() {
|
|
// Prefer the panel (ui) file; the routes/deploys.js proxy file is a
|
|
// different module (CommonJS, jest-side) and must not match this scan.
|
|
const candidates = [
|
|
path.join(__dirname, '..', 'js', 'deploys.js'),
|
|
path.join(__dirname, 'deploys.js'),
|
|
path.join(__dirname, 'ui-deploys.js'),
|
|
];
|
|
for (const p of candidates) {
|
|
try { if (fs.statSync(p).isFile()) return p; } catch (_) { /* keep looking */ }
|
|
}
|
|
const dir = __dirname;
|
|
let entries = [];
|
|
try { entries = fs.readdirSync(dir); } catch (_) { return null; }
|
|
const match = entries.find(e => e.endsWith('_ui-deploys.js') || e.endsWith('-ui-deploys.js'));
|
|
return match ? path.join(dir, match) : null;
|
|
}
|
|
|
|
const SOURCE_PATH = findTarget();
|
|
if (!SOURCE_PATH) {
|
|
throw new Error('Cannot find ui-deploys.js (panel bundle source). Searched ' + __dirname + ' and ../js/.');
|
|
}
|
|
|
|
function buildFakeDom() {
|
|
const elements = new Map();
|
|
function makeEl(id) {
|
|
return {
|
|
id,
|
|
value: '',
|
|
textContent: '',
|
|
innerHTML: '',
|
|
style: {},
|
|
dataset: {},
|
|
classList: {
|
|
_set: new Set(),
|
|
add(c) { this._set.add(c); },
|
|
remove(c) { this._set.delete(c); },
|
|
toggle(c, on) { if (on) this._set.add(c); else this._set.delete(c); },
|
|
contains(c) { return this._set.has(c); },
|
|
},
|
|
disabled: false,
|
|
addEventListener() {},
|
|
appendChild() {},
|
|
querySelectorAll() { return []; },
|
|
selectedOptions: [],
|
|
setAttribute() {},
|
|
getAttribute() { return null; },
|
|
};
|
|
}
|
|
const knownIds = [
|
|
'deploys-modal', 'dep-gh-gitea', 'dep-gh-gitea-host', 'dep-gh-gitea-token',
|
|
'dep-gh-anonymous', 'dep-gh-url', 'dep-gh-service', 'dep-gh-args', 'dep-gh-btn',
|
|
'dep-output', 'dep-services', 'dep-journal', 'dep-journal-name',
|
|
'dep-journal-btn', 'dep-status-name', 'dep-status-btn',
|
|
'dep-repo-select', 'dep-close', 'dep-deploy-btn',
|
|
];
|
|
for (const id of knownIds) elements.set(id, makeEl(id));
|
|
return {
|
|
_elements: elements,
|
|
body: { insertAdjacentHTML() {}, appendChild() {} },
|
|
getElementById(id) { return elements.get(id) || null; },
|
|
createElement() { return makeEl('created'); },
|
|
addEventListener() {},
|
|
querySelectorAll() { return []; },
|
|
readyState: 'complete',
|
|
};
|
|
}
|
|
|
|
function buildSandbox() {
|
|
const dom = buildFakeDom();
|
|
const windowStub = {
|
|
escapeHtml: (s) => String(s == null ? '' : s)
|
|
.replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c])),
|
|
renderApps: undefined,
|
|
renderGrid: undefined,
|
|
APPS: [],
|
|
};
|
|
const sandbox = {
|
|
window: windowStub,
|
|
document: dom,
|
|
fetch: () => Promise.resolve({ status: 200, json: async () => ({ success: true, rows: [], repos: [], services: [] }) }),
|
|
URL,
|
|
location: { origin: 'https://status.sami' },
|
|
setTimeout,
|
|
clearTimeout,
|
|
navigator: {},
|
|
injectModal: () => {},
|
|
wireModal: () => {},
|
|
showNotification: () => {},
|
|
console,
|
|
};
|
|
vm.createContext(sandbox);
|
|
return { sandbox, dom, windowStub };
|
|
}
|
|
|
|
function loadModule() {
|
|
const { sandbox, dom, windowStub } = buildSandbox();
|
|
vm.runInContext(fs.readFileSync(SOURCE_PATH, 'utf8'), sandbox, { filename: SOURCE_PATH });
|
|
return { dom, windowStub };
|
|
}
|
|
|
|
test('deploys.js loads in sandbox and exposes the DC-133 option builder', () => {
|
|
const { windowStub } = loadModule();
|
|
assert.equal(typeof windowStub.__dc133_buildRepoOptions, 'function');
|
|
});
|
|
|
|
test('repo options escape hostile full_name/description/url/id payloads', () => {
|
|
const { windowStub } = loadModule();
|
|
const build = windowStub.__dc133_buildRepoOptions;
|
|
const hostile = [{
|
|
url: 'https://evil.example/"><script>alert(1)</script>/x',
|
|
id: 'x" onmouseover="alert(2)',
|
|
full_name: '<script>alert(3)</script>',
|
|
description: '"><img src=x onerror=alert(4)>',
|
|
}];
|
|
const html = build(hostile);
|
|
// Security property: the hostile payloads' raw attack vectors must not
|
|
// survive — tags cannot open, quotes cannot delimit attributes. (The
|
|
// output legitimately contains its own <option> elements; what must be
|
|
// absent is any raw form of the injected values.)
|
|
assert.equal(html.includes('<script'), false, 'raw <script from payload must not survive');
|
|
assert.equal(html.includes('<img'), false, 'raw <img from payload must not survive');
|
|
assert.equal(html.includes('"><'), false, 'quote-angle injection delimiter must not survive');
|
|
assert.equal(html.includes('onmouseover="'), false, 'raw quoted attribute from payload must not survive');
|
|
assert.ok(html.includes('<script>'), 'escaped script tag present as inert text');
|
|
assert.ok(html.includes('">'), 'escaped quote-angle present');
|
|
});
|
|
|
|
test('repo options builder is safe with an injected escFn too (no bypass)', () => {
|
|
const { windowStub } = loadModule();
|
|
const build = windowStub.__dc133_buildRepoOptions;
|
|
const html = build([{ url: 'u"><svg onload=alert(9)>', id: 'i', full_name: 'n', description: 'd' }],
|
|
(s) => String(s).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c])));
|
|
assert.equal(html.includes('<svg'), false, 'raw <svg from payload must not survive');
|
|
assert.equal(html.includes('"><'), false, 'quote-angle delimiter must not survive');
|
|
});
|
|
|
|
test('empty and null repos arrays produce just the placeholder option', () => {
|
|
const { windowStub } = loadModule();
|
|
const build = windowStub.__dc133_buildRepoOptions;
|
|
assert.ok(build([]).includes('choose a repo'));
|
|
assert.ok(build(null).includes('choose a repo'));
|
|
});
|
|
|
|
test('token input is type=password in the modal markup', () => {
|
|
const source = fs.readFileSync(SOURCE_PATH, 'utf8');
|
|
const m = source.match(/id="dep-gh-gitea-token"[^>]*>/);
|
|
assert.ok(m, 'token input exists');
|
|
assert.ok(/type="password"/.test(m[0]), 'token input must be type=password, got: ' + m[0]);
|
|
});
|
|
|
|
test('install modal has no github.com-only placeholders (any-host UX)', () => {
|
|
const source = fs.readFileSync(SOURCE_PATH, 'utf8');
|
|
assert.equal(source.includes('https://github.com/owner/repo'), false,
|
|
'placeholder must not suggest github-only URLs');
|
|
});
|
|
|
|
test('request-token helper preserves explicit anonymous versus omitted', () => {
|
|
const { windowStub } = loadModule();
|
|
const token = windowStub.__dc133_requestToken;
|
|
assert.equal(typeof token, 'function');
|
|
assert.equal(token(true, 'typed-secret'), '',
|
|
'anonymous checkbox wins and emits explicit empty string');
|
|
assert.equal(token(false, ' typed-secret '), 'typed-secret');
|
|
assert.equal(token(false, ''), undefined,
|
|
'blank field without anonymous checkbox omits token (fleet fallback allowed)');
|
|
assert.equal(token(false, ' '), undefined);
|
|
});
|
|
|
|
test('anonymous checkbox exists in modal markup', () => {
|
|
const source = fs.readFileSync(SOURCE_PATH, 'utf8');
|
|
assert.match(source, /id="dep-gh-anonymous"[^>]*type="checkbox"/);
|
|
});
|