Harden security controls from REPORT findings
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
// @ts-expect-error Node strip-types runtime requires explicit .ts extension in ESM imports.
|
||||
import { downloadDocumentContentMarkdown, downloadDocumentFile, getDocumentPreviewBlob, getDocumentThumbnailBlob } from './api.ts';
|
||||
import { API_TOKEN_RUNTIME_GLOBAL_KEY, downloadDocumentContentMarkdown, downloadDocumentFile, getDocumentPreviewBlob, getDocumentThumbnailBlob, setApiTokenResolver, setRuntimeApiToken, updateDocumentMetadata } from './api.ts';
|
||||
|
||||
/**
|
||||
* Throws when a test condition is false.
|
||||
@@ -24,16 +24,70 @@ async function assertRejects(action: () => Promise<unknown>, expectedMessage: st
|
||||
throw new Error(`Expected rejection containing "${expectedMessage}"`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts fetch inputs into a URL string for assertions.
|
||||
*/
|
||||
function toRequestUrl(input: RequestInfo | URL): string {
|
||||
if (typeof input === 'string') {
|
||||
return input;
|
||||
}
|
||||
if (input instanceof URL) {
|
||||
return input.toString();
|
||||
}
|
||||
return input.url;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a minimal session storage implementation for Node-based tests.
|
||||
*/
|
||||
function createMemorySessionStorage(): Storage {
|
||||
const values = new Map<string, string>();
|
||||
return {
|
||||
get length(): number {
|
||||
return values.size;
|
||||
},
|
||||
clear(): void {
|
||||
values.clear();
|
||||
},
|
||||
getItem(key: string): string | null {
|
||||
return values.has(key) ? values.get(key) ?? null : null;
|
||||
},
|
||||
key(index: number): string | null {
|
||||
return Array.from(values.keys())[index] ?? null;
|
||||
},
|
||||
removeItem(key: string): void {
|
||||
values.delete(key);
|
||||
},
|
||||
setItem(key: string, value: string): void {
|
||||
values.set(key, String(value));
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs API helper tests for authenticated media and download flows.
|
||||
*/
|
||||
async function runApiTests(): Promise<void> {
|
||||
const originalFetch = globalThis.fetch;
|
||||
const runtimeGlobalSource = globalThis as typeof globalThis & Record<string, unknown>;
|
||||
const originalRuntimeGlobalToken = runtimeGlobalSource[API_TOKEN_RUNTIME_GLOBAL_KEY];
|
||||
const sessionStorageDescriptor = Object.getOwnPropertyDescriptor(globalThis, 'sessionStorage');
|
||||
|
||||
try {
|
||||
Object.defineProperty(globalThis, 'sessionStorage', {
|
||||
configurable: true,
|
||||
writable: true,
|
||||
value: createMemorySessionStorage(),
|
||||
});
|
||||
setApiTokenResolver(null);
|
||||
setRuntimeApiToken(null);
|
||||
delete runtimeGlobalSource[API_TOKEN_RUNTIME_GLOBAL_KEY];
|
||||
|
||||
const requestUrls: string[] = [];
|
||||
globalThis.fetch = (async (input: RequestInfo | URL): Promise<Response> => {
|
||||
requestUrls.push(typeof input === 'string' ? input : input.toString());
|
||||
const requestAuthHeaders: Array<string | null> = [];
|
||||
globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit): Promise<Response> => {
|
||||
requestUrls.push(toRequestUrl(input));
|
||||
requestAuthHeaders.push(new Headers(init?.headers).get('Authorization'));
|
||||
return new Response('preview-bytes', { status: 200 });
|
||||
}) as typeof fetch;
|
||||
|
||||
@@ -50,6 +104,50 @@ async function runApiTests(): Promise<void> {
|
||||
requestUrls[1] === 'http://localhost:8000/api/v1/documents/doc-1/preview',
|
||||
`Unexpected preview URL ${requestUrls[1]}`,
|
||||
);
|
||||
assert(requestAuthHeaders[0] === null, `Expected no auth header for thumbnail request, got "${requestAuthHeaders[0]}"`);
|
||||
assert(requestAuthHeaders[1] === null, `Expected no auth header for preview request, got "${requestAuthHeaders[1]}"`);
|
||||
|
||||
setRuntimeApiToken('session-user-token');
|
||||
globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit): Promise<Response> => {
|
||||
const authHeader = new Headers(init?.headers).get('Authorization');
|
||||
assert(authHeader === 'Bearer session-user-token', `Expected session token auth header, got "${authHeader}"`);
|
||||
return new Response('preview-bytes', { status: 200 });
|
||||
}) as typeof fetch;
|
||||
await getDocumentPreviewBlob('doc-session-auth');
|
||||
|
||||
setRuntimeApiToken('session-user-token');
|
||||
runtimeGlobalSource[API_TOKEN_RUNTIME_GLOBAL_KEY] = 'runtime-global-token';
|
||||
globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit): Promise<Response> => {
|
||||
const authHeader = new Headers(init?.headers).get('Authorization');
|
||||
assert(authHeader === 'Bearer runtime-global-token', `Expected global runtime token auth header, got "${authHeader}"`);
|
||||
return new Response('preview-bytes', { status: 200 });
|
||||
}) as typeof fetch;
|
||||
await getDocumentPreviewBlob('doc-global-auth');
|
||||
|
||||
setApiTokenResolver(() => 'resolver-token');
|
||||
let mergedContentType: string | null = null;
|
||||
let mergedAuthorization: string | null = null;
|
||||
globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit): Promise<Response> => {
|
||||
const headers = new Headers(init?.headers);
|
||||
mergedContentType = headers.get('Content-Type');
|
||||
mergedAuthorization = headers.get('Authorization');
|
||||
return new Response('{}', { status: 200 });
|
||||
}) as typeof fetch;
|
||||
await updateDocumentMetadata('doc-headers', { original_filename: 'renamed.pdf' });
|
||||
assert(mergedContentType === 'application/json', `Expected JSON content type to be preserved, got "${mergedContentType}"`);
|
||||
assert(mergedAuthorization === 'Bearer resolver-token', `Expected resolver token auth header, got "${mergedAuthorization}"`);
|
||||
|
||||
setApiTokenResolver(() => ' ');
|
||||
globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit): Promise<Response> => {
|
||||
const authHeader = new Headers(init?.headers).get('Authorization');
|
||||
assert(authHeader === 'Bearer runtime-global-token', `Expected fallback runtime global token auth header, got "${authHeader}"`);
|
||||
return new Response('preview-bytes', { status: 200 });
|
||||
}) as typeof fetch;
|
||||
await getDocumentPreviewBlob('doc-resolver-fallback');
|
||||
|
||||
setApiTokenResolver(null);
|
||||
setRuntimeApiToken(null);
|
||||
delete runtimeGlobalSource[API_TOKEN_RUNTIME_GLOBAL_KEY];
|
||||
|
||||
globalThis.fetch = (async (): Promise<Response> => {
|
||||
return new Response('file-bytes', {
|
||||
@@ -78,6 +176,18 @@ async function runApiTests(): Promise<void> {
|
||||
|
||||
await assertRejects(async () => downloadDocumentContentMarkdown('doc-4'), 'Failed to download document markdown');
|
||||
} finally {
|
||||
setApiTokenResolver(null);
|
||||
setRuntimeApiToken(null);
|
||||
if (originalRuntimeGlobalToken === undefined) {
|
||||
delete runtimeGlobalSource[API_TOKEN_RUNTIME_GLOBAL_KEY];
|
||||
} else {
|
||||
runtimeGlobalSource[API_TOKEN_RUNTIME_GLOBAL_KEY] = originalRuntimeGlobalToken;
|
||||
}
|
||||
if (sessionStorageDescriptor) {
|
||||
Object.defineProperty(globalThis, 'sessionStorage', sessionStorageDescriptor);
|
||||
} else {
|
||||
delete (globalThis as { sessionStorage?: Storage }).sessionStorage;
|
||||
}
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user