From 5c2a7e885f65d86b106d54452669fb225f8445ba Mon Sep 17 00:00:00 2001 From: ArtemHoruzhenko Date: Mon, 14 Sep 2026 11:58:41 +0300 Subject: [PATCH] feat(rdi): add transparent proxy endpoint for RDI native API @rdi-ui/pipeline ships its own RDI SDK and calls the native RDI API directly from the browser. Route it through RedisInsight's own API instead of exposing RDI to the browser directly: this keeps instance credentials and self-signed-certificate handling server-side, and avoids CORS entirely since the browser only ever talks to RedisInsight. The proxy forwards method/path/query/body/headers to the RDI instance via the existing RdiClient, with the following hardening applied: - Strips hop-by-hop and auth-carrying headers (Authorization, cookies, CSRF token, window-id) in both directions so the proxy's own credentials and RedisInsight's session state never leak to or from RDI. - Rejects non-CRUD HTTP methods and any request path that resolves outside the configured RDI instance URL, including attempts to escape it via encoded dot-segments or smuggled query/fragment delimiters. - Does not follow upstream redirects server-side (which could let an attacker point our backend at an arbitrary host); instead rewrites in-scope 3xx Location headers to the externally reachable proxy URL so the browser's follow-up request stays within the proxy, and strips out-of-scope ones. - Forwards/returns bodies as raw bytes (arraybuffer) to avoid axios parsing and re-serializing non-JSON, binary, or already-encoded payloads. - Forces a sandboxed Content-Security-Policy and nosniff on proxied responses so a compromised RDI instance can't get arbitrary HTML/JS executed under RedisInsight's own origin. Co-Authored-By: Claude Sonnet 5 --- .../rdi/client/api/v1/api.rdi.client.spec.ts | 237 ++++++++++++++++++ .../rdi/client/api/v1/api.rdi.client.ts | 190 +++++++++++++- .../api/src/modules/rdi/client/rdi.client.ts | 11 + .../api/src/modules/rdi/models/index.ts | 1 + .../api/src/modules/rdi/models/rdi-proxy.ts | 33 +++ .../modules/rdi/rdi-proxy.controller.spec.ts | 140 +++++++++++ .../src/modules/rdi/rdi-proxy.controller.ts | 84 +++++++ .../src/modules/rdi/rdi-proxy.service.spec.ts | 143 +++++++++++ .../api/src/modules/rdi/rdi-proxy.service.ts | 125 +++++++++ .../api/src/modules/rdi/rdi.module.ts | 4 + 10 files changed, 967 insertions(+), 1 deletion(-) create mode 100644 redisinsight/api/src/modules/rdi/models/rdi-proxy.ts create mode 100644 redisinsight/api/src/modules/rdi/rdi-proxy.controller.spec.ts create mode 100644 redisinsight/api/src/modules/rdi/rdi-proxy.controller.ts create mode 100644 redisinsight/api/src/modules/rdi/rdi-proxy.service.spec.ts create mode 100644 redisinsight/api/src/modules/rdi/rdi-proxy.service.ts diff --git a/redisinsight/api/src/modules/rdi/client/api/v1/api.rdi.client.spec.ts b/redisinsight/api/src/modules/rdi/client/api/v1/api.rdi.client.spec.ts index 9b0b63ae6b..7b398df2e6 100644 --- a/redisinsight/api/src/modules/rdi/client/api/v1/api.rdi.client.spec.ts +++ b/redisinsight/api/src/modules/rdi/client/api/v1/api.rdi.client.spec.ts @@ -718,6 +718,243 @@ describe('ApiRdiClient', () => { }); }); + describe('proxyRequest', () => { + it('should forward the request and return the raw response', async () => { + mockedAxios.request.mockResolvedValueOnce({ + status: 201, + headers: { 'content-type': 'application/json' }, + data: { id: '1' }, + }); + + const result = await client.proxyRequest({ + method: 'POST', + path: 'api/v1/pipelines', + query: 'dryRun=true', + body: { name: 'my-pipeline' }, + headers: { 'content-type': 'application/json' }, + }); + + expect(mockedAxios.request).toHaveBeenCalledWith({ + method: 'POST', + url: 'api/v1/pipelines?dryRun=true', + data: { name: 'my-pipeline' }, + headers: { 'content-type': 'application/json' }, + validateStatus: null, + allowAbsoluteUrls: false, + maxRedirects: 0, + responseType: 'arraybuffer', + }); + expect(result).toEqual({ + status: 201, + headers: { 'content-type': 'application/json' }, + data: { id: '1' }, + }); + }); + + it('should return the raw 3xx status without following it server-side, rewriting Location through the proxy', async () => { + mockedAxios.request.mockResolvedValueOnce({ + status: 307, + headers: { location: 'http://localhost:4000/api/v1/pipelines/new' }, + data: null, + }); + + const result = await client.proxyRequest({ + method: 'GET', + path: 'api/v1/pipelines/old', + }); + + expect(result.status).toBe(307); + expect(result.headers.location).toBe( + '/api/rdi/rdiId/proxy/api/v1/pipelines/new', + ); + }); + + it('should strip a redirect Location that points to a different origin', async () => { + mockedAxios.request.mockResolvedValueOnce({ + status: 307, + headers: { location: 'https://elsewhere.example/other' }, + data: null, + }); + + const result = await client.proxyRequest({ + method: 'GET', + path: 'api/v1/pipelines/old', + }); + + expect(result.status).toBe(307); + expect(result.headers.location).toBeUndefined(); + }); + + it('should strip a redirect Location that escapes the rdi url subpath', async () => { + const scopedClient = new ApiRdiClient(mockRdiClientMetadata, { + ...mockRdi, + url: 'http://localhost:4000/rdi', + }); + mockedAxios.request.mockResolvedValueOnce({ + status: 307, + headers: { location: 'http://localhost:4000/admin' }, + data: null, + }); + + const result = await scopedClient.proxyRequest({ + method: 'GET', + path: 'api/v1/pipelines/old', + }); + + expect(result.status).toBe(307); + expect(result.headers.location).toBeUndefined(); + }); + + it('should rewrite a relative redirect Location that stays within the rdi url subpath through the proxy', async () => { + const scopedClient = new ApiRdiClient(mockRdiClientMetadata, { + ...mockRdi, + url: 'http://localhost:4000/rdi', + }); + mockedAxios.request.mockResolvedValueOnce({ + status: 307, + headers: { location: '/rdi/api/v1/pipelines/new' }, + data: null, + }); + + const result = await scopedClient.proxyRequest({ + method: 'GET', + path: 'api/v1/pipelines/old', + }); + + expect(result.status).toBe(307); + expect(result.headers.location).toBe( + '/api/rdi/rdiId/proxy/api/v1/pipelines/new', + ); + }); + + it('should strip a redirect Location that escapes the rdi url subpath via an encoded ..%2f', async () => { + const scopedClient = new ApiRdiClient(mockRdiClientMetadata, { + ...mockRdi, + url: 'http://localhost:4000/rdi', + }); + mockedAxios.request.mockResolvedValueOnce({ + status: 307, + headers: { location: '/rdi/..%2f..%2fadmin' }, + data: null, + }); + + const result = await scopedClient.proxyRequest({ + method: 'GET', + path: 'api/v1/pipelines/old', + }); + + expect(result.status).toBe(307); + expect(result.headers.location).toBeUndefined(); + }); + + it('should allow a request path when the rdi url has no subpath', async () => { + mockedAxios.request.mockResolvedValueOnce({ + status: 200, + headers: {}, + data: null, + }); + + await expect( + client.proxyRequest({ method: 'GET', path: 'api/v1/pipelines' }), + ).resolves.toBeDefined(); + }); + + it('should allow a request path that stays under the rdi url subpath', async () => { + const scopedClient = new ApiRdiClient(mockRdiClientMetadata, { + ...mockRdi, + url: 'http://localhost:4000/rdi', + }); + mockedAxios.request.mockResolvedValueOnce({ + status: 200, + headers: {}, + data: null, + }); + + await expect( + scopedClient.proxyRequest({ + method: 'GET', + path: 'api/v1/pipelines', + }), + ).resolves.toBeDefined(); + }); + + it('should reject a request path that escapes the rdi url subpath via ..', async () => { + const scopedClient = new ApiRdiClient(mockRdiClientMetadata, { + ...mockRdi, + url: 'http://localhost:4000/rdi', + }); + + await expect( + scopedClient.proxyRequest({ method: 'GET', path: '../admin' }), + ).rejects.toThrow( + 'Requested path is outside the configured RDI instance URL', + ); + expect(mockedAxios.request).not.toHaveBeenCalled(); + }); + + it('should reject a request path that escapes the rdi url subpath via an encoded ..%2f', async () => { + const scopedClient = new ApiRdiClient(mockRdiClientMetadata, { + ...mockRdi, + url: 'http://localhost:4000/rdi', + }); + + await expect( + scopedClient.proxyRequest({ + method: 'GET', + path: 'foo/..%2f..%2fadmin', + }), + ).rejects.toThrow( + 'Requested path is outside the configured RDI instance URL', + ); + expect(mockedAxios.request).not.toHaveBeenCalled(); + }); + + it('should reject a request path that escapes the rdi url subpath via %2e%2e%2f', async () => { + const scopedClient = new ApiRdiClient(mockRdiClientMetadata, { + ...mockRdi, + url: 'http://localhost:4000/rdi', + }); + + await expect( + scopedClient.proxyRequest({ + method: 'GET', + path: '%2e%2e%2fadmin', + }), + ).rejects.toThrow( + 'Requested path is outside the configured RDI instance URL', + ); + expect(mockedAxios.request).not.toHaveBeenCalled(); + }); + + it('should reject a request path with malformed percent-encoding', async () => { + const scopedClient = new ApiRdiClient(mockRdiClientMetadata, { + ...mockRdi, + url: 'http://localhost:4000/rdi', + }); + + await expect( + scopedClient.proxyRequest({ method: 'GET', path: 'foo%' }), + ).rejects.toThrow('Requested path is malformed'); + expect(mockedAxios.request).not.toHaveBeenCalled(); + }); + + it('should pass through non-2xx responses instead of throwing', async () => { + mockedAxios.request.mockResolvedValueOnce({ + status: 404, + headers: {}, + data: { message: 'Not found' }, + }); + + const result = await client.proxyRequest({ + method: 'GET', + path: 'api/v1/pipelines/unknown', + }); + + expect(result.status).toBe(404); + expect(result.data).toEqual({ message: 'Not found' }); + }); + }); + describe('connect', () => { it('should set auth and authorization headers on successful login', async () => { const mockedAccessToken = sign( diff --git a/redisinsight/api/src/modules/rdi/client/api/v1/api.rdi.client.ts b/redisinsight/api/src/modules/rdi/client/api/v1/api.rdi.client.ts index 407fb94b7b..3b6d982842 100644 --- a/redisinsight/api/src/modules/rdi/client/api/v1/api.rdi.client.ts +++ b/redisinsight/api/src/modules/rdi/client/api/v1/api.rdi.client.ts @@ -1,7 +1,9 @@ +import { posix } from 'path'; import { sign } from 'jsonwebtoken'; import axios, { AxiosInstance } from 'axios'; import { plainToInstance } from 'class-transformer'; -import { HttpStatus, Logger } from '@nestjs/common'; +import { ForbiddenException, HttpStatus, Logger } from '@nestjs/common'; +import appConfig, { Config } from 'src/utils/config'; import { RdiClient } from 'src/modules/rdi/client/rdi.client'; import { @@ -38,6 +40,8 @@ import { RdiClientMetadata, Rdi, RdiPipelineStatus, + RdiProxyRequest, + RdiProxyResponse, } from 'src/modules/rdi/models'; import { RdiPipelineTimeoutException } from 'src/modules/rdi/exceptions/rdi-pipeline.timeout-error.exception'; import * as https from 'https'; @@ -58,6 +62,8 @@ interface ConnectionsConfig { sources: Record>; } +const SERVER_CONFIG = appConfig.get('server') as Config['server']; + export class ApiRdiClient extends RdiClient { protected readonly client: AxiosInstance; @@ -351,6 +357,188 @@ export class ApiRdiClient extends RdiClient { } } + async proxyRequest({ + method, + path, + query, + body, + headers, + }: RdiProxyRequest): Promise { + const requestUrl = query ? `${path}?${query}` : path; + this.assertPathWithinRdiBase(path); + + // Non-2xx responses are part of the RDI API contract the UI's SDK handles + // itself, so pass them through instead of throwing. + const response = await this.client.request({ + method, + url: requestUrl, + data: body, + headers, + validateStatus: null, + // `path` is caller-controlled; without this, axios lets an + // absolute/scheme-relative path override baseURL and send our + // Authorization header to an arbitrary host (SSRF) + allowAbsoluteUrls: false, + // return 3xx to the caller instead of following it server-side - a + // followed Location could point our backend (not just the browser) at + // an arbitrary/internal host, which allowAbsoluteUrls doesn't cover + maxRedirects: 0, + // avoid axios parsing/re-serializing the body, which corrupts + // non-JSON, binary, or already-encoded upstream responses + responseType: 'arraybuffer', + }); + + const responseHeaders = response.headers as Record; + + if (response.status >= 300 && response.status < 400) { + const locationKey = Object.keys(responseHeaders).find( + (key) => key.toLowerCase() === 'location', + ); + + if (locationKey) { + const rewritten = this.rewriteLocationThroughProxy( + responseHeaders[locationKey], + ); + + if (rewritten) { + responseHeaders[locationKey] = rewritten; + } else { + delete responseHeaders[locationKey]; + } + } + } + + return { + status: response.status, + headers: responseHeaders, + data: response.data, + }; + } + + /** + * `rdi.url` may itself have a path (RDI hosted under a subpath, e.g. + * https://host/rdi). allowAbsoluteUrls only stops an absolute/scheme + * -relative path from overriding the origin - a relative `../` segment + * still normalizes past that subpath once combined with the base URL, so + * check the resolved target stays under it. + */ + private assertPathWithinRdiBase(path: string): void { + const baseUrl = new URL(this.rdi.url); + const basePath = baseUrl.pathname.replace(/\/+$/, ''); + + if (!basePath) { + return; + } + + const decodedPath = ApiRdiClient.decodePathComponent(path); + + // mirrors axios's own combineURLs (plain concatenation), not WHATWG + // relative-URL resolution - the latter would drop the base's last path + // segment as if it were a filename, which isn't how axios joins these + const resolved = new URL( + `${basePath}/${decodedPath.replace(/^\/+/, '')}`, + baseUrl.origin, + ); + + if ( + resolved.pathname !== basePath && + !resolved.pathname.startsWith(`${basePath}/`) + ) { + throw new ForbiddenException( + 'Requested path is outside the configured RDI instance URL', + ); + } + } + + /** + * decodeURIComponent can turn an encoded delimiter (%3F, %23) into a + * literal ?/# that a later URL() parse would treat as the start of a + * query/fragment, silently truncating what actually gets validated while + * the real request still carries the original, still-encoded path - + * reject outright rather than let the check and the request diverge. + */ + private static decodePathComponent(value: string): string { + let decoded: string; + try { + decoded = decodeURIComponent(value); + } catch { + throw new ForbiddenException('Requested path is malformed'); + } + + if (/[?#]/.test(decoded)) { + throw new ForbiddenException('Requested path is malformed'); + } + + return decoded; + } + + /** + * If a redirect Location stays within RDI's base, rewrite it to the + * externally reachable proxy URL - RDI's raw Location (absolute or + * root-relative) would otherwise send the browser straight to RDI + * itself, bypassing the proxy's injected auth and RedisInsight's CORS. + * Returns null when the Location should be stripped instead (points + * outside RDI's base or failed to parse). + */ + private rewriteLocationThroughProxy(location: string): string | null { + const baseUrl = new URL(this.rdi.url); + + let resolved: URL; + try { + resolved = new URL(location, baseUrl); + } catch { + return null; + } + + if (resolved.origin !== baseUrl.origin) { + return null; + } + + // resolved.pathname can still hide an encoded dot-segment (%2e%2e, + // %2f) - decode and re-resolve it the same way assertPathWithinRdiBase + // does, so an RDI-returned Location can't encode its way past the + // subpath check below. + let decodedPathname: string; + try { + decodedPathname = decodeURIComponent(resolved.pathname); + } catch { + return null; + } + + if (/[?#]/.test(decodedPathname)) { + return null; + } + + let normalized: URL; + try { + normalized = new URL(decodedPathname, baseUrl.origin); + } catch { + return null; + } + + const basePath = baseUrl.pathname.replace(/\/+$/, ''); + + if ( + basePath && + normalized.pathname !== basePath && + !normalized.pathname.startsWith(`${basePath}/`) + ) { + return null; + } + + const relativePath = normalized.pathname.slice(basePath.length); + const proxyPrefix = posix.join( + '/', + SERVER_CONFIG.proxyPath || '', + SERVER_CONFIG.globalPrefix, + 'rdi', + this.metadata.id, + 'proxy', + ); + + return `${posix.join(proxyPrefix, relativePath)}${resolved.search}${resolved.hash}`; + } + private async pollActionStatus( actionId: string, action: PipelineActions, diff --git a/redisinsight/api/src/modules/rdi/client/rdi.client.ts b/redisinsight/api/src/modules/rdi/client/rdi.client.ts index c44e2228af..ae5384de29 100644 --- a/redisinsight/api/src/modules/rdi/client/rdi.client.ts +++ b/redisinsight/api/src/modules/rdi/client/rdi.client.ts @@ -3,6 +3,8 @@ import { RdiClientMetadata, RdiPipeline, RdiPipelineStatus, + RdiProxyRequest, + RdiProxyResponse, RdiStatisticsResult, } from 'src/modules/rdi/models'; import { @@ -70,6 +72,15 @@ export abstract class RdiClient { abstract connect(): Promise; + /** + * Forwards an arbitrary request to the RDI instance's native API, reusing this + * client's base URL and bearer token. + * + * Needed by the @rdi-ui/pipeline pipeline management UI, which speaks the + * native RDI API directly instead of RedisInsight's curated /rdi endpoints. + */ + abstract proxyRequest(request: RdiProxyRequest): Promise; + public setLastUsed(): void { this.lastUsed = Date.now(); } diff --git a/redisinsight/api/src/modules/rdi/models/index.ts b/redisinsight/api/src/modules/rdi/models/index.ts index 0dfd46698c..25f0558e2d 100644 --- a/redisinsight/api/src/modules/rdi/models/index.ts +++ b/redisinsight/api/src/modules/rdi/models/index.ts @@ -6,3 +6,4 @@ export * from './rdi-dry-run'; export * from './rdi-statistics'; export * from './rdi-info'; export * from './rdi.pipeline.status'; +export * from './rdi-proxy'; diff --git a/redisinsight/api/src/modules/rdi/models/rdi-proxy.ts b/redisinsight/api/src/modules/rdi/models/rdi-proxy.ts new file mode 100644 index 0000000000..75880953fb --- /dev/null +++ b/redisinsight/api/src/modules/rdi/models/rdi-proxy.ts @@ -0,0 +1,33 @@ +/** + * Shapes for the RDI native-API passthrough used by the @rdi-ui/pipeline + * pipeline management UI. + * + * The UI ships its own SDK that talks the native RDI API, so RedisInsight + * forwards those calls verbatim rather than mapping them onto its curated + * /rdi/:id/pipeline endpoints. Auth and TLS handling stay server-side. + */ +export interface RdiProxyRequest { + method: string; + + /** Path relative to the RDI instance base URL, without a leading slash. */ + path: string; + + /** Raw query string from the incoming request, without the leading '?'. */ + query?: string; + + body?: unknown; + + /** + * Headers forwarded from the caller. Hop-by-hop, auth and host headers are + * stripped before they reach here. + */ + headers?: Record; +} + +export interface RdiProxyResponse { + status: number; + + headers: Record; + + data: unknown; +} diff --git a/redisinsight/api/src/modules/rdi/rdi-proxy.controller.spec.ts b/redisinsight/api/src/modules/rdi/rdi-proxy.controller.spec.ts new file mode 100644 index 0000000000..bd86220395 --- /dev/null +++ b/redisinsight/api/src/modules/rdi/rdi-proxy.controller.spec.ts @@ -0,0 +1,140 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { mockRdiClientMetadata } from 'src/__mocks__'; +import { RdiProxyController } from './rdi-proxy.controller'; +import { RdiProxyService } from './rdi-proxy.service'; + +const mockRdiProxyService = () => ({ + proxy: jest.fn(), +}); + +const mockResponse = () => { + const res: any = {}; + res.status = jest.fn().mockReturnValue(res); + res.set = jest.fn().mockReturnValue(res); + res.send = jest.fn().mockReturnValue(res); + return res; +}; + +describe('RdiProxyController', () => { + let controller: RdiProxyController; + let service: ReturnType; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + controllers: [RdiProxyController], + providers: [ + { + provide: RdiProxyService, + useFactory: mockRdiProxyService, + }, + ], + }).compile(); + + controller = module.get(RdiProxyController); + service = module.get(RdiProxyService); + }); + + describe('proxy', () => { + it('should forward the request path, method, query and body, and reply with the upstream response', async () => { + service.proxy.mockResolvedValueOnce({ + status: 201, + headers: { 'content-type': 'application/json' }, + data: { id: '1' }, + }); + const res = mockResponse(); + const req: any = { + method: 'POST', + url: `/rdi/${mockRdiClientMetadata.id}/proxy/api/v1/pipelines?dryRun=true`, + body: { name: 'my-pipeline' }, + headers: { 'content-type': 'application/json' }, + }; + + await controller.proxy(mockRdiClientMetadata, req, res); + + expect(service.proxy).toHaveBeenCalledWith(mockRdiClientMetadata, { + method: 'POST', + path: 'api/v1/pipelines', + query: 'dryRun=true', + body: { name: 'my-pipeline' }, + headers: { 'content-type': 'application/json' }, + }); + expect(res.status).toHaveBeenCalledWith(201); + expect(res.set).toHaveBeenCalledWith({ + 'content-type': 'application/json', + }); + expect(res.send).toHaveBeenCalledWith({ id: '1' }); + }); + + it('should preserve percent-encoded delimiters in the upstream path', async () => { + service.proxy.mockResolvedValueOnce({ + status: 200, + headers: {}, + data: null, + }); + const res = mockResponse(); + const req: any = { + method: 'GET', + url: `/rdi/${mockRdiClientMetadata.id}/proxy/pipelines/foo%3Fbar`, + headers: {}, + }; + + await controller.proxy(mockRdiClientMetadata, req, res); + + expect(service.proxy).toHaveBeenCalledWith( + mockRdiClientMetadata, + expect.objectContaining({ + path: 'pipelines/foo%3Fbar', + }), + ); + }); + + it('should return an empty path when the proxy marker is not found in the url', async () => { + service.proxy.mockResolvedValueOnce({ + status: 200, + headers: {}, + data: null, + }); + const res = mockResponse(); + const req: any = { + method: 'GET', + url: '/some/unrelated/path', + headers: {}, + }; + + await controller.proxy(mockRdiClientMetadata, req, res); + + expect(service.proxy).toHaveBeenCalledWith( + mockRdiClientMetadata, + expect.objectContaining({ path: '' }), + ); + }); + + it('should reject a TRACE request without forwarding it to rdi', async () => { + const res = mockResponse(); + const req: any = { + method: 'TRACE', + url: `/rdi/${mockRdiClientMetadata.id}/proxy/api/v1/pipelines`, + headers: {}, + }; + + await expect( + controller.proxy(mockRdiClientMetadata, req, res), + ).rejects.toThrow('Method TRACE is not supported by this proxy'); + expect(service.proxy).not.toHaveBeenCalled(); + }); + + it('should reject a CONNECT request without forwarding it to rdi', async () => { + const res = mockResponse(); + const req: any = { + method: 'CONNECT', + url: `/rdi/${mockRdiClientMetadata.id}/proxy/api/v1/pipelines`, + headers: {}, + }; + + await expect( + controller.proxy(mockRdiClientMetadata, req, res), + ).rejects.toThrow('Method CONNECT is not supported by this proxy'); + expect(service.proxy).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/redisinsight/api/src/modules/rdi/rdi-proxy.controller.ts b/redisinsight/api/src/modules/rdi/rdi-proxy.controller.ts new file mode 100644 index 0000000000..b0baf406bc --- /dev/null +++ b/redisinsight/api/src/modules/rdi/rdi-proxy.controller.ts @@ -0,0 +1,84 @@ +import { + All, + Controller, + MethodNotAllowedException, + Req, + Res, +} from '@nestjs/common'; +import { ApiExcludeController } from '@nestjs/swagger'; +import type { Request, Response } from 'express'; +import { RdiProxyService } from 'src/modules/rdi/rdi-proxy.service'; +import { RequestRdiClientMetadata } from 'src/modules/rdi/decorators'; +import { RdiClientMetadata } from 'src/modules/rdi/models'; + +const ALLOWED_PROXY_METHODS = new Set([ + 'GET', + 'POST', + 'PUT', + 'PATCH', + 'DELETE', +]); + +/** + * Transparent passthrough to an RDI instance's native API. + * + * The @rdi-ui/pipeline pipeline management UI ships its own RDI SDK and + * expects to call the native API directly. Routing it through here keeps the + * instance credentials and the self-signed-certificate handling server-side, + * and avoids CORS entirely since the browser only ever talks to RedisInsight. + * + * Excluded from Swagger: the surface is whatever the upstream RDI exposes, not + * a contract RedisInsight defines. + */ +@ApiExcludeController() +@Controller('rdi/:id/proxy') +export class RdiProxyController { + constructor(private readonly rdiProxyService: RdiProxyService) {} + + @All('*path') + async proxy( + @RequestRdiClientMetadata() rdiClientMetadata: RdiClientMetadata, + @Req() req: Request, + @Res() res: Response, + ): Promise { + if (!ALLOWED_PROXY_METHODS.has(req.method)) { + throw new MethodNotAllowedException( + `Method ${req.method} is not supported by this proxy`, + ); + } + + const { status, headers, data } = await this.rdiProxyService.proxy( + rdiClientMetadata, + { + method: req.method, + path: RdiProxyController.getUpstreamPath(req, rdiClientMetadata.id), + query: RdiProxyController.getQueryString(req), + body: req.body, + headers: req.headers as Record, + }, + ); + + res.status(status).set(headers).send(data); + } + + /** + * Sliced from the raw (still percent-encoded) URL rather than Express's + * decoded `req.params.path` wildcard array - decoding first would turn an + * encoded delimiter like %2F into a literal path separator axios acts on. + */ + private static getUpstreamPath(req: Request, rdiInstanceId: string): string { + const rawPath = req.url.split('?')[0]; + const marker = `/rdi/${rdiInstanceId}/proxy`; + const markerIndex = rawPath.indexOf(marker); + + if (markerIndex === -1) { + return ''; + } + + return rawPath.slice(markerIndex + marker.length).replace(/^\/+/, ''); + } + + private static getQueryString(req: Request): string { + return req.url.split('?').slice(1).join('?'); + } +} diff --git a/redisinsight/api/src/modules/rdi/rdi-proxy.service.spec.ts b/redisinsight/api/src/modules/rdi/rdi-proxy.service.spec.ts new file mode 100644 index 0000000000..b353fb4167 --- /dev/null +++ b/redisinsight/api/src/modules/rdi/rdi-proxy.service.spec.ts @@ -0,0 +1,143 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { RdiClientProvider } from 'src/modules/rdi/providers/rdi.client.provider'; +import { mockRdiClientMetadata, mockRdiClientProvider } from 'src/__mocks__'; +import { RdiProxyService } from './rdi-proxy.service'; + +describe('RdiProxyService', () => { + let service: RdiProxyService; + let rdiClientProvider: ReturnType; + let client: { proxyRequest: jest.Mock; setLastUsed: jest.Mock }; + + beforeEach(async () => { + client = { + proxyRequest: jest.fn(), + setLastUsed: jest.fn(), + }; + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + RdiProxyService, + { + provide: RdiClientProvider, + useFactory: mockRdiClientProvider, + }, + ], + }).compile(); + + service = module.get(RdiProxyService); + rdiClientProvider = module.get(RdiClientProvider); + rdiClientProvider.getOrCreate.mockResolvedValue(client); + }); + + describe('proxy', () => { + it('should forward the request through the rdi client and mark it as used', async () => { + client.proxyRequest.mockResolvedValueOnce({ + status: 200, + headers: { 'content-type': 'application/json' }, + data: { ok: true }, + }); + + const result = await service.proxy(mockRdiClientMetadata, { + method: 'GET', + path: 'api/v1/pipelines', + headers: { authorization: 'Bearer client-token' }, + }); + + expect(rdiClientProvider.getOrCreate).toHaveBeenCalledWith( + mockRdiClientMetadata, + ); + expect(client.proxyRequest).toHaveBeenCalledWith( + expect.objectContaining({ + method: 'GET', + path: 'api/v1/pipelines', + }), + ); + expect(client.setLastUsed).toHaveBeenCalled(); + expect(result).toEqual({ + status: 200, + headers: { + 'content-type': 'application/json', + 'content-security-policy': 'sandbox', + 'x-content-type-options': 'nosniff', + }, + data: { ok: true }, + }); + }); + + it('should strip hop-by-hop and auth headers from the outgoing request', async () => { + client.proxyRequest.mockResolvedValueOnce({ + status: 200, + headers: {}, + data: null, + }); + + await service.proxy(mockRdiClientMetadata, { + method: 'POST', + path: 'api/v1/pipelines', + headers: { + host: 'redisinsight.local', + cookie: 'session=abc', + authorization: 'Bearer caller-token', + 'x-csrf-token': 'csrf-token', + 'x-window-id': 'window-1', + 'content-encoding': 'gzip', + 'content-type': 'application/json', + }, + }); + + const forwardedRequest = client.proxyRequest.mock.calls[0][0]; + expect(forwardedRequest.headers).toEqual({ + 'content-type': 'application/json', + }); + }); + + it('should strip transport-level and CORS headers from the response', async () => { + client.proxyRequest.mockResolvedValueOnce({ + status: 200, + headers: { + connection: 'keep-alive', + 'content-length': '123', + 'set-cookie': 'session=abc', + 'access-control-allow-origin': 'https://rdi-host', + 'clear-site-data': '"cookies", "storage"', + 'content-type': 'application/json', + }, + data: null, + }); + + const result = await service.proxy(mockRdiClientMetadata, { + method: 'GET', + path: 'api/v1/pipelines', + }); + + expect(result.headers).toEqual({ + 'content-type': 'application/json', + 'content-security-policy': 'sandbox', + 'x-content-type-options': 'nosniff', + }); + }); + + it('should force a sandboxed CSP and nosniff, overriding any values RDI sends', async () => { + client.proxyRequest.mockResolvedValueOnce({ + status: 200, + headers: { + 'content-type': 'text/html', + 'content-security-policy': "default-src 'self' 'unsafe-inline'", + 'x-content-type-options': 'none', + }, + data: '', + }); + + const result = await service.proxy(mockRdiClientMetadata, { + method: 'GET', + path: 'api/v1/pipelines', + }); + + expect(result.headers).toEqual({ + 'content-type': 'text/html', + 'content-security-policy': 'sandbox', + 'x-content-type-options': 'nosniff', + }); + }); + }); +}); diff --git a/redisinsight/api/src/modules/rdi/rdi-proxy.service.ts b/redisinsight/api/src/modules/rdi/rdi-proxy.service.ts new file mode 100644 index 0000000000..9b4ee58c1a --- /dev/null +++ b/redisinsight/api/src/modules/rdi/rdi-proxy.service.ts @@ -0,0 +1,125 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { RdiClientProvider } from 'src/modules/rdi/providers/rdi.client.provider'; +import { + RdiClientMetadata, + RdiProxyRequest, + RdiProxyResponse, +} from 'src/modules/rdi/models'; + +/** + * Headers that must not be forwarded to the RDI instance. + * + * `authorization` is dropped because the proxy attaches the RDI client's own + * bearer token; `host`/`content-length` are recalculated by axios; the rest are + * hop-by-hop headers that are meaningless to the upstream. + */ +const STRIPPED_REQUEST_HEADERS = new Set([ + 'host', + 'connection', + 'keep-alive', + 'content-length', + 'content-encoding', + 'transfer-encoding', + 'upgrade', + 'proxy-authorization', + 'proxy-authenticate', + 'te', + 'trailer', + 'authorization', + 'cookie', + 'x-csrf-token', + 'x-window-id', +]); + +/** + * Response headers that describe the upstream transport rather than the payload + * and would corrupt the response if replayed to the browser. + * + * `access-control-*` is dropped too: Nest's own enableCors() already sets the + * correct policy for RedisInsight's own origin, and RDI's CORS headers (which + * describe a policy for RDI's own origin, not RedisInsight's) would replace it. + */ +const STRIPPED_RESPONSE_HEADERS = new Set([ + 'connection', + 'keep-alive', + 'content-length', + 'content-encoding', + 'transfer-encoding', + 'upgrade', + 'set-cookie', + // browsers act on this even for a plain fetch response - would let RDI + // wipe RedisInsight's own cookies/storage since it comes from our origin + 'clear-site-data', + // overridden below with FORCED_RESPONSE_HEADERS, not just stripped + 'content-security-policy', + 'x-content-type-options', +]); +const STRIPPED_RESPONSE_HEADER_PREFIXES = ['access-control-']; + +/** + * If RDI is compromised and returns e.g. `text/html`, opening the proxy URL + * as a document would otherwise render/execute that response under + * RedisInsight's own origin. `sandbox` only affects that document-navigation + * case - it doesn't touch the fetch/XHR responses the pipeline SDK actually + * consumes. + */ +const FORCED_RESPONSE_HEADERS: Record = { + 'content-security-policy': 'sandbox', + 'x-content-type-options': 'nosniff', +}; + +@Injectable() +export class RdiProxyService { + private readonly logger = new Logger('RdiProxyService'); + + constructor(private readonly rdiClientProvider: RdiClientProvider) {} + + async proxy( + rdiClientMetadata: RdiClientMetadata, + request: RdiProxyRequest, + ): Promise { + this.logger.debug('Proxying request to rdi instance', rdiClientMetadata); + + const client = await this.rdiClientProvider.getOrCreate(rdiClientMetadata); + + const response = await client.proxyRequest({ + ...request, + headers: RdiProxyService.filterRequestHeaders(request.headers), + }); + + client.setLastUsed(); + + return { + ...response, + headers: RdiProxyService.filterResponseHeaders(response.headers), + }; + } + + private static filterRequestHeaders( + headers: Record = {}, + ): Record { + return Object.fromEntries( + Object.entries(headers).filter( + ([name]) => !STRIPPED_REQUEST_HEADERS.has(name.toLowerCase()), + ), + ); + } + + private static filterResponseHeaders( + headers: Record = {}, + ): Record { + const filtered = Object.fromEntries( + Object.entries(headers).filter(([name]) => { + const lowerName = name.toLowerCase(); + return ( + !STRIPPED_RESPONSE_HEADERS.has(lowerName) && + !STRIPPED_RESPONSE_HEADER_PREFIXES.some((prefix) => + lowerName.startsWith(prefix), + ) + ); + }), + ); + + return { ...filtered, ...FORCED_RESPONSE_HEADERS }; + } +} diff --git a/redisinsight/api/src/modules/rdi/rdi.module.ts b/redisinsight/api/src/modules/rdi/rdi.module.ts index 092c8b1f56..c5da76e943 100644 --- a/redisinsight/api/src/modules/rdi/rdi.module.ts +++ b/redisinsight/api/src/modules/rdi/rdi.module.ts @@ -16,6 +16,8 @@ import { PipelineDraftController } from 'src/modules/rdi/pipeline-draft.controll import { PipelineDraftService } from 'src/modules/rdi/pipeline-draft.service'; import { PipelineDraftRepository } from 'src/modules/rdi/repository/pipeline-draft.repository'; import { LocalPipelineDraftRepository } from 'src/modules/rdi/repository/local.pipeline-draft.repository'; +import { RdiProxyController } from 'src/modules/rdi/rdi-proxy.controller'; +import { RdiProxyService } from 'src/modules/rdi/rdi-proxy.service'; @Module({}) export class RdiModule { @@ -27,12 +29,14 @@ export class RdiModule { RdiPipelineController, RdiStatisticsController, PipelineDraftController, + RdiProxyController, ], providers: [ RdiService, RdiPipelineService, RdiStatisticsService, PipelineDraftService, + RdiProxyService, RdiClientProvider, RdiClientStorage, RdiClientFactory,