Harden security controls from REPORT findings

This commit is contained in:
2026-03-01 13:32:08 -03:00
parent da5cbc2c01
commit bdd97d1c62
20 changed files with 1455 additions and 97 deletions

View File

@@ -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>
)}

View File

@@ -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;
}
}

View File

@@ -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;
}