Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,15 @@ DB_PASSWORD=changeme
# non-proxied client.
# TRUST_PROXY=true

# Canonical base URL the API is reachable at, used to build absolute
# URLs in the RFC 5988 Link header (next/prev/first/last pagination
# refs). Pin this in production so a client sending `Host: evil.com`
# can't get evil.com echoed back into the Link header and influence
# how its own paginating client walks the result set. Unset = derive
# from `req.protocol` + `req.get('host')` (the express default,
# which is safe when an upstream proxy filters Host).
# PUBLIC_BASE_URL=https://api.example.com

# Optional bearer token gating /metrics. Unset = open scrape (the
# usual private-network deployment pattern). When set, the
# Prometheus scrape must include `Authorization: Bearer <token>`.
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -208,6 +208,7 @@ production). See `.env.example` for the canonical reference.
| `DB_NAME` | `timetracker` | Database name. |
| `DB_USER` | `timetracker` | Database user (must have access to the `dbo` schema). |
| `DB_PASSWORD` | (empty) | Database password. **Required.** Setting it empty will cause connection failures and a startup warning. |
| `PUBLIC_BASE_URL` | (unset) | Canonical `scheme://host` the API is publicly reachable at. Used as the base for absolute URLs in the RFC 5988 `Link` header (pagination next/prev/first/last). Pin in production so a client sending a malicious `Host` header can't get it echoed back. Unset = derive from `req.protocol` + `req.get('host')`. |

`.env` is gitignored. Never commit a populated `.env`.

Expand Down
26 changes: 23 additions & 3 deletions app/middleware/pagination.js
Original file line number Diff line number Diff line change
Expand Up @@ -33,18 +33,38 @@ function buildLinkHeader({ req, limit, offset, count }) {

// Resolve the URL minus the query string. req.originalUrl is "/path?qs";
// strip the qs portion deterministically.
const proto = (req.protocol || 'http');
const host = (req.get && req.get('host')) || 'localhost';
const url = req.originalUrl || '/';
const qIdx = url.indexOf('?');
const basePath = qIdx === -1 ? url : url.slice(0, qIdx);
const existingQs = qIdx === -1 ? '' : url.slice(qIdx + 1);

// Base URL resolution, in priority order:
// 1. PUBLIC_BASE_URL env var — operator-pinned canonical hostname.
// Use this in production behind a reverse proxy that may
// receive arbitrary Host headers; pinning here prevents a
// client that sends `Host: evil.com` from getting evil.com
// echoed back into the Link header (next/prev/first/last)
// and influencing how its own paginating client walks the
// result set.
// 2. req.protocol + req.get('host') — the express defaults.
// Useful for local development and for deployments where the
// Host header is already filtered upstream (Caddy with a
// pinned TLS_DOMAIN, nginx with a server_name match, etc.).
let baseUrl;
const envBase = (process.env.PUBLIC_BASE_URL || '').trim().replace(/\/+$/, '');
if (envBase) {
baseUrl = envBase;
} else {
const proto = (req.protocol || 'http');
const host = (req.get && req.get('host')) || 'localhost';
baseUrl = `${proto}://${host}`;
}

const buildLink = (newOffset) => {
const params = new URLSearchParams(existingQs);
params.set('limit', String(lim));
params.set('offset', String(newOffset));
return `${proto}://${host}${basePath}?${params.toString()}`;
return `${baseUrl}${basePath}?${params.toString()}`;
};

const links = [];
Expand Down
41 changes: 40 additions & 1 deletion tests/unit/pagination.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
//
// Unit tests for the RFC 5988 Link header builder.

import { describe, test, expect } from 'vitest';
import { describe, test, expect, afterEach } from 'vitest';
import { buildLinkHeader } from '../../app/middleware/pagination.js';

function fakeReq({ originalUrl = '/v1/customer/bycompany/1', host = 'api.example.com', protocol = 'https' } = {}) {
Expand Down Expand Up @@ -73,4 +73,43 @@ describe('buildLinkHeader', () => {
const link = buildLinkHeader({ req: fakeReq(), limit: 30, offset: 0, count: 100 });
expect(link).toContain('offset=90'); // last page anchor
});

describe('PUBLIC_BASE_URL pins the Link header base', () => {
// Save + restore env so we don't leak into sibling tests.
const ORIG = process.env.PUBLIC_BASE_URL;
afterEach(() => {
if (ORIG === undefined) delete process.env.PUBLIC_BASE_URL;
else process.env.PUBLIC_BASE_URL = ORIG;
});

test('when set, builds Links against PUBLIC_BASE_URL (ignoring Host header)', () => {
process.env.PUBLIC_BASE_URL = 'https://node.timetrackerapi.com';
// Malicious Host header should be ignored entirely.
const link = buildLinkHeader({
req: fakeReq({ host: 'evil.example', protocol: 'http' }),
limit: 10, offset: 0, count: 50,
});
expect(link).toContain('https://node.timetrackerapi.com/v1/customer/bycompany/1');
expect(link).not.toContain('evil.example');
});

test('trailing slash on PUBLIC_BASE_URL is stripped to prevent `//path`', () => {
process.env.PUBLIC_BASE_URL = 'https://api.example.com//';
const link = buildLinkHeader({
req: fakeReq(),
limit: 10, offset: 0, count: 50,
});
expect(link).toContain('https://api.example.com/v1/customer/bycompany/1');
expect(link).not.toContain('//v1/customer');
});

test('empty / whitespace PUBLIC_BASE_URL falls back to req-based default', () => {
process.env.PUBLIC_BASE_URL = ' ';
const link = buildLinkHeader({
req: fakeReq({ host: 'api.example.com', protocol: 'https' }),
limit: 10, offset: 0, count: 50,
});
expect(link).toContain('https://api.example.com/v1/customer/bycompany/1');
});
});
});