This commit is contained in:
@@ -0,0 +1,58 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
applySecurityHeaders,
|
||||
securityHeadersMiddleware,
|
||||
HSTS_MAX_AGE_SECONDS,
|
||||
} from './security-headers.js';
|
||||
import { type Request, type Response } from 'express';
|
||||
|
||||
function fakeRes() {
|
||||
const headers: Record<string, string> = {};
|
||||
const res = {
|
||||
setHeader(name: string, value: string) {
|
||||
headers[name] = value;
|
||||
},
|
||||
} as unknown as Response;
|
||||
return { res, headers };
|
||||
}
|
||||
|
||||
describe('applySecurityHeaders', () => {
|
||||
it('sets the baseline hardening headers on every response', () => {
|
||||
const { res, headers } = fakeRes();
|
||||
applySecurityHeaders({ secure: false } as Request, res);
|
||||
expect(headers['X-Content-Type-Options']).toBe('nosniff');
|
||||
expect(headers['X-Frame-Options']).toBe('SAMEORIGIN');
|
||||
expect(headers['Referrer-Policy']).toBe('strict-origin-when-cross-origin');
|
||||
});
|
||||
|
||||
it('keeps X-Frame-Options at SAMEORIGIN so the app can serve its own iframes', () => {
|
||||
// PDF preview and noVNC browser sessions embed same-origin iframes; DENY
|
||||
// would break them.
|
||||
const { res, headers } = fakeRes();
|
||||
applySecurityHeaders({ secure: true } as Request, res);
|
||||
expect(headers['X-Frame-Options']).not.toBe('DENY');
|
||||
expect(headers['X-Frame-Options']).toBe('SAMEORIGIN');
|
||||
});
|
||||
|
||||
it('emits HSTS only when the response is served over TLS', () => {
|
||||
const plain = fakeRes();
|
||||
applySecurityHeaders({ secure: false } as Request, plain.res);
|
||||
expect(plain.headers['Strict-Transport-Security']).toBeUndefined();
|
||||
|
||||
const tls = fakeRes();
|
||||
applySecurityHeaders({ secure: true } as Request, tls.res);
|
||||
expect(tls.headers['Strict-Transport-Security']).toBe(
|
||||
`max-age=${HSTS_MAX_AGE_SECONDS}; includeSubDomains`,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('securityHeadersMiddleware', () => {
|
||||
it('applies the headers and calls next()', () => {
|
||||
const { res, headers } = fakeRes();
|
||||
const next = vi.fn();
|
||||
securityHeadersMiddleware()({ secure: false } as Request, res, next);
|
||||
expect(headers['X-Frame-Options']).toBe('SAMEORIGIN');
|
||||
expect(next).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,45 @@
|
||||
import { type Request, type Response, type NextFunction } from 'express';
|
||||
|
||||
/**
|
||||
* Baseline security response headers applied to every response.
|
||||
*
|
||||
* These are deliberately conservative so they cannot break the SPA, which
|
||||
* serves same-origin `<iframe>`s (PDF preview, noVNC browser sessions) and
|
||||
* follows OAuth via top-level redirects rather than popups:
|
||||
*
|
||||
* - `X-Content-Type-Options: nosniff` — stop MIME-sniffing a response into
|
||||
* something executable. (Untrusted workspace files layer `Content-Security-Policy:
|
||||
* sandbox` on top via setUntrustedFileResponseHeaders.)
|
||||
* - `X-Frame-Options: SAMEORIGIN` — block clickjacking from foreign origins
|
||||
* while still allowing the app's own same-origin iframes.
|
||||
* - `Referrer-Policy: strict-origin-when-cross-origin` — never leak the full
|
||||
* path (which can carry task ids) to third-party origins.
|
||||
* - `Strict-Transport-Security` — only when the response is served over TLS
|
||||
* (native HTTPS, or a trusted proxy reporting `X-Forwarded-Proto: https`).
|
||||
* Never emitted on plain-HTTP localhost dev so it can't pin a dev browser.
|
||||
*
|
||||
* Note: `req.secure` reflects `X-Forwarded-Proto` only when Express trusts the
|
||||
* proxy. The server enables `trust proxy` together with `auth.secure_cookie`, so
|
||||
* an operator terminating TLS at a reverse proxy should set `secure_cookie: true`
|
||||
* to also get HSTS. Native HTTPS sets `req.secure` intrinsically and is unaffected.
|
||||
*/
|
||||
export const HSTS_MAX_AGE_SECONDS = 15552000; // 180 days
|
||||
|
||||
export function applySecurityHeaders(req: Request, res: Response): void {
|
||||
res.setHeader('X-Content-Type-Options', 'nosniff');
|
||||
res.setHeader('X-Frame-Options', 'SAMEORIGIN');
|
||||
res.setHeader('Referrer-Policy', 'strict-origin-when-cross-origin');
|
||||
if (req.secure) {
|
||||
res.setHeader(
|
||||
'Strict-Transport-Security',
|
||||
`max-age=${HSTS_MAX_AGE_SECONDS}; includeSubDomains`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export function securityHeadersMiddleware() {
|
||||
return (req: Request, res: Response, next: NextFunction): void => {
|
||||
applySecurityHeaders(req, res);
|
||||
next();
|
||||
};
|
||||
}
|
||||
@@ -29,6 +29,7 @@ import { mountAdminApi } from './admin-api.js';
|
||||
import { createAdminGatewayApi } from './admin-gateway-api.js';
|
||||
import { mountUsersApi } from './users-api.js';
|
||||
import { mountShareApi } from './share-api.js';
|
||||
import { securityHeadersMiddleware } from './security-headers.js';
|
||||
import { mountLocalTasksApi } from './local-tasks-api.js';
|
||||
import { findPieceFile } from './pieces-api.js';
|
||||
import { mountLocalFilesApi } from './local-files-api.js';
|
||||
@@ -191,15 +192,11 @@ export function createCoreServer(opts: CoreServerOptions): {
|
||||
// Don't advertise the framework/version (minor info-leak, free to drop).
|
||||
app.disable('x-powered-by');
|
||||
|
||||
// Baseline hardening header on every response. `nosniff` stops the browser
|
||||
// from MIME-sniffing a response into something executable. File-serving
|
||||
// endpoints add `Content-Security-Policy: sandbox` on top
|
||||
// (see setUntrustedFileResponseHeaders) to neutralize stored XSS from
|
||||
// agent/user-authored workspace files.
|
||||
app.use((_req, res, next) => {
|
||||
res.setHeader('X-Content-Type-Options', 'nosniff');
|
||||
next();
|
||||
});
|
||||
// Baseline hardening headers on every response (nosniff, X-Frame-Options,
|
||||
// Referrer-Policy, and HSTS over TLS). File-serving endpoints add
|
||||
// `Content-Security-Policy: sandbox` on top (see setUntrustedFileResponseHeaders)
|
||||
// to neutralize stored XSS from agent/user-authored workspace files.
|
||||
app.use(securityHeadersMiddleware());
|
||||
|
||||
// リバースプロキシ背後で secure cookie / X-Forwarded-Proto を正しく処理
|
||||
if (opts.authConfig?.secureCookie) {
|
||||
|
||||
Reference in New Issue
Block a user