fix(upload): review — CSP SVG, boundary 5 MiB, regression guard 502/413

Hasil review pasca-fix upload asset:

Keamanan:
- Mitigasi stored-XSS via SVG (route /assets/* publik, same-origin):
  CSP 'default-src none; sandbox' saat serve SVG — di backend
  (send_assets) DAN di proxy SvelteKit (proxy hanya meneruskan
  content-type/set-cookie, jadi CSP backend tak sampai ke browser).
- Deteksi 413 proxy kini ber-test status code + pesan.

Testing:
- Regression guard: error jaringan biasa tetap 502, bukan 413.
- 413 via status code saja (tanpa regex pesan) tetap 413.
- Boundary 5 MiB: file TEPAT 5 MiB lolos; 5 MiB+1 ditolak (cabang
  len(raw) — SVG dipakai karena dikecualikan validasi PIL).
- CSP: SVG dapat header, raster tidak.
- E2E script (verify_asset_upload.py) memakai file asli
  mr-edu/assets/ilustrasi_beban.jpeg + test 413 terintegrasi.

Reuseability:
- Komentar silang-referensi _MAX_ASSET_BYTES (sumber) dengan pesan 413
  proxy dan BODY_SIZE_LIMIT compose.
This commit is contained in:
a2nr 2026-10-03 08:03:28 +07:00
parent 8fd39d6e1a
commit bb8ca50650
7 changed files with 416 additions and 2 deletions

View File

@ -90,6 +90,114 @@ describe('hooks.server handle', () => {
}
});
it('should still return 502 for ordinary network errors (not 413)', async () => {
// Regression guard: error jaringan biasa (fetch failed) TIDAK Boleh
// diklasifikasikan 413 — harus tetap 502 "Backend tidak tersedia".
const arrayBufferMock = vi.fn().mockRejectedValue(new TypeError('fetch failed'));
const mockEvent: any = {
request: {
method: 'POST',
headers: new Headers({ 'content-type': 'application/json' }),
arrayBuffer: arrayBufferMock
},
url: new URL('https://sinau-c-dev.manakin-gentoo.ts.net/api/content/assets/upload')
};
const origFetch = global.fetch;
global.fetch = vi.fn() as any;
try {
const resolve = vi.fn();
const response = await handle({ event: mockEvent, resolve });
expect(response.status).toBe(502);
const body = await response.json();
expect(body.success).toBe(false);
expect(body.message).toBe('Backend tidak tersedia');
expect(global.fetch).not.toHaveBeenCalled();
} finally {
global.fetch = origFetch;
}
});
it('should return 413 when error has status 413 even without "exceeds limit" message', async () => {
// Deteksi via err.status === 413 harus bekerja tanpa regex pesan
// (pesan library bisa berubah; status code adalah kontrak).
const arrayBufferMock = vi.fn().mockRejectedValue(
Object.assign(new Error('request body too large'), { status: 413 })
);
const mockEvent: any = {
request: {
method: 'POST',
headers: new Headers({ 'content-type': 'application/json' }),
arrayBuffer: arrayBufferMock
},
url: new URL('https://sinau-c-dev.manakin-gentoo.ts.net/api/content/assets/upload')
};
const origFetch = global.fetch;
global.fetch = vi.fn() as any;
try {
const resolve = vi.fn();
const response = await handle({ event: mockEvent, resolve });
expect(response.status).toBe(413);
const body = await response.json();
expect(body.success).toBe(false);
expect(body.message).toMatch(/terlalu besar/i);
} finally {
global.fetch = origFetch;
}
});
it('should add restrictive CSP when proxying SVG assets (stored-XSS mitigasi)', async () => {
// SVG bisa memuat <script>. Saat SVG dibuka langsung di tab browser,
// script-nya jalan same-origin → bisa baca localStorage (token).
// Proxy HARUS memberi CSP ketat — backend juga mengirim header ini,
// tapi proxy sebelumnya hanya meneruskan content-type/set-cookie.
const mockEvent: any = {
request: { method: 'GET', headers: new Headers() },
url: new URL('https://sinau-c-dev.manakin-gentoo.ts.net/assets/malware.svg')
};
const origFetch = global.fetch;
global.fetch = vi.fn().mockResolvedValue(
new Response('<svg><script>alert(1)</script></svg>', {
status: 200,
headers: { 'content-type': 'image/svg+xml' }
})
) as any;
try {
const resolve = vi.fn();
const response = await handle({ event: mockEvent, resolve });
expect(response.status).toBe(200);
expect(response.headers.get('content-security-policy')).toBe("default-src 'none'; sandbox");
} finally {
global.fetch = origFetch;
}
});
it('should NOT add CSP for non-SVG assets', async () => {
const mockEvent: any = {
request: { method: 'GET', headers: new Headers() },
url: new URL('https://sinau-c-dev.manakin-gentoo.ts/assets/diagram.png')
};
const origFetch = global.fetch;
global.fetch = vi.fn().mockResolvedValue(
new Response('binary', { status: 200, headers: { 'content-type': 'image/png' } })
) as any;
try {
const resolve = vi.fn();
const response = await handle({ event: mockEvent, resolve });
expect(response.status).toBe(200);
expect(response.headers.get('content-security-policy')).toBeNull();
} finally {
global.fetch = origFetch;
}
});
it('should read body as arrayBuffer when proxying POST requests', async () => {
const arrayBufferMock = vi.fn().mockResolvedValue(new Uint8Array([1, 2, 3]).buffer);
const mockEvent: any = {

View File

@ -73,6 +73,16 @@ export const handle: Handle = async ({ event, resolve }) => {
const setCookie = res.headers.get('set-cookie');
if (setCookie) resHeaders['set-cookie'] = setCookie;
// Mitigasi stored-XSS: SVG asset bisa memuat <script>. Route
// /assets/* publik (same-origin) — kalau SVG dibuka langsung di
// tab browser, script-nya bisa membaca localStorage (token
// siswa/guru). Backend juga mengirim CSP ini (routes/lessons.py
// send_assets), tapi proxy hanya meneruskan content-type/
// set-cookie — jadi beri ulang di sini.
if (isAsset && event.url.pathname.toLowerCase().endsWith('.svg')) {
resHeaders['content-security-policy'] = "default-src 'none'; sandbox";
}
return new Response(body, {
status: res.status,
headers: resHeaders,
@ -89,6 +99,7 @@ export const handle: Handle = async ({ event, resolve }) => {
JSON.stringify({
success: false,
error: 'Payload too large',
// Nilai sumber: _MAX_ASSET_BYTES di routes/content_editor.py (backend).
message: 'Ukuran file terlalu besar (maksimum 5 MiB per asset)'
}),
{

View File

@ -85,6 +85,7 @@ services:
- API_BACKEND=http://127.0.0.1:5000
# Backend menerima asset ≤ 5 MiB (JSON base64 ≈ 7 MiB di wire).
# Default adapter-node 512K menolak upload gambar sebelum sampai backend.
# Nilai sumber: _MAX_ASSET_BYTES di routes/content_editor.py — sinkronkan.
- BODY_SIZE_LIMIT=8M
- PUBLIC_APP_BAR_TITLE=${APP_BAR_TITLE}
- PUBLIC_COPYRIGHT_TEXT=${COPYRIGHT_TEXT}

View File

@ -40,7 +40,11 @@ content_editor_bp = Blueprint("content_editor", __name__, url_prefix="/content")
_CONTENT_DIR_ABS = os.path.abspath(CONTENT_DIR)
_ASSETS_DIR_ABS = os.path.abspath(ASSETS_DIR)
_ALLOWED_IMAGE_EXT = {".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg"}
_MAX_ASSET_BYTES = 5 * 1024 * 1024
_MAX_ASSET_BYTES = 5 * 1024 * 1024 # 5 MiB — batas upload per asset.
# SINKRONISASI: frontend hook (frontend/src/hooks.server.ts) mengembalikan
# pesan 413 "maksimum 5 MiB per asset", dan podman-compose.yml menetapkan
# BODY_SIZE_LIMIT=8M (5 MiB × 4/3 base64 + envelope). Ubah nilai di sini →
# perbarui keduanya.
_PROTECTED_BASENAMES = {"home.md", "sub-home.md"}

View File

@ -294,4 +294,11 @@ def get_key_text(filename):
@lessons_bp.route('/assets/<path:path>')
def send_assets(path):
"""Serve asset files (images, etc.) from ASSETS_DIR."""
return send_from_directory(ASSETS_DIR, path)
response = send_from_directory(ASSETS_DIR, path)
# Mitigasi stored-XSS: SVG bisa memuat <script>. Route ini publik
# (same-origin) — kalau SVG dibuka langsung di tab browser, script-nya
# bisa membaca localStorage (token siswa/guru). CSP ketat mematikan
# semua eksekusi di dokumen tersebut; sandbox melarang navigasi/form.
if path.lower().endswith(".svg"):
response.headers["Content-Security-Policy"] = "default-src 'none'; sandbox"
return response

View File

@ -0,0 +1,219 @@
#!/usr/bin/env python3
"""E2E: upload asset asli (../mr-edu/assets/ilustrasi_beban.jpeg) via UI.
Verifikasi fix "Backend tidak tersedia" (502) saat upload:
1. Upload file yang ASLINYA gagal (710 KB, body JSON-base64 ~947 KB) via UI.
2. POST /api/content/assets/upload → 200, entri di tree, download bytes-cocok.
3. Jalur 413: body > 8M → 413 + pesan ukuran (bukan 502).
4. Cleanup probe (entri asli ilustrasi_beban.jpeg DIPERTAHANKAN).
Keamanan data user:
- Bila file dengan nama sama SUDAH ada di server:
• identik dengan source → delete via API lalu re-upload via UI (bukti fix,
nama persis sama; file identik jadi tak ada data yang hilang);
• berbeda → upload dengan nama probe (timestamp), entri asli tidak disentuh.
Run: /home/a2nr/lms-dev/venv/bin/python scripts/verify_asset_upload.py
"""
import base64
import json
import os
import random
import shutil
import sys
import time
import urllib.request
BASE = "https://sinau-c-dev.manakin-gentoo.ts.net"
TOKEN = "141214"
SRC = "/home/a2nr/mr-edu/assets/ilustrasi_beban.jpeg"
ORIGINAL_NAME = "ilustrasi_beban.jpeg"
from playwright.sync_api import sync_playwright
def login(page) -> None:
"""Login flow via POST /api/login (paling andal, tak kena race auth.init)."""
page.goto(BASE + "/login", wait_until="domcontentloaded", timeout=60000)
page.wait_for_timeout(2000)
st = page.evaluate("""(token) => fetch('/api/login', {
method: 'POST', headers: {'Content-Type': 'application/json'},
body: JSON.stringify({token})
}).then(x => x.status)""", TOKEN)
assert st == 200, f"login gagal (status {st}) — cek token di DB"
def wait_editor_ready(page) -> None:
"""Tunggu tree sidebar ter-render (tab Materi/Assets) — bukan networkidle
(sering tak tercapai: long-poll/keep-alive, container baru warm-up)."""
page.goto(BASE + "/teacher/content", wait_until="domcontentloaded", timeout=90000)
page.locator('button.tree-tab', has_text='Assets').first.wait_for(state="visible", timeout=45000)
def fetch_tree(page):
return page.evaluate("""() => fetch('/api/content/tree?root=assets',
{credentials:'include'}).then(x => x.json())""")
def find_asset(nodes: list, name: str):
"""Cari node file bernama `name` di list tree (rekursif masuk children)."""
for n in nodes:
is_file = (n.get("type") == "file") or (n.get("is_dir") is False)
if is_file and n.get("name") == name:
return n
if n.get("children"):
r = find_asset(n["children"], name)
if r:
return r
return None
def list_files(nodes: list, prefix: str = "") -> list[str]:
"""Semua path file di tree (rekursif)."""
out = []
for n in nodes:
p = f"{prefix}/{n['name']}" if prefix else n["name"]
if (n.get("type") == "file") or (n.get("is_dir") is False):
out.append(p)
if n.get("children"):
out.extend(list_files(n["children"], p))
return out
def delete_entry(page, path: str) -> None:
page.evaluate("""(a) => fetch(`/api/content/tree/entry?root=assets&path=${encodeURIComponent(a)}&force=true`,
{method:'DELETE', credentials:'include'})""", path)
def upload_via_ui(page, local_file: str) -> None:
"""Upload via UI: tab Assets → ⬆️ → file chooser → prompt folder ''.
Tombol ⬆️ hanya render saat tab Assets aktif ({#if} di +page.svelte)."""
page.locator('button.tree-tab', has_text='Assets').first.click()
page.locator('button[title="Upload gambar"]').first.wait_for(state="visible", timeout=15000)
# Dialog handler HARUS didaftarkan sebelum click (skill pitfall):
# prompt "Folder tujuan" → accept("") = root assets
# confirm "Salin path ke clipboard?" → dismiss
page.on("dialog", lambda d: d.accept("") if "Folder tujuan" in (d.message or "") else d.dismiss())
with page.expect_file_chooser(timeout=10000) as fc:
page.locator('button[title="Upload gambar"]').first.click()
fc.value.set_files(local_file)
page.wait_for_timeout(8000) # upload base64 ~950 KB + refresh tree
def test_413_path(page) -> None:
"""Body > 8M (file 8 MiB) harus 413 + pesan ukuran, bukan 502."""
b64 = base64.b64encode(random.Random(1).randbytes(8 * 1024 * 1024)).decode()
res = page.evaluate("""(b64) => fetch('/api/content/assets/upload', {
method:'POST', headers:{'Content-Type':'application/json'},
body: JSON.stringify({filename:'huge.png', folder:'', content_base64:b64})
}).then(async x => ({status: x.status, body: await x.json()}))""", b64)
assert res["status"] == 413, f"413 path: dapat {res['status']}: {res['body']}"
assert "terlalu besar" in res["body"].get("message", "").lower(), \
f"413 path: pesan salah: {res['body']}"
print(f"413 path: status={res['status']}, message='{res['body']['message']}' ✓")
def main() -> None:
if not os.path.isfile(SRC):
sys.exit(f"File sumber tidak ada: {SRC}")
with open(SRC, "rb") as f:
src_raw = f.read()
print(f"source: {SRC} ({len(src_raw)} bytes, body ~{len(src_raw) * 4 // 3 // 1024} KB)")
errors: list[str] = []
api_events: list[tuple[str, int, str]] = []
upload_name = ORIGINAL_NAME
local_file = SRC
deleted_existing = False
with sync_playwright() as p:
browser = p.chromium.launch(headless=True)
ctx = browser.new_context(
ignore_https_errors=True,
service_workers="block",
viewport={"width": 1280, "height": 800},
)
ctx.add_cookies([
{"name": "student_token", "value": TOKEN,
"domain": "sinau-c-dev.manakin-gentoo.ts.net", "path": "/",
"http_only": True, "same_site": "Lax"}
])
ctx.add_init_script(
"localStorage.setItem('student_token','141214');"
"localStorage.setItem('student_last_active','" + str(int(time.time() * 1000)) + "');"
)
page = ctx.new_page()
page.on("pageerror", lambda e: errors.append(f"pageerror: {e}"))
page.on("response", lambda r: api_events.append(
(r.request.method, r.status, r.url)) if "/api/" in r.url else None)
login(page)
wait_editor_ready(page)
# ── 0) Cek state awal: entri dengan nama sama sudah ada? ──
existing = find_asset(fetch_tree(page).get("tree", []), ORIGINAL_NAME)
if existing:
req = urllib.request.Request(f"{BASE}/assets/{existing['path']}")
with urllib.request.urlopen(req) as resp:
existing_raw = resp.read()
if existing_raw == src_raw:
# Identik → delete + re-upload (bukti fix, nama persis sama)
print(f"entri sama (identik) sudah ada di server — delete + re-upload {ORIGINAL_NAME}")
delete_entry(page, existing["path"])
page.wait_for_timeout(1000)
deleted_existing = True
else:
# Berbeda → jangan timpa; pakai nama probe
upload_name = f"beban_probe_{int(time.time())}.jpeg"
local_file = f"/tmp/{upload_name}"
shutil.copy(SRC, local_file)
print(f"entri berbeda sudah ada di server — upload sebagai {upload_name} (asli tak disentuh)")
# ── 1) Upload via UI ──
upload_via_ui(page, local_file)
upload_evt = [(m, s, u) for m, s, u in api_events if "assets/upload" in u]
print("upload events:", upload_evt)
assert upload_evt and upload_evt[-1][1] == 200, \
f"upload tidak sukses: {upload_evt or 'tidak ada POST assets/upload'}"
# ── 2) Tree memuat entri; download bytes-cocok ──
node = find_asset(fetch_tree(page).get("tree", []), upload_name)
assert node, f"entri {upload_name} tidak ada di tree assets"
print(f"tree: entri {upload_name} ada ✓")
with urllib.request.urlopen(f"{BASE}/assets/{node['path']}") as resp:
data = resp.read()
assert resp.status == 200 and data == src_raw, \
f"download mismatch: {resp.status}, {len(data)} vs {len(src_raw)} bytes"
print(f"download /assets/{node['path']}: 200, {len(data)} bytes cocok ✓")
# ── 3) Jalur 413 (body > 8M) ──
test_413_path(page)
# ── 4) Cleanup: semua probe beban_probe_* (artefak kita), entri asli tetap ──
stale = [p_ for p_ in list_files(fetch_tree(page).get("tree", [])) if p_.startswith("beban_probe")]
for s in stale:
delete_entry(page, s)
page.wait_for_timeout(1000)
final_files = list_files(fetch_tree(page).get("tree", []))
left = [f for f in final_files if f.startswith("beban_probe")]
assert not left, f"cleanup gagal — probe tersisa: {left}"
print(f"cleanup {len(stale)} probe ✓; entri akhir: {len(final_files)} file")
if upload_name == ORIGINAL_NAME:
assert ORIGINAL_NAME in final_files, "entri asli hilang setelah cleanup!"
print(f"entri asli {ORIGINAL_NAME} tersimpan di server ✓")
errs = [e for e in errors if "favicon" not in e.lower()]
if errs:
print("PAGE ERRORS:")
for e in errs[:10]:
print(" ", e)
sys.exit(1)
print("pageerrors: bersih ✓")
browser.close()
print(f"\nSUCCESS: upload {upload_name} ({len(src_raw)} B, body ~{len(src_raw) * 4 // 3 // 1024} KB) "
f"lolos proxy tanpa 502; 413 path jelas; data user utuh.")
if __name__ == "__main__":
main()

View File

@ -670,3 +670,67 @@ def test_export_scope_content_excludes_assets(client):
names = zipfile.ZipFile(io.BytesIO(resp.data)).namelist()
assert any(n.startswith("content/") for n in names)
assert not any(n.startswith("assets/") for n in names)
# ── Boundary 5 MiB (cabang len(raw) — bukan pre-check len(b64)) ──────
# SVG dipakai karena dikecualikan dari validasi gambar PIL (Pillow tidak
# parse SVG), sehingga ukuran murni yang diuji, bukan validasi isi.
# Blok ini self-contained (helper sendiri) agar tidak bergantung helper
# lain di file ini.
import base64 as _b64_mod # noqa: E402 (import lokal — blok ini standalone)
_SVG_HDR = b'<svg xmlns="http://www.w3.org/2000/svg" width="1" height="1"/>'
def _svg_of(raw_size: int) -> bytes:
return _SVG_HDR + b" " * (raw_size - len(_SVG_HDR))
def _post_upload_plain(client, filename, raw):
"""POST upload tanpa flag overwrite (blok ini standalone — tidak
memakai helper _upload di atas agar commit-nya bisa berdiri sendiri)."""
return client.post("/content/assets/upload", json={
"filename": filename,
"folder": "",
"content_base64": _b64_mod.b64encode(raw).decode(),
})
def test_upload_exactly_5mib_accepted(client):
"""File TEPAT _MAX_ASSET_BYTES (5 MiB) harus lolos — bukan ditolak."""
_seed_and_setup()
_login_teacher(client)
resp = _post_upload_plain(client, "exact5mib.svg", _svg_of(5 * 1024 * 1024))
assert resp.status_code == 200, resp.get_json()
assert resp.get_json()["success"] is True
def test_upload_5mib_plus_1_rejected(client):
"""5 MiB + 1 byte harus ditolak cabang len(raw) dengan pesan ukuran."""
_seed_and_setup()
_login_teacher(client)
resp = _post_upload_plain(client, "over5mib.svg", _svg_of(5 * 1024 * 1024 + 1))
assert resp.status_code == 400
assert "mib" in resp.get_json()["message"].lower()
# ── CSP saat serve SVG (mitigasi stored-XSS via SVG ber-script) ──────
def test_serve_svg_has_restrictive_csp(client):
"""SVG diserve dengan CSP ketat — <script> di dalamnya tak bisa jalan
saat SVG dibuka langsung di tab browser (same-origin)."""
(TEST_ASSETS / "malware.svg").write_bytes(
b'<svg xmlns="http://www.w3.org/2000/svg"><script>fetch("/api/login")</script></svg>'
)
resp = client.get("/assets/malware.svg")
assert resp.status_code == 200
assert resp.headers.get("Content-Security-Policy") == "default-src 'none'; sandbox"
def test_serve_raster_image_no_csp(client):
"""Gambar raster (PNG) tidak diberi CSP — hanya SVG yang berisiko."""
(TEST_ASSETS / "normal.png").write_bytes(b"\x89PNG\r\n\x1a\n")
resp = client.get("/assets/normal.png")
assert resp.status_code == 200
assert "Content-Security-Policy" not in resp.headers