// @ts-check
/**
* Wave 2 — Security Hardening Regression Suite (v1.0.0)
* =====================================================
*
* Circuit-breaker tests untuk temuan MED dari audit mendalam. Tiap test
* mengkodekan proof-of-concept: GAGAL sebelum fix (membuktikan kerentanan
* nyata), LULUS sesudah fix. Bila regresi memasukkan kembali lubang, build
* langsung merah.
*
* Cakupan:
* S-4 — Injeksi atribut/event-handler (on*) & URL skema aktif via setAttribute
* S-5 — Role-tampering: guard peran client-side diperkuat + seam verifyPeran
* S-6 — Path traversal dev-server: startsWith() cacat → path.relative()
*/
import { describe, it, expect } from 'vitest';
import vm from 'node:vm';
import path from 'node:path';
import fs from 'node:fs';
import { JSDOM } from 'jsdom';
const PJS = require('../../src/engine/promptjs');
const RT = require('../../src/compiler/emitters/runtime');
/** Compile satu sumber .pjs via engine penuh. */
function compile(src) {
return PJS.compile(src);
}
/** Ambil hanya error fatal (severity 'error'). */
function errorsOf(result) {
return (result.errors || []).filter((e) => e.severity === 'error');
}
// ════════════════════════════════════════════════════════════════════════
// S-4 — Atribut: event-handler & URL skema aktif
// ════════════════════════════════════════════════════════════════════════
describe('S-4 — __safeAttr runtime memblokir atribut berbahaya', () => {
/** Bangun sandbox DOM + muat helper __safeAttr, kembalikan fungsi + element. */
function loadSafeAttr() {
const dom = new JSDOM('
');
const warns = [];
const sandbox = {
document: dom.window.document,
console: { warn: (m) => warns.push(String(m)), error: () => {} },
};
vm.createContext(sandbox);
vm.runInContext(RT.RUNTIME_HELPER_MAP.__safeAttr, sandbox);
const el = dom.window.document.createElement('a');
return { sandbox, el, warns, dom };
}
it('audit PoC: atribut event-handler onclick DITOLAK (tidak ter-set)', () => {
const { sandbox, el, warns } = loadSafeAttr();
const ok = vm.runInContext('__safeAttr', sandbox)(el, 'onclick', 'alert(1)');
expect(ok).toBe(false);
expect(el.hasAttribute('onclick')).toBe(false);
expect(warns.join(' ')).toMatch(/event-handler diblokir/i);
// Lapis 2 (kanal warning terstruktur): pesan memakai kode PJS-W1001 + saran.
expect(warns.join(' ')).toMatch(/PJS-W1001/);
expect(warns.join(' ')).toMatch(/saran:/i);
});
it('audit PoC: onerror / onmouseover (semua on*) DITOLAK', () => {
const { sandbox, el } = loadSafeAttr();
const f = vm.runInContext('__safeAttr', sandbox);
expect(f(el, 'onerror', 'x')).toBe(false);
expect(f(el, 'OnMouseOver', 'x')).toBe(false); // case-insensitive
expect(el.hasAttribute('onerror')).toBe(false);
expect(el.hasAttribute('onmouseover')).toBe(false);
});
it('audit PoC: href="javascript:..." DITOLAK', () => {
const { sandbox, el } = loadSafeAttr();
const ok = vm.runInContext('__safeAttr', sandbox)(el, 'href', 'javascript:alert(1)');
expect(ok).toBe(false);
expect(el.hasAttribute('href')).toBe(false);
});
it('audit PoC: src="data:text/html,..." DITOLAK', () => {
const { sandbox, el } = loadSafeAttr();
const ok = vm.runInContext('__safeAttr', sandbox)(
el,
'src',
'data:text/html,'
);
expect(ok).toBe(false);
expect(el.hasAttribute('src')).toBe(false);
});
it('bypass obfuscation: " jaVaScRiPt:" (whitespace + mixed case) DITOLAK', () => {
const { sandbox, el } = loadSafeAttr();
const ok = vm.runInContext('__safeAttr', sandbox)(el, 'href', ' jaVaScRiPt:alert(1)');
expect(ok).toBe(false);
});
it('nilai sah: href="/login" dan src="https://x/y.png" TER-SET normal', () => {
const { sandbox, el } = loadSafeAttr();
const f = vm.runInContext('__safeAttr', sandbox);
expect(f(el, 'href', '/login')).toBe(true);
expect(el.getAttribute('href')).toBe('/login');
expect(f(el, 'src', 'https://x/y.png')).toBe(true);
expect(f(el, 'data-id', '42')).toBe(true);
expect(el.getAttribute('data-id')).toBe('42');
});
});
// ═══════════════════════════════════════════════════════════════════════════
// S-7 — srcset: skema aktif harus dicek PER kandidat (audit LOW-2)
// S-8 — style: skema/ekspresi aktif difilter (audit LOW-3)
// ═══════════════════════════════════════════════════════════════════════════
describe('S-7/S-8 — __safeAttr: srcset per-kandidat & gating style', () => {
function loadSafeAttr(tag) {
const dom = new JSDOM('');
const warns = [];
const sandbox = {
document: dom.window.document,
console: { warn: (m) => warns.push(String(m)), error: () => {} },
};
vm.createContext(sandbox);
vm.runInContext(RT.RUNTIME_HELPER_MAP.__safeAttr, sandbox);
const el = dom.window.document.createElement(tag || 'img');
return { f: vm.runInContext('__safeAttr', sandbox), el, warns };
}
it('S-7: srcset bersih (multi-kandidat) TER-SET normal', () => {
const { f, el } = loadSafeAttr();
expect(f(el, 'srcset', 'a.png 1x, b.png 2x')).toBe(true);
expect(el.getAttribute('srcset')).toBe('a.png 1x, b.png 2x');
});
it('S-7 PoC: javascript: di kandidat KEDUA srcset DITOLAK (sebelumnya lolos)', () => {
const { f, el } = loadSafeAttr();
expect(f(el, 'srcset', 'a.png 1x, javascript:alert(1) 2x')).toBe(false);
expect(el.hasAttribute('srcset')).toBe(false);
});
it('S-7: javascript: di kandidat pertama srcset juga DITOLAK', () => {
const { f, el } = loadSafeAttr();
expect(f(el, 'srcset', 'javascript:alert(1) 1x')).toBe(false);
});
it('S-8: style aman (color/position) TER-SET normal', () => {
const { f, el } = loadSafeAttr('div');
expect(f(el, 'style', 'color:red;position:fixed')).toBe(true);
expect(el.getAttribute('style')).toContain('color:red');
});
it('S-8 PoC: style dengan expression() DITOLAK', () => {
const { f, el } = loadSafeAttr('div');
expect(f(el, 'style', 'width:expression(alert(1))')).toBe(false);
expect(el.hasAttribute('style')).toBe(false);
});
it('S-8 PoC: style dengan url(javascript:) DITOLAK', () => {
const { f, el } = loadSafeAttr('div');
expect(f(el, 'style', 'background:url(javascript:alert(1))')).toBe(false);
});
});
describe('S-4 — emitter merutekan setAttribute lewat __safeAttr', () => {
it('Buat gambar: src = → emit __safeAttr(...,"src",...)', () => {
const r = compile('Buat gambar:\n src = "https://ex.com/a.png"\n alt = "foto"');
expect(errorsOf(r)).toHaveLength(0);
expect(r.js).toMatch(/__safeAttr\(__el_\d+, "src",/);
// Tidak ada lagi direct property assignment mentah untuk src.
expect(r.js).not.toMatch(/__el_\d+\.src = /);
});
it('href via property tetap difilter (tidak direct-assign)', () => {
const r = compile('Buat a:\n href = "javascript:alert(1)"\n teks = "x"');
expect(errorsOf(r)).toHaveLength(0);
expect(r.js).toMatch(/__safeAttr\(__el_\d+, "href",/);
expect(r.js).not.toMatch(/__el_\d+\.href = /);
});
it('atribut tak-dikenal via Perbarui → __safeAttr fallback', () => {
const r = compile('Buat div#a:\n teks = "x"\nPerbarui #a atribut "data-role" = "admin"');
// Apa pun jalur yang dipakai, tidak boleh ada setAttribute mentah tak-terfilter
// untuk atribut tak-dikenal.
if (!errorsOf(r).length && r.js) {
const rawSet = /\.setAttribute\("data-role"/.test(r.js);
const safe = /__safeAttr\([^,]+, "data-role"/.test(r.js);
expect(safe || !rawSet).toBe(true);
}
});
it('runtime helper __safeAttr ada di output saat dipakai (tree-shaking)', () => {
const r = compile('Buat gambar:\n src = "https://ex.com/a.png"');
expect(r.js).toContain('function __safeAttr(');
});
});
// ════════════════════════════════════════════════════════════════════════
// S-5 — Role-tampering: guard peran client-side diperkuat
// ════════════════════════════════════════════════════════════════════════
describe('S-5 — Auth role guard hardening', () => {
const authSrc = (peran) =>
`butuhAuth: benar\ntoken: localStorage\nredirect: "/login"\nperan: "${peran}"\n---\nBuat ruang:\n "rahasia"`;
it('guard peran mendukung banyak peran (split koma + normalisasi)', () => {
const r = compile(authSrc('admin, editor'));
expect(errorsOf(r)).toHaveLength(0);
expect(r.js).toMatch(/split\(','\)/);
expect(r.js).toMatch(/toLowerCase\(\)/);
expect(r.js).toMatch(/indexOf\(__peranNorm\)/);
});
it('menyediakan seam window.__pjs_verifyPeran untuk verifikasi server-side', () => {
const r = compile(authSrc('admin'));
expect(r.js).toContain('window.__pjs_verifyPeran');
});
it('jujur: emit console.warn bahwa cek peran bersifat advisory/client-side', () => {
const r = compile(authSrc('admin'));
expect(r.js).toMatch(/client-side|advisory/i);
expect(r.js).toMatch(/console\.warn/);
});
it('eksekusi: __pjs_verifyPeran=false MENOLAK walau __peran cocok (anti-tamper seam)', () => {
const r = compile(authSrc('admin'));
expect(errorsOf(r)).toHaveLength(0);
const dom = new JSDOM('');
const redirects = [];
// Stub localStorage: penyerang memalsukan peran 'admin'.
const storage = { getItem: (k) => (k === '__peran' ? 'admin' : 'tok') };
// window TIRUAN dengan location.href yang bisa di-set (hindari batasan JSDOM).
const fakeWindow = {
__pjs_verifyPeran: () => false, // verifikasi server-side menolak
location: {
_href: '',
set href(v) {
redirects.push(v);
this._href = v;
},
get href() {
return this._href;
},
},
};
const sandbox = {
window: fakeWindow,
document: dom.window.document,
localStorage: storage,
console: { warn() {}, error() {} },
};
vm.createContext(sandbox);
// Jalankan seluruh output; guard auth berjalan lebih dulu (body aman).
expect(() => vm.runInContext(r.js, sandbox)).not.toThrow();
expect(redirects).toContain('/login'); // ditolak → redirect
});
it('eksekusi: tanpa seam, peran palsu cocok → console.warn advisory muncul', () => {
const r = compile(authSrc('admin'));
const dom = new JSDOM('');
const warns = [];
const storage = { getItem: (k) => (k === '__peran' ? 'admin' : 'tok') };
const fakeWindow = {
location: {
_href: '',
set href(v) {
this._href = v;
},
get href() {
return this._href;
},
},
};
const sandbox = {
window: fakeWindow,
document: dom.window.document,
localStorage: storage,
console: { warn: (m) => warns.push(String(m)), error() {} },
};
vm.createContext(sandbox);
expect(() => vm.runInContext(r.js, sandbox)).not.toThrow();
// Tanpa seam, cek client-side lolos TAPI memperingatkan dengan jujur.
expect(warns.join(' ')).toMatch(/client-side|advisory|server/i);
});
});
// ════════════════════════════════════════════════════════════════════════
// S-6 — Path traversal dev-server: path.relative, bukan startsWith
// ════════════════════════════════════════════════════════════════════════
describe('S-6 — Dev-server traversal guard', () => {
// v1.0.1: PoC kini memanggil util terpusat YANG SEBENARNYA dikirim
// (src/utils/path-guard.js), bukan replika lokal — sehingga bukti bahwa
// kelas serangan sibling-escape & encoded-traversal tertutup berlaku pada
// kode produksi, bukan salinan yang bisa menyimpang.
const { isInsideRoot } = require('../../src/utils/path-guard');
function isInside(rootDir, requestedRel) {
return isInsideRoot(rootDir, path.resolve(rootDir, requestedRel));
}
it('audit PoC: sibling-directory escape (/srv/app vs /srv/app-secret) DITOLAK', () => {
const root = '/srv/app';
// startsWith() lama: path.resolve('/srv/app','../app-secret/x') = '/srv/app-secret/x'
// → '/srv/app-secret/x'.startsWith('/srv/app') === true (LOLOS, BUG).
const resolved = path.resolve(root, '../app-secret/secret.txt');
expect(resolved.startsWith(root)).toBe(true); // membuktikan bug lama nyata
expect(isInside(root, '../app-secret/secret.txt')).toBe(false); // fix menolak
});
it('audit PoC: ../../etc/passwd DITOLAK', () => {
expect(isInside('/srv/app', '../../etc/passwd')).toBe(false);
});
it('file di dalam root TETAP diizinkan', () => {
expect(isInside('/srv/app', 'index.html')).toBe(true);
expect(isInside('/srv/app', 'sub/dir/page.pjs')).toBe(true);
expect(isInside('/srv/app', '')).toBe(true);
});
it('serve.js mendelegasikan guard ke util terpusat path-guard (S-15 sentralisasi)', () => {
// S-15/S-21 (v1.0.1): guard traversal dipindah ke util bersama
// `src/utils/path-guard.js` agar konsisten lintas adapter & CLI. serve.js
// kini WAJIB memanggil isInsideRoot, BUKAN menyalin logika inline lagi.
const serveSrc = fs.readFileSync(
path.resolve(__dirname, '../../src/cli/commands/serve.js'),
'utf8'
);
expect(serveSrc).toMatch(/require\(['"]\.\.\/\.\.\/utils\/path-guard['"]\)/);
expect(serveSrc).toMatch(/isInsideRoot\(rootDir, resolved\)/);
// Guard lama yang cacat tidak boleh muncul kembali.
expect(serveSrc).not.toMatch(/if \(!resolved\.startsWith\(rootDir\)\)/);
});
it('util terpusat path-guard memakai path.relative (bukan startsWith yang cacat)', () => {
const guardSrc = fs.readFileSync(
path.resolve(__dirname, '../../src/utils/path-guard.js'),
'utf8'
);
expect(guardSrc).toMatch(/path\.relative\(/);
expect(guardSrc).not.toMatch(/\.startsWith\(resolvedRoot\)/);
});
it('source serve.js men-decode percent-encoding (anti %2e%2e traversal)', () => {
const serveSrc = fs.readFileSync(
path.resolve(__dirname, '../../src/cli/commands/serve.js'),
'utf8'
);
expect(serveSrc).toMatch(/decodeURIComponent\(urlPath\)/);
});
});