From 8fd39d6e1afb8f3153130c8290b8ec90d98511f7 Mon Sep 17 00:00:00 2001 From: a2nr Date: Sat, 3 Oct 2026 05:16:41 +0700 Subject: [PATCH 1/3] =?UTF-8?q?fix(frontend):=20upload=20asset=20lolos=20p?= =?UTF-8?q?roxy=20=E2=80=94=20BODY=5FSIZE=5FLIMIT=208M=20+=20proxy=20413?= =?UTF-8?q?=20jelas?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Body upload (JSON base64, ~7 MiB untuk file 5 MiB) ditolak default adapter-node 512K sebelum sampai backend Flask, lalu error itu terlapor sebagai 502 'Backend tidak tersedia'. - podman-compose: BODY_SIZE_LIMIT=8M untuk elemes-frontend - hooks.server: SvelteKitError body-size (413) dipisah dari kegagalan koneksi — balas 413 'Ukuran file terlalu besar' bukan 502 - test: proxy balas 413 untuk body melebihi limit --- frontend/src/hooks.server.test.ts | 33 +++++++++++++++++++++++++++++++ frontend/src/hooks.server.ts | 23 +++++++++++++++++++-- podman-compose.yml | 3 +++ 3 files changed, 57 insertions(+), 2 deletions(-) diff --git a/frontend/src/hooks.server.test.ts b/frontend/src/hooks.server.test.ts index 9a81156..0892214 100644 --- a/frontend/src/hooks.server.test.ts +++ b/frontend/src/hooks.server.test.ts @@ -57,6 +57,39 @@ describe('hooks.server handle', () => { expect(response.headers.get('Cross-Origin-Resource-Policy')).toBe('cross-origin'); }); + it('should return 413 with size message when body exceeds adapter limit', async () => { + // SvelteKitError dari adapter-node saat body POST melebihi BODY_SIZE_LIMIT. + // Proxy harus balas 413 dengan pesan ukuran — bukan 502 "Backend tidak tersedia". + const arrayBufferMock = vi.fn().mockRejectedValue( + Object.assign(new Error('Content-length of 947488 exceeds limit of 524288 bytes.'), { 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); + // Tidak boleh dikirim ke backend sama sekali + expect(global.fetch).not.toHaveBeenCalled(); + } 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 = { diff --git a/frontend/src/hooks.server.ts b/frontend/src/hooks.server.ts index 2bb26ab..31ebdf4 100644 --- a/frontend/src/hooks.server.ts +++ b/frontend/src/hooks.server.ts @@ -77,9 +77,28 @@ export const handle: Handle = async ({ event, resolve }) => { status: res.status, headers: resHeaders, }); - } catch (err) { + } catch (err: any) { + // adapter-node melempar SvelteKitError saat body POST melebihi + // BODY_SIZE_LIMIT — bukan berarti backend mati. Laporkan sebagai 413 + // dengan pesan yang jelas, jangan 502 "Backend tidak tersedia". + const isBodyTooLarge = + err?.status === 413 || /exceeds limit/i.test(String(err?.message ?? '')); + if (isBodyTooLarge) { + console.warn(`[hooks] Body too large for ${event.request.method} ${event.url.pathname}`); + return new Response( + JSON.stringify({ + success: false, + error: 'Payload too large', + message: 'Ukuran file terlalu besar (maksimum 5 MiB per asset)' + }), + { + status: 413, + headers: { 'content-type': 'application/json' } + } + ); + } console.error(`API proxy error (${backendUrl}):`, err); - return new Response(JSON.stringify({ error: 'Backend unavailable' }), { + return new Response(JSON.stringify({ success: false, error: 'Backend unavailable', message: 'Backend tidak tersedia' }), { status: 502, headers: { 'content-type': 'application/json' }, }); diff --git a/podman-compose.yml b/podman-compose.yml index 7b4145f..50a5290 100644 --- a/podman-compose.yml +++ b/podman-compose.yml @@ -83,6 +83,9 @@ services: environment: - ORIGIN=${ORIGIN:-http://localhost:3000} - 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. + - BODY_SIZE_LIMIT=8M - PUBLIC_APP_BAR_TITLE=${APP_BAR_TITLE} - PUBLIC_COPYRIGHT_TEXT=${COPYRIGHT_TEXT} - PUBLIC_PAGE_TITLE_SUFFIX=${PAGE_TITLE_SUFFIX} From bb8ca5065051e8275ec1eb737e1ff9925d8a1e19 Mon Sep 17 00:00:00 2001 From: a2nr Date: Sat, 3 Oct 2026 08:03:28 +0700 Subject: [PATCH 2/3] =?UTF-8?q?fix(upload):=20review=20=E2=80=94=20CSP=20S?= =?UTF-8?q?VG,=20boundary=205=20MiB,=20regression=20guard=20502/413?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- frontend/src/hooks.server.test.ts | 108 +++++++++ frontend/src/hooks.server.ts | 11 + podman-compose.yml | 1 + routes/content_editor.py | 6 +- routes/lessons.py | 9 +- scripts/verify_asset_upload.py | 219 +++++++++++++++++++ services/tests/test_content_editor_routes.py | 64 ++++++ 7 files changed, 416 insertions(+), 2 deletions(-) create mode 100644 scripts/verify_asset_upload.py diff --git a/frontend/src/hooks.server.test.ts b/frontend/src/hooks.server.test.ts index 0892214..525e624 100644 --- a/frontend/src/hooks.server.test.ts +++ b/frontend/src/hooks.server.test.ts @@ -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 ', { + 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 = { diff --git a/frontend/src/hooks.server.ts b/frontend/src/hooks.server.ts index 31ebdf4..4c9eeca 100644 --- a/frontend/src/hooks.server.ts +++ b/frontend/src/hooks.server.ts @@ -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 ' + ) + 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 + From 3c2546c818d6fa0897f3818e6ff8fb4858bc11ef Mon Sep 17 00:00:00 2001 From: a2nr Date: Sat, 3 Oct 2026 21:47:05 +0700 Subject: [PATCH 3/3] fix(content-editor): diagnostik upload asset + kelengkapan bundle - upload_asset: deteksi format dari magic bytes saat PIL menolak, log exception asli - contentEditor.ts: import bundle stop di failure pertama (informasi utuh tetap tersedia); guard frontend - verify_import_export.py: probe PNG 1x1 valid (PNG palsu ditolak PIL) - test_content_editor_routes.py: coverage pesan error + AVIF - bundle: elemes_content_fixed.zip (electron_flow_sparkfun.png 404 nginx dibuang) --- .../src/lib/services/contentEditor.test.ts | 71 ++++++++++++++ frontend/src/lib/services/contentEditor.ts | 14 ++- routes/content_editor.py | 42 ++++++++- scripts/verify_import_export.py | 10 +- services/tests/test_content_editor_routes.py | 92 +++++++++++++++++++ 5 files changed, 221 insertions(+), 8 deletions(-) diff --git a/frontend/src/lib/services/contentEditor.test.ts b/frontend/src/lib/services/contentEditor.test.ts index 9a078e2..cbc6c16 100644 --- a/frontend/src/lib/services/contentEditor.test.ts +++ b/frontend/src/lib/services/contentEditor.test.ts @@ -255,4 +255,75 @@ describe('extractBundle', () => { const { extractBundle } = await import('./contentEditor'); await expect(extractBundle(new File([new Uint8Array([1, 2, 3])], 'b.zip'))).rejects.toThrow(); }); +}); + +describe('importBundle — overwrite asset & pesan fallback', () => { + it('mode overwrite mengirim overwrite:true untuk asset', async () => { + const { importBundle } = await import('./contentEditor'); + const bodies: any[] = []; + global.fetch = vi.fn().mockImplementation((url: string, init?: RequestInit) => { + if (url.includes('/api/content/tree/folder')) { + return Promise.resolve(new Response(JSON.stringify({ success: true }), { status: 200 })); + } + if (url.includes('/api/content/assets/upload')) { + bodies.push(JSON.parse(String(init?.body))); + return Promise.resolve(new Response(JSON.stringify({ success: true }), { status: 200 })); + } + return Promise.resolve(new Response(JSON.stringify({ success: true }), { status: 200 })); + }); + const result = await importBundle({ + contentFiles: [], + assetFiles: [{ path: 'ovw.png', bytes: new Uint8Array([1]) }], + mode: 'overwrite', + }); + expect(bodies[0].overwrite).toBe(true); + expect(result.success).toBe(true); + }); + + it('mode skip mengirim overwrite:false untuk asset', async () => { + const { importBundle } = await import('./contentEditor'); + const bodies: any[] = []; + global.fetch = vi.fn().mockImplementation((url: string, init?: RequestInit) => { + if (url.includes('/api/content/assets/upload')) { + bodies.push(JSON.parse(String(init?.body))); + return Promise.resolve(new Response(JSON.stringify({ success: true }), { status: 200 })); + } + return Promise.resolve(new Response(JSON.stringify({ success: true }), { status: 200 })); + }); + await importBundle({ + contentFiles: [], + assetFiles: [{ path: 's.png', bytes: new Uint8Array([1]) }], + mode: 'skip', + existingPaths: new Set(), + }); + expect(bodies[0].overwrite).toBe(false); + }); + + it('respons tanpa message → fallback "HTTP ", bukan undefined', async () => { + const { importBundle } = await import('./contentEditor'); + global.fetch = vi.fn().mockImplementation((url: string) => { + if (url.includes('/api/content/assets/upload')) { + return Promise.resolve(new Response(JSON.stringify({ error: 'Backend unavailable' }), { status: 502 })); + } + return Promise.resolve(new Response(JSON.stringify({ success: true }), { status: 200 })); + }); + const result = await importBundle({ + contentFiles: [], + assetFiles: [{ path: 'x.png', bytes: new Uint8Array([1]) }], + mode: 'overwrite', + }); + expect(result.success).toBe(false); + expect(result.message).toContain('502'); + expect(result.message).not.toContain('undefined'); + }); + + it('uploadAsset merespons JSON sukses apa adanya (res.ok true)', async () => { + const { uploadAsset } = await import('./contentEditor'); + global.fetch = vi.fn().mockResolvedValue( + new Response(JSON.stringify({ success: true, url: '/assets/x.png' }), { status: 200 }), + ); + const res = await uploadAsset(new File(['x'], 'x.png'), ''); + expect(res.success).toBe(true); + expect(res.url).toBe('/assets/x.png'); + }); }); \ No newline at end of file diff --git a/frontend/src/lib/services/contentEditor.ts b/frontend/src/lib/services/contentEditor.ts index ae064ca..59eb213 100644 --- a/frontend/src/lib/services/contentEditor.ts +++ b/frontend/src/lib/services/contentEditor.ts @@ -135,14 +135,21 @@ function fileToBase64(file: File): Promise { }); } -export async function uploadAsset(file: File, folder = '') { +export async function uploadAsset(file: File, folder = '', overwrite = false) { const content_base64 = await fileToBase64(file); const res = await fetch('/api/content/assets/upload', { method: 'POST', headers: { 'Content-Type': 'application/json' }, credentials: 'include', - body: JSON.stringify({ filename: file.name, folder, content_base64 }), + body: JSON.stringify({ filename: file.name, folder, content_base64, overwrite }), }); + if (!res.ok) { + // Respons error bisa berupa JSON {message, ...}, JSON lain (mis. 502 + // proxy {"error": ...}), atau HTML (Flask default 404/500). Pastikan + // selalu menghasilkan string — jangan pernah 'undefined'. + const data = await res.json().catch(() => null); + return { success: false, message: data?.message ?? `HTTP ${res.status}` }; + } return res.json(); } @@ -237,9 +244,10 @@ export async function importBundle(options: ImportOptions): Promise str: + """Deteksi format aktual dari magic bytes — dipakai untuk pesan error + diagnostik saat PIL menolak file (PIL tidak menyebut format aslinya; + kasus nyata: halaman 404 HTML tersamar sebagai .png di bundle zip).""" + if raw.startswith(b"\x89PNG"): + return "PNG" + if raw.startswith(b"\xff\xd8\xff"): + return "JPEG" + if raw.startswith((b"GIF87a", b"GIF89a")): + return "GIF" + if raw[:4] == b"RIFF" and raw[8:12] == b"WEBP": + return "WebP" + if raw[4:8] == b"ftyp" and raw[8:12] in (b"avif", b"avis"): + return "AVIF" + head = raw[:512].lstrip().lower() + if head.startswith((b" Path: if PROBE_ZIP.exists(): return PROBE_ZIP with zipfile.ZipFile(PROBE_ZIP, "w", zipfile.ZIP_DEFLATED) as zf: zf.writestr("content/imported/probe.md", "# Probe Import\n\nMateri test.\n") - zf.writestr("assets/imported/probe.png", b"\x89PNG\r\n\x1a\nfake-png") + zf.writestr("assets/imported/probe.png", PNG_1PX) return PROBE_ZIP diff --git a/services/tests/test_content_editor_routes.py b/services/tests/test_content_editor_routes.py index 1265d54..9aef33f 100644 --- a/services/tests/test_content_editor_routes.py +++ b/services/tests/test_content_editor_routes.py @@ -10,6 +10,7 @@ Mengunci kontrak: - regression: _render_markdown_string() == render_markdown_content() untuk file yang sama; """ +import base64 import io import os import pathlib @@ -670,6 +671,97 @@ 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) + + +# ── Upload asset: GIF diterima, overwrite eksplisit, kontrak pesan error ── + +# GIF89a 1x1 hitam — GIF valid minimal +GIF_1PX = base64.b64decode( + "R0lGODlhAQABAIAAAP///wAAACH5BAEAAAAALAAAAAABAAEAAAICRAEAOw==") + + +def _upload(client, filename, raw, folder="", overwrite=None): + payload = { + "filename": filename, + "folder": folder, + "content_base64": base64.b64encode(raw).decode(), + } + if overwrite is not None: + payload["overwrite"] = overwrite + return client.post("/content/assets/upload", json=payload) + + +def test_upload_gif_accepted(client): + _seed_and_setup() + _login_teacher(client) + resp = _upload(client, "anim.gif", GIF_1PX) + assert resp.status_code == 200, resp.get_json() + body = resp.get_json() + assert body["success"] is True + assert body["url"] == "/assets/anim.gif" + assert (TEST_ASSETS / "anim.gif").read_bytes() == GIF_1PX + + +def test_upload_existing_name_rejected_without_overwrite(client): + _seed_and_setup() + _login_teacher(client) + _upload(client, "dup.gif", GIF_1PX) + resp = _upload(client, "dup.gif", GIF_1PX) + assert resp.status_code == 409 + assert resp.get_json()["message"] # pesan tidak boleh kosong + + +def test_upload_existing_name_overwritten_with_flag(client): + _seed_and_setup() + _login_teacher(client) + _upload(client, "dup.gif", GIF_1PX) + resp = _upload(client, "dup.gif", GIF_1PX, overwrite=True) + assert resp.status_code == 200, resp.get_json() + assert resp.get_json()["success"] is True + + +def test_upload_corrupt_gif_rejected_with_message(client): + _seed_and_setup() + _login_teacher(client) + resp = _upload(client, "bad.gif", b"GIF89a\x00garbage") + assert resp.status_code == 400 + assert resp.get_json()["message"] # "File bukan gambar yang valid" + + +def test_upload_oversize_rejected_with_message(client): + _seed_and_setup() + _login_teacher(client) + big = GIF_1PX + b"\x00" * (5 * 1024 * 1024) + resp = _upload(client, "big.gif", big) + assert resp.status_code == 400 + assert "mib" in resp.get_json()["message"].lower() + + +def test_upload_invalid_image_message_includes_detected_format(client): + """Pesan penolakan PIL harus menyebut format terdeteksi — file 404 HTML + tersamar .png (bug nyata: electron_flow_sparkfun.png 153 byte) harus + terbaca penyebabnya langsung dari toast.""" + _seed_and_setup() + _login_teacher(client) + resp = _upload(client, "fake.png", b"\r\n404 Not Found\r\n") + assert resp.status_code == 400 + msg = resp.get_json()["message"] + assert "File bukan gambar yang valid" in msg + assert "HTML" in msg # format terdeteksi disebut eksplisit + + +def test_upload_avif_reports_avif(client): + """AVIF dinamai .png ditolak dengan format terdeteksi + hint konversi.""" + _seed_and_setup() + _login_teacher(client) + avif = b"\x00\x00\x00\x1cftypavif" + b"\x00" * 16 + resp = _upload(client, "photo.png", avif) + assert resp.status_code == 400 + msg = resp.get_json()["message"] + assert "AVIF" in msg + assert "PNG/JPEG" in msg # hint konversi ikut disertakan + + # ── 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.