mirror of https://github.com/ghostfolio/ghostfolio
committed by
GitHub
7 changed files with 188 additions and 38 deletions
@ -0,0 +1,104 @@ |
|||
import helmet from 'helmet'; |
|||
import { IncomingMessage, ServerResponse } from 'node:http'; |
|||
|
|||
import { getHelmetOptions } from './security-headers.helper'; |
|||
|
|||
/** |
|||
* Gives the headers which the helmet middleware sets on a response with the |
|||
* options of the helper, and the function which it calls as next middleware. |
|||
*/ |
|||
function getHeaders({ |
|||
isSubscriptionEnabled |
|||
}: { |
|||
isSubscriptionEnabled: boolean; |
|||
}) { |
|||
const headers = new Map<string, string>(); |
|||
const next = jest.fn(); |
|||
|
|||
const response = { |
|||
removeHeader: (name: string) => { |
|||
headers.delete(name.toLowerCase()); |
|||
}, |
|||
setHeader: (name: string, value: string) => { |
|||
headers.set(name.toLowerCase(), value); |
|||
} |
|||
}; |
|||
|
|||
helmet(getHelmetOptions({ isSubscriptionEnabled }))( |
|||
{} as IncomingMessage, |
|||
response as unknown as ServerResponse, |
|||
next |
|||
); |
|||
|
|||
return { headers, next }; |
|||
} |
|||
|
|||
describe('getHelmetOptions', () => { |
|||
describe('without the subscription', () => { |
|||
let headers: Map<string, string>; |
|||
let next: jest.Mock; |
|||
|
|||
beforeAll(() => { |
|||
({ headers, next } = getHeaders({ isSubscriptionEnabled: false })); |
|||
}); |
|||
|
|||
it('should call the next middleware without an error', () => { |
|||
expect(next).toHaveBeenCalledWith(); |
|||
}); |
|||
|
|||
it('should set the Content-Security-Policy header', () => { |
|||
expect(headers.get('content-security-policy')).toContain( |
|||
"default-src 'self'" |
|||
); |
|||
}); |
|||
|
|||
it('should not upgrade insecure requests', () => { |
|||
expect(headers.get('content-security-policy')).not.toContain( |
|||
'upgrade-insecure-requests' |
|||
); |
|||
}); |
|||
|
|||
it('should not set the Strict-Transport-Security header', () => { |
|||
expect(headers.has('strict-transport-security')).toBe(false); |
|||
}); |
|||
|
|||
it('should not set the Cross-Origin-Opener-Policy header', () => { |
|||
expect(headers.has('cross-origin-opener-policy')).toBe(false); |
|||
}); |
|||
|
|||
it('should not allow resources of Stripe', () => { |
|||
expect(headers.get('content-security-policy')).not.toContain( |
|||
'https://js.stripe.com' |
|||
); |
|||
}); |
|||
}); |
|||
|
|||
describe('with the subscription', () => { |
|||
let headers: Map<string, string>; |
|||
let next: jest.Mock; |
|||
|
|||
beforeAll(() => { |
|||
({ headers, next } = getHeaders({ isSubscriptionEnabled: true })); |
|||
}); |
|||
|
|||
it('should call the next middleware without an error', () => { |
|||
expect(next).toHaveBeenCalledWith(); |
|||
}); |
|||
|
|||
it('should upgrade insecure requests', () => { |
|||
expect(headers.get('content-security-policy')).toContain( |
|||
'upgrade-insecure-requests' |
|||
); |
|||
}); |
|||
|
|||
it('should set the Strict-Transport-Security header', () => { |
|||
expect(headers.has('strict-transport-security')).toBe(true); |
|||
}); |
|||
|
|||
it('should allow resources of Stripe', () => { |
|||
expect(headers.get('content-security-policy')).toContain( |
|||
'https://js.stripe.com' |
|||
); |
|||
}); |
|||
}); |
|||
}); |
|||
@ -0,0 +1,37 @@ |
|||
import { HelmetOptions } from 'helmet'; |
|||
|
|||
export function getHelmetOptions({ |
|||
isSubscriptionEnabled |
|||
}: { |
|||
isSubscriptionEnabled: boolean; |
|||
}): HelmetOptions { |
|||
if (isSubscriptionEnabled) { |
|||
return { |
|||
contentSecurityPolicy: { |
|||
directives: { |
|||
connectSrc: ["'self'", 'https://js.stripe.com'], // Allow connections to Stripe
|
|||
frameSrc: ["'self'", 'https://js.stripe.com'], // Allow loading frames from Stripe
|
|||
scriptSrc: ["'self'", "'unsafe-inline'", 'https://js.stripe.com'], // Allow inline scripts and scripts from Stripe
|
|||
scriptSrcAttr: ["'self'", "'unsafe-inline'"], // Allow inline event handlers
|
|||
styleSrc: ["'self'", "'unsafe-inline'"] // Allow inline styles
|
|||
} |
|||
}, |
|||
crossOriginOpenerPolicy: false // Disable Cross-Origin-Opener-Policy header (for Internet Identity)
|
|||
}; |
|||
} |
|||
|
|||
// The self-hosted setup can run via HTTP, hence the headers which need HTTPS
|
|||
// are disabled (see https://github.com/ghostfolio/ghostfolio/issues/2102)
|
|||
return { |
|||
contentSecurityPolicy: { |
|||
directives: { |
|||
scriptSrc: ["'self'", "'unsafe-inline'"], // Allow inline scripts
|
|||
scriptSrcAttr: ["'self'", "'unsafe-inline'"], // Allow inline event handlers
|
|||
styleSrc: ["'self'", "'unsafe-inline'"], // Allow inline styles
|
|||
upgradeInsecureRequests: null // Disable upgrade-insecure-requests directive (the browser changes each request to HTTPS, which gives a blank page via HTTP)
|
|||
} |
|||
}, |
|||
crossOriginOpenerPolicy: false, // Disable Cross-Origin-Opener-Policy header (the browser ignores it via HTTP)
|
|||
strictTransportSecurity: false // Disable Strict-Transport-Security header (the reverse proxy sets it, if HTTPS is required)
|
|||
}; |
|||
} |
|||
Loading…
Reference in new issue