forked from akai/readest
495783d045
Server-side hardening for three reported web advisories: - OPDS proxy (/api/opds/proxy): add http(s) scheme allowlist, internal/loopback/ link-local host blocklist, and manual per-hop redirect re-validation so a public URL can't redirect into an internal address. Move isBlockedHost into the shared src/utils/network.ts as the canonical blocklist and reimplement isLanAddress to delegate to it (also tightens the /api/kosync LAN check); fetch-url.ts re-exports it. (GHSA-c7mm-g2j2-98cx, GHSA-5g3f-mq2c-j65v) - Storage upload (/api/storage/upload): validate the client-supplied fileName with a new isSafeObjectKeyName helper before building the object key, so a name can't escape the caller's own prefix. (GHSA-mfmj-2frf-vhgw) - Stripe (/api/stripe/check): bind the entitlement to the session owner — reject a Checkout Session whose metadata.userId differs from the authenticated caller. (GHSA-pv88-3727-j7v8) Unit tests added for each path; full suite + lint green. The Tauri-native advisory (GHSA-55vr-pvq5-6fmg) is handled in a separate change. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
47 lines
2.2 KiB
TypeScript
47 lines
2.2 KiB
TypeScript
import { describe, it, expect } from 'vitest';
|
|
import { isSafeObjectKeyName } from '@/utils/object';
|
|
|
|
// GHSA-mfmj-2frf-vhgw: the storage object key is built as `${user.id}/${fileName}`
|
|
// from a client-controlled `fileName`. The R2 signer interpolates it into
|
|
// `new Request(url)`, whose URL parser collapses `../` before signing — so a
|
|
// crafted name escapes the caller's `${user.id}/` prefix into another tenant's
|
|
// namespace. fileName legitimately contains '/' (Readest/Books/..., Replicas),
|
|
// so we reject traversal/absolute/backslash forms rather than separators.
|
|
describe('isSafeObjectKeyName', () => {
|
|
it('accepts the legitimate book / replica / cover key shapes', () => {
|
|
expect(isSafeObjectKeyName('Readest/Books/abc123.epub')).toBe(true);
|
|
expect(isSafeObjectKeyName('Readest/Replicas/dict/id-1/data.bin')).toBe(true);
|
|
expect(isSafeObjectKeyName('cover.png')).toBe(true);
|
|
expect(isSafeObjectKeyName('My Book (2024).epub')).toBe(true);
|
|
expect(isSafeObjectKeyName('A&B.epub')).toBe(true);
|
|
});
|
|
|
|
it('rejects parent-directory traversal segments', () => {
|
|
expect(isSafeObjectKeyName('../victim/Readest/Book/h/book.epub')).toBe(false);
|
|
expect(isSafeObjectKeyName('Readest/../../victim/book.epub')).toBe(false);
|
|
expect(isSafeObjectKeyName('..')).toBe(false);
|
|
expect(isSafeObjectKeyName('a/../b')).toBe(false);
|
|
});
|
|
|
|
it('rejects percent-encoded traversal', () => {
|
|
expect(isSafeObjectKeyName('%2e%2e/victim/book.epub')).toBe(false);
|
|
expect(isSafeObjectKeyName('a/%2e%2e/b')).toBe(false);
|
|
});
|
|
|
|
it('rejects absolute paths, backslashes, NUL and empty segments', () => {
|
|
expect(isSafeObjectKeyName('/etc/passwd')).toBe(false);
|
|
expect(isSafeObjectKeyName('a\\b')).toBe(false);
|
|
expect(isSafeObjectKeyName('a\0b')).toBe(false);
|
|
expect(isSafeObjectKeyName('a//b')).toBe(false);
|
|
expect(isSafeObjectKeyName('a/')).toBe(false);
|
|
});
|
|
|
|
it('rejects empty / non-string input', () => {
|
|
expect(isSafeObjectKeyName('')).toBe(false);
|
|
// @ts-expect-error runtime guard for untrusted req.body values
|
|
expect(isSafeObjectKeyName(undefined)).toBe(false);
|
|
// @ts-expect-error runtime guard for untrusted req.body values
|
|
expect(isSafeObjectKeyName(123)).toBe(false);
|
|
});
|
|
});
|