diff --git a/src/extract.ts b/src/extract.ts index fe42f75..07c4472 100644 --- a/src/extract.ts +++ b/src/extract.ts @@ -9,6 +9,7 @@ import { unzipSync } from "fflate"; const ZIP_SIGNATURE = Buffer.from([0x50, 0x4b, 0x03, 0x04]); const MAX_TOTAL_BYTES = 200 * 1024 * 1024; +const MAX_ENTRIES = 2000; export function isZip(bytes: Buffer): boolean { return bytes.length >= 4 && bytes.subarray(0, 4).equals(ZIP_SIGNATURE); @@ -37,9 +38,16 @@ export function isSafeEntryPath(name: string): boolean { export function extractZip(bytes: Buffer, targetDir: string): string[] { // A szűrő a kibontás ELŐTT fut, a központi jegyzék `originalSize` mezőjéből: // egy apró, erősen tömörített zip így nem tudja kimeríteni a memóriát. + // A lemezre írás csak a teljes szűrés után történik, így ha valamelyik + // korlát hibát dob, semmi nem kerül a célmappába. let total = 0; + let entryCount = 0; const entries = unzipSync(new Uint8Array(bytes), { filter(info) { + entryCount += 1; + if (entryCount > MAX_ENTRIES) { + throw new Error("A zip bejegyzésszáma meghaladja a 2000-es korlátot."); + } if (info.name.endsWith("/")) { return false; } @@ -58,8 +66,8 @@ export function extractZip(bytes: Buffer, targetDir: string): string[] { for (const [name, data] of Object.entries(entries)) { const relative = name.replace(/\\/g, "/").split(posix.sep).join(sep); const target = join(targetDir, relative); - mkdirSync(dirname(target), { recursive: true }); - writeFileSync(target, data); + mkdirSync(dirname(target), { recursive: true, mode: 0o700 }); + writeFileSync(target, data, { mode: 0o600 }); written.push(relative); } diff --git a/src/store/index.ts b/src/store/index.ts index 5dd5d69..6a57eb8 100644 --- a/src/store/index.ts +++ b/src/store/index.ts @@ -40,6 +40,11 @@ export function knownIds(entries: readonly IndexEntry[]): Set { } export function appendIndex(outDir: string, entry: IndexEntry): void { - mkdirSync(outDir, { recursive: true }); - appendFileSync(join(outDir, INDEX_FILE), `${JSON.stringify(entry)}\n`, "utf8"); + mkdirSync(outDir, { recursive: true, mode: 0o700 }); + // A `mode` csak a fájl létrehozásakor érvényesül: a meglévő index.jsonl + // jogosultságait a későbbi hozzáfűzések nem bántják. + appendFileSync(join(outDir, INDEX_FILE), `${JSON.stringify(entry)}\n`, { + encoding: "utf8", + mode: 0o600 + }); } diff --git a/src/tarhely/inbox.ts b/src/tarhely/inbox.ts index a86c2ca..3fdbb91 100644 --- a/src/tarhely/inbox.ts +++ b/src/tarhely/inbox.ts @@ -152,8 +152,9 @@ async function downloadOne( const label = slugify(row.desc || row.type || "level").slice(0, 40); const dir = join(mailbox.slug, `${row.receivedOn || "ismeretlen-datum"}_${eid}_${label}`); const absolute = join(options.outDir, dir); - mkdirSync(absolute, { recursive: true }); - writeFileSync(join(absolute, file.name), file.bytes); + // A hivatalos iratokat tartalmazó mappa csak a tulajdonos számára érhető el. + mkdirSync(absolute, { recursive: true, mode: 0o700 }); + writeFileSync(join(absolute, file.name), file.bytes, { mode: 0o600 }); const extracted = options.extract && isZip(file.bytes) ? extractZip(file.bytes, join(absolute, "kibontva")) : []; @@ -173,6 +174,9 @@ async function downloadOne( extracted: extracted.map((name) => join("kibontva", name)), downloadedAt: now().toISOString() }; - writeFileSync(join(absolute, "meta.json"), `${JSON.stringify(entry, null, 2)}\n`, "utf8"); + writeFileSync(join(absolute, "meta.json"), `${JSON.stringify(entry, null, 2)}\n`, { + encoding: "utf8", + mode: 0o600 + }); return entry; } diff --git a/test/browser.test.ts b/test/browser.test.ts index 5e8343d..bb31f86 100644 --- a/test/browser.test.ts +++ b/test/browser.test.ts @@ -50,6 +50,40 @@ describe("isAllowedHost", () => { expect(isAllowedHost("not-tarhely.gov.hu")).toBe(false); }); + it("allows uppercase host because URL parsing normalizes it to lowercase", () => { + const host = new URL("https://KAU.GOV.HU/").hostname; + expect(host).toBe("kau.gov.hu"); + expect(isAllowedHost(host)).toBe(true); + }); + + it("rejects host with trailing dot", () => { + expect(isAllowedHost("kau.gov.hu.")).toBe(false); + }); + + it("rejects Cyrillic homoglyph host and its punycode form", () => { + // A második betű cirill á (U+0430), nem latin a. + const homoglyph = "kаu.gov.hu"; + expect(isAllowedHost(homoglyph)).toBe(false); + const punycode = new URL(`https://${homoglyph}/`).hostname; + expect(punycode).toBe("xn--ku-7kc.gov.hu"); + expect(isAllowedHost(punycode)).toBe(false); + }); + + it("rejects host behind userinfo in URL", () => { + const host = new URL("https://kau.gov.hu@evil.example/").hostname; + expect(host).toBe("evil.example"); + expect(isAllowedHost(host)).toBe(false); + }); + + it("rejects IPv4 address host", () => { + expect(isAllowedHost("192.0.2.1")).toBe(false); + }); + + it("rejects IPv6 address host", () => { + expect(isAllowedHost("[::1]")).toBe(false); + expect(isAllowedHost("::1")).toBe(false); + }); + it("supports custom allowlist patterns", () => { expect(isAllowedHost("api.pelda.hu", ["*.pelda.hu"])).toBe(true); }); diff --git a/test/extract.test.ts b/test/extract.test.ts index 1173db5..52d9af8 100644 --- a/test/extract.test.ts +++ b/test/extract.test.ts @@ -65,6 +65,14 @@ describe("isSafeEntryPath", () => { }); }); +function buildManyEntryZip(entryCount: number): Buffer { + const files: Record = {}; + for (let index = 0; index < entryCount; index++) { + files[`bejegyzes-${index}.txt`] = new Uint8Array(0); + } + return Buffer.from(zipSync(files)); +} + describe("extractZip", () => { it("extracts zip entries to target directory and returns relative paths", () => { const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "tarhely-test-")); @@ -109,4 +117,35 @@ describe("extractZip", () => { ); expect(() => extractZip(zipData, tmpDir)).toThrow("Gyanús útvonal a zipben"); }); + + it("throws when the zip contains more than 2000 entries and writes no files", () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "tarhely-test-")); + const zipData = buildManyEntryZip(2001); + expect(() => extractZip(zipData, tmpDir)).toThrow( + "A zip bejegyzésszáma meghaladja a 2000-es korlátot." + ); + expect(fs.readdirSync(tmpDir)).toEqual([]); + }); + + it("extracts a zip with exactly 2000 entries", () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "tarhely-test-")); + const zipData = buildManyEntryZip(2000); + const written = extractZip(zipData, tmpDir); + expect(written).toHaveLength(2000); + }); + + it.skipIf(process.platform === "win32")( + "creates directories with 0o700 mode and extracted files with 0o600 mode", + () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "tarhely-test-")); + const zipData = Buffer.from( + zipSync({ + "almappa/irat.pdf": Buffer.from("tartalom") + }) + ); + extractZip(zipData, tmpDir); + expect(fs.statSync(path.join(tmpDir, "almappa")).mode & 0o777).toBe(0o700); + expect(fs.statSync(path.join(tmpDir, "almappa", "irat.pdf")).mode & 0o777).toBe(0o600); + } + ); }); diff --git a/test/inbox.test.ts b/test/inbox.test.ts index 1c28e07..bf49692 100644 --- a/test/inbox.test.ts +++ b/test/inbox.test.ts @@ -300,6 +300,28 @@ describe("downloadNewLetters", () => { expect(readIndex(outDir)).toHaveLength(2); }); + // A jogosultsági bitek Windowson nincsenek értelmezve, ott a teszt nem fut. + // A 022 umask nem maszkolja a 0o700/0o600 módokat, ezért pontos egyenlőség várható. + it.skipIf(process.platform === "win32")( + "creates the letter directory with 0o700 and the letter file and meta.json with 0o600", + async () => { + const outDir = tempOutDir(); + const harness = createHarness( + [rawRow({ eid: EID_0101, receivedAtMs: Date.UTC(2026, 0, 1, 10) })], + { [EID_0101]: { fajlNev: "level-1.pdf" } }, + { [EID_0101]: Buffer.from("pdf-tartalom-1") } + ); + + const result = await runDownload(harness, outDir); + expect(result.downloaded).toBe(1); + + const dir = letterDir(outDir, EID_0101, "2026-01-01"); + expect(fs.statSync(dir).mode & 0o777).toBe(0o700); + expect(fs.statSync(path.join(dir, "level-1.pdf")).mode & 0o777).toBe(0o600); + expect(fs.statSync(path.join(dir, "meta.json")).mode & 0o777).toBe(0o600); + } + ); + it("keeps downloading when one letter fails, reports it, keeps it out of index.jsonl and retries it on the next run", async () => { const outDir = tempOutDir(); const harness = createHarness( diff --git a/test/index-store.test.ts b/test/index-store.test.ts index 4e5d089..924133b 100644 --- a/test/index-store.test.ts +++ b/test/index-store.test.ts @@ -72,6 +72,18 @@ describe("appendIndex", () => { expect(readIndex(tmpDir)).toEqual([sampleEntry1]); }); + // A jogosultsági bitek Windowson nincsenek értelmezve, ott a teszt nem fut. + // A 022 umask nem maszkolja a 0o700/0o600 módokat, ezért pontos egyenlőség várható. + it.skipIf(process.platform === "win32")( + "creates the outDir with 0o700 and index.jsonl with 0o600 permissions", + () => { + const tmpDir = path.join(fs.mkdtempSync(path.join(os.tmpdir(), "tarhely-idx-")), "submappa"); + appendIndex(tmpDir, sampleEntry1); + expect(fs.statSync(tmpDir).mode & 0o777).toBe(0o700); + expect(fs.statSync(path.join(tmpDir, INDEX_FILE)).mode & 0o777).toBe(0o600); + } + ); + it("appends multiple entries in order", () => { const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "tarhely-idx-")); appendIndex(tmpDir, sampleEntry1);