Harden security controls from REPORT findings
This commit is contained in:
@@ -19,6 +19,47 @@ import type { DmsDocument, DmsDocumentDetail } from '../types';
|
||||
import PathInput from './PathInput';
|
||||
import TagInput from './TagInput';
|
||||
|
||||
const SAFE_IMAGE_PREVIEW_MIME_TYPES = new Set<string>([
|
||||
'image/bmp',
|
||||
'image/gif',
|
||||
'image/jpeg',
|
||||
'image/jpg',
|
||||
'image/png',
|
||||
'image/webp',
|
||||
]);
|
||||
|
||||
const SAFE_IFRAME_PREVIEW_MIME_TYPES = new Set<string>([
|
||||
'application/json',
|
||||
'application/pdf',
|
||||
'text/csv',
|
||||
'text/markdown',
|
||||
'text/plain',
|
||||
]);
|
||||
|
||||
/**
|
||||
* Normalizes MIME values by stripping parameters and lowercasing for stable comparison.
|
||||
*/
|
||||
function normalizeMimeType(mimeType: string | null | undefined): string {
|
||||
if (!mimeType) {
|
||||
return '';
|
||||
}
|
||||
return mimeType.split(';')[0]?.trim().toLowerCase() ?? '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves whether a MIME type is safe to render as an image preview.
|
||||
*/
|
||||
function isSafeImagePreviewMimeType(mimeType: string): boolean {
|
||||
return SAFE_IMAGE_PREVIEW_MIME_TYPES.has(mimeType);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves whether a MIME type is safe to render inside a sandboxed iframe preview.
|
||||
*/
|
||||
function isSafeIframePreviewMimeType(mimeType: string): boolean {
|
||||
return SAFE_IFRAME_PREVIEW_MIME_TYPES.has(mimeType);
|
||||
}
|
||||
|
||||
/**
|
||||
* Defines props for the selected document viewer panel.
|
||||
*/
|
||||
@@ -60,6 +101,30 @@ export default function DocumentViewer({
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const previewObjectUrlRef = useRef<string | null>(null);
|
||||
|
||||
/**
|
||||
* Resolves normalized MIME type used by preview safety checks.
|
||||
*/
|
||||
const previewMimeType = useMemo(() => normalizeMimeType(document?.mime_type), [document?.mime_type]);
|
||||
|
||||
/**
|
||||
* Resolves whether selected document should render as a safe image element in preview.
|
||||
*/
|
||||
const isImageDocument = useMemo(() => {
|
||||
return isSafeImagePreviewMimeType(previewMimeType);
|
||||
}, [previewMimeType]);
|
||||
|
||||
/**
|
||||
* Resolves whether selected document should render in sandboxed iframe preview.
|
||||
*/
|
||||
const canRenderIframePreview = useMemo(() => {
|
||||
return isSafeIframePreviewMimeType(previewMimeType);
|
||||
}, [previewMimeType]);
|
||||
|
||||
/**
|
||||
* Resolves whether selected document supports any inline preview mode.
|
||||
*/
|
||||
const canRenderInlinePreview = isImageDocument || canRenderIframePreview;
|
||||
|
||||
/**
|
||||
* Syncs editable metadata fields whenever selection changes.
|
||||
*/
|
||||
@@ -100,6 +165,12 @@ export default function DocumentViewer({
|
||||
setIsLoadingPreview(false);
|
||||
return;
|
||||
}
|
||||
if (!canRenderInlinePreview) {
|
||||
revokePreviewObjectUrl();
|
||||
setPreviewObjectUrl(null);
|
||||
setIsLoadingPreview(false);
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
setIsLoadingPreview(true);
|
||||
@@ -131,7 +202,7 @@ export default function DocumentViewer({
|
||||
cancelled = true;
|
||||
revokePreviewObjectUrl();
|
||||
};
|
||||
}, [document?.id]);
|
||||
}, [document?.id, canRenderInlinePreview]);
|
||||
|
||||
/**
|
||||
* Refreshes editable metadata from list updates only while form is clean.
|
||||
@@ -183,16 +254,6 @@ export default function DocumentViewer({
|
||||
};
|
||||
}, [document?.id]);
|
||||
|
||||
/**
|
||||
* Resolves whether selected document should render as an image element in preview.
|
||||
*/
|
||||
const isImageDocument = useMemo(() => {
|
||||
if (!document) {
|
||||
return false;
|
||||
}
|
||||
return document.mime_type.startsWith('image/');
|
||||
}, [document]);
|
||||
|
||||
/**
|
||||
* Extracts provider/transcription errors from document metadata for user visibility.
|
||||
*/
|
||||
@@ -482,11 +543,22 @@ export default function DocumentViewer({
|
||||
{previewObjectUrl ? (
|
||||
isImageDocument ? (
|
||||
<img src={previewObjectUrl} alt={document.original_filename} />
|
||||
) : canRenderIframePreview ? (
|
||||
<iframe
|
||||
src={previewObjectUrl}
|
||||
title={document.original_filename}
|
||||
sandbox=""
|
||||
referrerPolicy="no-referrer"
|
||||
allow="clipboard-read 'none'; clipboard-write 'none'; geolocation 'none'; microphone 'none'; camera 'none'; payment 'none'; usb 'none'; fullscreen 'none'"
|
||||
loading="lazy"
|
||||
/>
|
||||
) : (
|
||||
<iframe src={previewObjectUrl} title={document.original_filename} />
|
||||
<p className="small">Preview blocked for this file type. Download to inspect safely.</p>
|
||||
)
|
||||
) : isLoadingPreview ? (
|
||||
<p className="small">Loading preview...</p>
|
||||
) : !canRenderInlinePreview ? (
|
||||
<p className="small">Preview blocked for this file type. Download to inspect safely.</p>
|
||||
) : (
|
||||
<p className="small">Preview unavailable for this document.</p>
|
||||
)}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,23 +19,123 @@ import type {
|
||||
const API_BASE = import.meta.env?.VITE_API_BASE ?? 'http://localhost:8000/api/v1';
|
||||
|
||||
/**
|
||||
* Optional bearer token used for authenticated backend routes.
|
||||
* Legacy environment token fallback used only when no runtime token source is available.
|
||||
*/
|
||||
const API_TOKEN = import.meta.env?.VITE_API_TOKEN?.trim();
|
||||
const LEGACY_API_TOKEN = normalizeBearerToken(import.meta.env?.VITE_API_TOKEN);
|
||||
|
||||
/**
|
||||
* Global property name used for runtime token injection.
|
||||
*/
|
||||
export const API_TOKEN_RUNTIME_GLOBAL_KEY = '__DCM_API_TOKEN__';
|
||||
|
||||
/**
|
||||
* Session storage key used for per-user runtime token persistence.
|
||||
*/
|
||||
export const API_TOKEN_RUNTIME_STORAGE_KEY = 'dcm.api_token';
|
||||
|
||||
/**
|
||||
* Resolves a bearer token dynamically at request time.
|
||||
*/
|
||||
export type ApiTokenResolver = () => string | null | undefined;
|
||||
|
||||
let runtimeTokenResolver: ApiTokenResolver | null = null;
|
||||
|
||||
type ApiRequestInit = Omit<RequestInit, 'headers'> & { headers?: HeadersInit };
|
||||
|
||||
/**
|
||||
* Merges request headers and appends bearer authorization when configured.
|
||||
* Normalizes candidate token values by trimming whitespace and filtering non-string values.
|
||||
*/
|
||||
function normalizeBearerToken(candidate: unknown): string | undefined {
|
||||
if (typeof candidate !== 'string') {
|
||||
return undefined;
|
||||
}
|
||||
const normalized = candidate.trim();
|
||||
return normalized ? normalized : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves runtime token from mutable global injection points when available.
|
||||
*/
|
||||
function resolveGlobalRuntimeToken(): string | undefined {
|
||||
const source = globalThis as typeof globalThis & Record<string, unknown>;
|
||||
return normalizeBearerToken(source[API_TOKEN_RUNTIME_GLOBAL_KEY]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves runtime token from session storage where per-user state can be isolated by browser session.
|
||||
*/
|
||||
function resolveSessionStorageToken(): string | undefined {
|
||||
if (typeof globalThis.sessionStorage === 'undefined') {
|
||||
return undefined;
|
||||
}
|
||||
try {
|
||||
return normalizeBearerToken(globalThis.sessionStorage.getItem(API_TOKEN_RUNTIME_STORAGE_KEY));
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves bearer token using runtime sources first, then legacy environment fallback for compatibility.
|
||||
*/
|
||||
function resolveApiToken(): string | undefined {
|
||||
const resolverToken = normalizeBearerToken(runtimeTokenResolver?.());
|
||||
if (resolverToken) {
|
||||
return resolverToken;
|
||||
}
|
||||
const globalRuntimeToken = resolveGlobalRuntimeToken();
|
||||
if (globalRuntimeToken) {
|
||||
return globalRuntimeToken;
|
||||
}
|
||||
const sessionStorageToken = resolveSessionStorageToken();
|
||||
if (sessionStorageToken) {
|
||||
return sessionStorageToken;
|
||||
}
|
||||
return LEGACY_API_TOKEN;
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers or clears a request-time bearer token resolver used by API helpers.
|
||||
*
|
||||
* @param resolver Function returning a token for each request, or `null` to remove custom resolution.
|
||||
*/
|
||||
export function setApiTokenResolver(resolver: ApiTokenResolver | null): void {
|
||||
runtimeTokenResolver = resolver;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stores or clears the per-user runtime API token in session storage.
|
||||
*
|
||||
* @param token Token value to persist for this browser session; clears persisted token when empty.
|
||||
*/
|
||||
export function setRuntimeApiToken(token: string | null | undefined): void {
|
||||
if (typeof globalThis.sessionStorage === 'undefined') {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const normalized = normalizeBearerToken(token);
|
||||
if (normalized) {
|
||||
globalThis.sessionStorage.setItem(API_TOKEN_RUNTIME_STORAGE_KEY, normalized);
|
||||
return;
|
||||
}
|
||||
globalThis.sessionStorage.removeItem(API_TOKEN_RUNTIME_STORAGE_KEY);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Merges request headers and appends bearer authorization when a token can be resolved.
|
||||
*/
|
||||
function buildRequestHeaders(headers?: HeadersInit): Headers | undefined {
|
||||
if (!API_TOKEN && !headers) {
|
||||
const apiToken = resolveApiToken();
|
||||
if (!apiToken && !headers) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const requestHeaders = new Headers(headers);
|
||||
if (API_TOKEN) {
|
||||
requestHeaders.set('Authorization', `Bearer ${API_TOKEN}`);
|
||||
if (apiToken) {
|
||||
requestHeaders.set('Authorization', `Bearer ${apiToken}`);
|
||||
}
|
||||
return requestHeaders;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user