@@ -11,7 +11,7 @@ import { Hooks } from './hooks';
1111import { getPotentialLocaleIdFromUrl , getPreferredLocale } from './i18n' ;
1212import { EntryPointExports , getAngularAppEngineManifest } from './manifest' ;
1313import { joinUrlParts } from './utils/url' ;
14- import { secureRequest , validateRequest } from './utils/validation' ;
14+ import { cloneRequestWithPatchedHeaders , validateRequest } from './utils/validation' ;
1515
1616/**
1717 * Options for the Angular server application engine.
@@ -78,15 +78,7 @@ export class AngularAppEngine {
7878 * @param options Options for the Angular server application engine.
7979 */
8080 constructor ( options ?: AngularAppEngineOptions ) {
81- const allowedHosts = new Set ( this . manifest . allowedHosts ) ;
82-
83- if ( options ?. allowedHosts ) {
84- for ( const host of options . allowedHosts ) {
85- allowedHosts . add ( host ) ;
86- }
87- }
88-
89- this . allowedHosts = allowedHosts ;
81+ this . allowedHosts = new Set ( [ ...( options ?. allowedHosts ?? [ ] ) , ...this . manifest . allowedHosts ] ) ;
9082 }
9183
9284 /**
@@ -100,44 +92,37 @@ export class AngularAppEngine {
10092 * @remarks A request to `https://www.example.com/page/index.html` will serve or render the Angular route
10193 * corresponding to `https://www.example.com/page`.
10294 *
103- * @remarks
104- * To prevent potential Server-Side Request Forgery (SSRF), this function verifies the hostname
105- * of the `request.url` against a list of authorized hosts.
106- * If the hostname is not recognized, a Client-Side Rendered (CSR) version of the page is returned.
107-
108- * Resolution:
109- * Authorize your hostname by configuring `allowedHosts` in `angular.json` in:
110- * `projects.[project-name].architect.build.options.security.allowedHosts`.
111- * Alternatively, you pass it directly through the configuration options of `AngularAppEngine`.
112- *
113- * For more information see: https://angular.dev/best-practices/security#preventing-server-side-request-forgery-ssrf
114- */
95+ * @remarks
96+ * To prevent potential Server-Side Request Forgery (SSRF), this function verifies the hostname
97+ * of the `request.url` against a list of authorized hosts.
98+ * If the hostname is not recognized and `allowedHosts` is not empty , a Client-Side Rendered (CSR) version of the
99+ * page is returned otherwise a 400 Bad Request is returned.
100+ * Resolution:
101+ * Authorize your hostname by configuring `allowedHosts` in `angular.json` in:
102+ * `projects.[project-name].architect.build.options.security.allowedHosts`.
103+ * Alternatively, you pass it directly through the configuration options of `AngularAppEngine`.
104+ *
105+ * For more information see: https://angular.dev/best-practices/security#preventing-server-side-request-forgery-ssrf
106+ */
115107 async handle ( request : Request , requestContext ?: unknown ) : Promise < Response | null > {
116108 const allowedHost = this . allowedHosts ;
117- request = secureRequest ( request , this . allowedHosts ) ;
118109
119110 try {
120111 validateRequest ( request , allowedHost ) ;
121112 } catch ( error ) {
122- const msg = error instanceof Error ? error . message : error ;
123-
124- // eslint-disable-next-line no-console
125- console . error (
126- `ERROR: Bad Request ("${ request . url } ").\n` +
127- msg +
128- '\nFallbacking to client side rendering. This will become a 400 Bad Request in a future major version.' ,
129- ) ;
130-
131- // Fallback to CSR to avoid a breaking change.
132- // TODO(alanagius): Return a 400 and remove this fallback in the next major version (v22).
133- const serverApp = await this . getAngularServerAppForRequest ( request ) ;
134-
135- return serverApp ?. serveClientSidePage ( ) ?? null ;
113+ return this . handleValidationError ( error as Error , request ) ;
136114 }
137115
138- const serverApp = await this . getAngularServerAppForRequest ( request ) ;
116+ // Clone request with patched headers to prevent unallowed host header access.
117+ const { request : securedRequest , onError : onHeaderValidationError } =
118+ cloneRequestWithPatchedHeaders ( request , allowedHost ) ;
119+
120+ const serverApp = await this . getAngularServerAppForRequest ( securedRequest ) ;
139121 if ( serverApp ) {
140- return serverApp . handle ( request , requestContext ) ;
122+ return Promise . race ( [
123+ onHeaderValidationError . then ( ( error ) => this . handleValidationError ( error , securedRequest ) ) ,
124+ serverApp . handle ( securedRequest , requestContext ) ,
125+ ] ) ;
141126 }
142127
143128 if ( this . supportedLocales . length > 1 ) {
@@ -266,4 +251,42 @@ export class AngularAppEngine {
266251
267252 return this . getEntryPointExports ( potentialLocale ) ?? this . getEntryPointExports ( '' ) ;
268253 }
254+
255+ /**
256+ * Handles validation errors by logging the error and returning an appropriate response.
257+ *
258+ * @param error - The validation error to handle.
259+ * @param request - The HTTP request that caused the validation error.
260+ * @returns A promise that resolves to a `Response` object with a 400 status code if allowed hosts are configured,
261+ * or `null` if allowed hosts are not configured (in which case the request is served client-side).
262+ */
263+ private async handleValidationError ( error : Error , request : Request ) : Promise < Response | null > {
264+ const isAllowedHostConfigured = this . allowedHosts . size > 0 ;
265+ const errorMessage = error . message ;
266+
267+ // eslint-disable-next-line no-console
268+ console . error (
269+ `ERROR: Bad Request ("${ request . url } ").\n` +
270+ errorMessage +
271+ ( isAllowedHostConfigured
272+ ? ''
273+ : '\nFallbacking to client side rendering. This will become a 400 Bad Request in a future major version.' ) +
274+ '\n\nFor more information, see https://angular.dev/best-practices/security#preventing-server-side-request-forgery-ssrf' ,
275+ ) ;
276+
277+ if ( isAllowedHostConfigured ) {
278+ // Allowed hosts has been configured incorrectly, thus we can return a 400 bad request.
279+ return new Response ( errorMessage , {
280+ status : 400 ,
281+ statusText : 'Bad Request' ,
282+ headers : { 'Content-Type' : 'text/plain' } ,
283+ } ) ;
284+ }
285+
286+ // Fallback to CSR to avoid a breaking change.
287+ // TODO(alanagius): Return a 400 and remove this fallback in the next major version (v22).
288+ const serverApp = await this . getAngularServerAppForRequest ( request ) ;
289+
290+ return serverApp ?. serveClientSidePage ( ) ?? null ;
291+ }
269292}
0 commit comments