From aeba980ff575b685da115ed3ac480a7d58169e75 Mon Sep 17 00:00:00 2001 From: Erik Michelson Date: Fri, 20 Feb 2026 17:11:39 +0100 Subject: [PATCH] fix(backend): expose backend port for non-localhost The current implementation restricts the backend port binding to 127.0.0.1, since this is the default of fastify. This is a reasonable default from a security standpoint. However, in certain contexts like docker network, this won't work. The new configuration option HD_BACKEND_BIND_IP allows to set a custom IP address to which fastify binds, or setting 0.0.0.0 to bind to all interfaces. At the same time this fix extends the Dockerfile to announce port 3000 to be available to the docker daemon. Signed-off-by: Erik Michelson --- backend/docker/Dockerfile | 1 + backend/src/config/app.config.spec.ts | 62 ++++++++++++++++++++++ backend/src/config/app.config.ts | 2 + backend/src/config/mock/app.config.mock.ts | 1 + backend/src/main.ts | 2 +- docs/content/references/config/general.md | 1 + 6 files changed, 68 insertions(+), 1 deletion(-) diff --git a/backend/docker/Dockerfile b/backend/docker/Dockerfile index 0338a5b9e..b8ed30929 100644 --- a/backend/docker/Dockerfile +++ b/backend/docker/Dockerfile @@ -74,4 +74,5 @@ COPY --chown=node commons/package.json /usr/src/app/commons/package.json COPY --chown=node --from=builder /usr/src/app/commons/dist commons/dist WORKDIR /usr/src/app/backend +EXPOSE 3000/tcp CMD ["node", "dist/main.js"] diff --git a/backend/src/config/app.config.spec.ts b/backend/src/config/app.config.spec.ts index c7fda98c0..09558ad0d 100644 --- a/backend/src/config/app.config.spec.ts +++ b/backend/src/config/app.config.spec.ts @@ -17,6 +17,9 @@ describe('appConfig', () => { const floatPort = 3.14; const outOfRangePort = 1000000; const invalidPort = 'not-a-port'; + const bindIp = '0.0.0.0'; + const bindIpV6 = '::1'; + const invalidBindIp = 'not-an-ip'; const loglevel = Loglevel.TRACE; const showLogTimestamp = false; const invalidLoglevel = 'not-a-loglevel'; @@ -29,6 +32,7 @@ describe('appConfig', () => { HD_BASE_URL: baseUrl, HD_RENDERER_BASE_URL: rendererBaseUrl, HD_BACKEND_PORT: port.toString(), + HD_BACKEND_BIND_IP: bindIp, HD_LOG_LEVEL: loglevel, HD_LOG_SHOW_TIMESTAMP: showLogTimestamp.toString(), /* oxlint-enable @typescript-eslint/naming-convention */ @@ -41,11 +45,49 @@ describe('appConfig', () => { expect(config.baseUrl).toEqual(baseUrl); expect(config.rendererBaseUrl).toEqual(rendererBaseUrl); expect(config.backendPort).toEqual(port); + expect(config.backendBindIp).toEqual(bindIp); expect(config.log.level).toEqual(loglevel); expect(config.log.showTimestamp).toEqual(showLogTimestamp); restore(); }); + it('when given an IPv6 address as HD_BACKEND_BIND_IP', () => { + const restore = mockedEnv( + { + /* oxlint-disable @typescript-eslint/naming-convention */ + HD_BASE_URL: baseUrl, + HD_BACKEND_BIND_IP: bindIpV6, + HD_LOG_LEVEL: loglevel, + HD_LOG_SHOW_TIMESTAMP: showLogTimestamp.toString(), + /* oxlint-enable @typescript-eslint/naming-convention */ + }, + { + clear: true, + }, + ); + const config = appConfig(); + expect(config.backendBindIp).toEqual(bindIpV6); + restore(); + }); + + it('when no HD_BACKEND_BIND_IP is set', () => { + const restore = mockedEnv( + { + /* oxlint-disable @typescript-eslint/naming-convention */ + HD_BASE_URL: baseUrl, + HD_LOG_LEVEL: loglevel, + HD_LOG_SHOW_TIMESTAMP: showLogTimestamp.toString(), + /* oxlint-enable @typescript-eslint/naming-convention */ + }, + { + clear: true, + }, + ); + const config = appConfig(); + expect(config.backendBindIp).toEqual('127.0.0.1'); + restore(); + }); + it('when no HD_RENDER_BASE_URL is set', () => { const restore = mockedEnv( { @@ -289,6 +331,26 @@ describe('appConfig', () => { restore(); }); + it('when given a non-IP address as HD_BACKEND_BIND_IP', async () => { + const restore = mockedEnv( + { + /* oxlint-disable @typescript-eslint/naming-convention */ + HD_BASE_URL: baseUrl, + HD_BACKEND_BIND_IP: invalidBindIp, + HD_LOG_LEVEL: loglevel, + HD_LOG_SHOW_TIMESTAMP: showLogTimestamp.toString(), + /* oxlint-enable @typescript-eslint/naming-convention */ + }, + { + clear: true, + }, + ); + appConfig(); + expect(spyConsoleError.mock.calls[0][0]).toContain('HD_BACKEND_BIND_IP'); + expect(spyProcessExit).toHaveBeenCalledWith(1); + restore(); + }); + it('when given a non-loglevel HD_LOG_LEVEL', async () => { const restore = mockedEnv( { diff --git a/backend/src/config/app.config.ts b/backend/src/config/app.config.ts index ed26ea042..7f3c4f09f 100644 --- a/backend/src/config/app.config.ts +++ b/backend/src/config/app.config.ts @@ -61,6 +61,7 @@ const schema = z .default('') .describe('HD_RENDERER_BASE_URL'), backendPort: z.number().positive().int().max(65535).default(3000).describe('HD_BACKEND_PORT'), + backendBindIp: z.string().ip().default('127.0.0.1').describe('HD_BACKEND_BIND_IP'), log: z.object({ level: z .enum(Object.values(Loglevel) as [Loglevel, ...Loglevel[]]) @@ -84,6 +85,7 @@ export default registerAs('appConfig', () => { baseUrl: process.env.HD_BASE_URL, rendererBaseUrl: process.env.HD_RENDERER_BASE_URL, backendPort: parseOptionalNumber(process.env.HD_BACKEND_PORT), + backendBindIp: process.env.HD_BACKEND_BIND_IP, log: { level: process.env.HD_LOG_LEVEL, showTimestamp: parseOptionalBoolean(process.env.HD_LOG_SHOW_TIMESTAMP), diff --git a/backend/src/config/mock/app.config.mock.ts b/backend/src/config/mock/app.config.mock.ts index 5ddbb5bb6..61bdb26c9 100644 --- a/backend/src/config/mock/app.config.mock.ts +++ b/backend/src/config/mock/app.config.mock.ts @@ -14,6 +14,7 @@ export function createDefaultMockAppConfig(): AppConfig { baseUrl: 'md.example.com', rendererBaseUrl: 'md-renderer.example.com', backendPort: 3000, + backendBindIp: '127.0.0.1', log: { level: Loglevel.INFO, showTimestamp: true, diff --git a/backend/src/main.ts b/backend/src/main.ts index c26cd4f72..58b328cb2 100644 --- a/backend/src/main.ts +++ b/backend/src/main.ts @@ -60,7 +60,7 @@ async function bootstrap(): Promise { await setupApp(app, appConfig, authConfig, mediaConfig, securityConfig, logger); // Start the server - await app.listen(appConfig.backendPort); + await app.listen(appConfig.backendPort, appConfig.backendBindIp); } void bootstrap(); diff --git a/docs/content/references/config/general.md b/docs/content/references/config/general.md index 6a7fde6f8..06e0062e3 100644 --- a/docs/content/references/config/general.md +++ b/docs/content/references/config/general.md @@ -5,6 +5,7 @@ | `HD_BASE_URL` | - | `https://md.example.com` | The URL the HedgeDoc instance is accessed with, like it is entered in the browser | | `HD_BACKEND_PORT` | 3000 | | The port the backend process listens on. | | `HD_FRONTEND_PORT` | 3001 | | The port the frontend process listens on. | +| `HD_BACKEND_BIND_IP` | 127.0.0.1 | `0.0.0.0` | The IP address to which the backend server should bind. | | `HD_RENDERER_BASE_URL` | Content of HD_BASE_URL | | The URL the renderer runs on. If omitted this will be the same as `HD_BASE_URL`. For more detail see [this faq entry][faq-entry] | | `HD_INTERNAL_API_URL` | Content of HD_BASE_URL | `http://localhost:3000` | This URL is used by the frontend to access the backend directly if it can't reach the backend using the `HD_BASE_URL` | | `HD_LOG_LEVEL` | warn | | The loglevel that should be used. Options are `error`, `warn`, `info`, `debug` or `trace`. |