Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 10 additions & 2 deletions src/extract.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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;
}
Expand All @@ -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);
}

Expand Down
9 changes: 7 additions & 2 deletions src/store/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,11 @@ export function knownIds(entries: readonly IndexEntry[]): Set<string> {
}

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
});
}
10 changes: 7 additions & 3 deletions src/tarhely/inbox.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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")) : [];
Expand All @@ -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;
}
34 changes: 34 additions & 0 deletions test/browser.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
Expand Down
39 changes: 39 additions & 0 deletions test/extract.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,14 @@ describe("isSafeEntryPath", () => {
});
});

function buildManyEntryZip(entryCount: number): Buffer {
const files: Record<string, Uint8Array> = {};
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-"));
Expand Down Expand Up @@ -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);
}
);
});
22 changes: 22 additions & 0 deletions test/inbox.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
12 changes: 12 additions & 0 deletions test/index-store.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Loading