File size: 4,274 Bytes
6f91ce5
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
// The PDF proxy against a local HTTP server: node --test server/pdfProxy.test.ts
import assert from 'node:assert/strict'
import { mkdtempSync, rmSync } from 'node:fs'
import { createServer, type Server } from 'node:http'
import type { AddressInfo } from 'node:net'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { after, before, test } from 'node:test'
import { fetchPdf, PdfError, servePdf, setPdfDir } from './pdfProxy.ts'

const PDF = Buffer.from('%PDF-1.4\n% a tiny test file\n%%EOF\n')
const dir = mkdtempSync(join(tmpdir(), 'pdf-proxy-'))
setPdfDir(dir)

let http: Server
let base = ''
let hits = 0

before(async () => {
	http = createServer((req, res) => {
		hits++
		const path = req.url ?? '/'
		if (path === '/paper.pdf') return res.writeHead(200, { 'content-type': 'application/pdf' }).end(PDF)
		if (path === '/page') return res.writeHead(200, { 'content-type': 'text/html' }).end('<html>Sign in to read</html>')
		if (path === '/big.pdf') return res.writeHead(200).end(Buffer.concat([PDF, Buffer.alloc(2000)]))
		if (path === '/moved') return res.writeHead(302, { location: '/paper.pdf' }).end()
		if (path === '/to-secret') return res.writeHead(302, { location: '/secret' }).end()
		if (path === '/loop') return res.writeHead(302, { location: '/loop' }).end()
		res.writeHead(404).end()
	})
	await new Promise<void>((resolve) => http.listen(0, '127.0.0.1', resolve))
	base = `http://127.0.0.1:${(http.address() as AddressInfo).port}`
})

after(async () => {
	await new Promise((resolve) => http.close(resolve))
	rmSync(dir, { recursive: true, force: true })
})

// the local server is on a private address, so these tests swap the check for one that lets it through
const local = async () => {}
const refused = (pattern: RegExp) => (e: unknown) => e instanceof PdfError && e.refused && pattern.test(e.message)
const failed = (pattern: RegExp) => (e: unknown) => e instanceof PdfError && !e.refused && pattern.test(e.message)

test('fetches a PDF, following redirects', async () => {
	assert.deepEqual(await fetchPdf(`${base}/paper.pdf`, { check: local }), PDF)
	assert.deepEqual(await fetchPdf(`${base}/moved`, { check: local }), PDF)
})

test('refuses http, file: and private addresses, on the first hop and every redirect', async () => {
	await assert.rejects(fetchPdf('http://127.0.0.1/paper.pdf'), refused(/Only https/))
	await assert.rejects(fetchPdf('file:///etc/passwd'), refused(/Only https/))
	await assert.rejects(fetchPdf('not a url'), refused(/Not a URL/))
	const port = (http.address() as AddressInfo).port
	await assert.rejects(fetchPdf(`https://localhost:${port}/paper.pdf`), refused(/Blocked private address/))
	const noSecrets = async (url: URL) => {
		if (url.pathname === '/secret') throw new PdfError('Blocked /secret', true)
	}
	await assert.rejects(fetchPdf(`${base}/to-secret`, { check: noSecrets }), refused(/Blocked \/secret/))
})

test('refuses what isn’t a PDF, a redirect loop and a file over the cap', async () => {
	await assert.rejects(fetchPdf(`${base}/page`, { check: local }), failed(/didn't send a PDF/))
	await assert.rejects(fetchPdf(`${base}/loop`, { check: local }), failed(/More than 3 redirects/))
	await assert.rejects(fetchPdf(`${base}/big.pdf`, { check: local, maxBytes: 1000 }), failed(/larger than 1000 bytes/))
	await assert.rejects(fetchPdf(`${base}/gone.pdf`, { check: local }), failed(/answered 404/))
})

test('servePdf caches the file and sends it with a CSP that runs nothing', async () => {
	const proxy = createServer((_req, res) =>
		servePdf(res, 'doi:10.1/x', async () => `${base}/paper.pdf`, { check: local }).catch((e) => res.writeHead(502).end(String(e)))
	)
	await new Promise<void>((resolve) => proxy.listen(0, '127.0.0.1', resolve))
	const url = `http://127.0.0.1:${(proxy.address() as AddressInfo).port}/`
	const before = hits
	for (let i = 0; i < 2; i++) {
		const res = await fetch(url)
		assert.equal(res.status, 200)
		assert.equal(res.headers.get('content-type'), 'application/pdf')
		assert.equal(res.headers.get('content-security-policy'), "default-src 'none'")
		assert.deepEqual(Buffer.from(await res.arrayBuffer()), PDF)
	}
	assert.equal(hits - before, 1, 'the second request comes from the cache')
	await new Promise((resolve) => proxy.close(resolve))
})