Skip to content

Commit 2a4f460

Browse files
committed
Add custom middleware for to present an Angular-tailored message
Fix angular#32028
1 parent 588f3b8 commit 2a4f460

File tree

3 files changed

+139
-2
lines changed

3 files changed

+139
-2
lines changed

packages/angular/build/src/builders/dev-server/tests/options/allowed-hosts_spec.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,12 +27,13 @@ describeServeBuilder(executeDevServer, DEV_SERVER_BUILDER_INFO, (harness, setupT
2727
...BASE_OPTIONS,
2828
});
2929

30-
const { result, response } = await executeOnceAndGet(harness, '/', {
30+
const { result, response, content } = await executeOnceAndGet(harness, '/', {
3131
request: { headers: FETCH_HEADERS },
3232
});
3333

3434
expect(result?.success).toBeTrue();
3535
expect(response?.statusCode).toBe(403);
36+
expect(content).toContain('angular.json');
3637
});
3738

3839
it('does not allow an invalid host when option is an empty array', async () => {
@@ -41,12 +42,13 @@ describeServeBuilder(executeDevServer, DEV_SERVER_BUILDER_INFO, (harness, setupT
4142
allowedHosts: [],
4243
});
4344

44-
const { result, response } = await executeOnceAndGet(harness, '/', {
45+
const { result, response, content } = await executeOnceAndGet(harness, '/', {
4546
request: { headers: FETCH_HEADERS },
4647
});
4748

4849
expect(result?.success).toBeTrue();
4950
expect(response?.statusCode).toBe(403);
51+
expect(content).toContain('angular.json');
5052
});
5153

5254
it('allows a host when specified in the option', async () => {

packages/angular/build/src/builders/dev-server/vite/server.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -226,6 +226,8 @@ export async function setupServer(
226226
ssrMode,
227227
resetComponentUpdates: () => templateUpdates.clear(),
228228
projectRoot: serverOptions.projectRoot,
229+
allowedHosts: serverOptions.allowedHosts,
230+
devHost: serverOptions.host,
229231
}),
230232
createRemoveIdPrefixPlugin(externalMetadata.explicitBrowser),
231233
await createAngularSsrTransformPlugin(serverOptions.workspaceRoot),

packages/angular/build/src/tools/vite/plugins/setup-middlewares-plugin.ts

Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,57 @@ interface AngularSetupMiddlewaresPluginOptions {
5555
ssrMode: ServerSsrMode;
5656
resetComponentUpdates: () => void;
5757
projectRoot: string;
58+
allowedHosts: true | string[];
59+
devHost: string;
60+
}
61+
62+
function extractHostname(hostHeader: string | undefined): string {
63+
if (!hostHeader) {
64+
return '';
65+
}
66+
67+
// Remove port if present (e.g., example.com:4200)
68+
const idx = hostHeader.lastIndexOf(':');
69+
if (idx > -1 && hostHeader.indexOf(']') === -1) {
70+
// Skip IPv6 addresses that include ':' within brackets
71+
return hostHeader.slice(0, idx).toLowerCase();
72+
}
73+
74+
return hostHeader.toLowerCase();
75+
}
76+
77+
function html403(hostname: string): string {
78+
return `<!doctype html>
79+
<html>
80+
<head>
81+
<meta charset="utf-8" />
82+
<meta name="viewport" content="width=device-width, initial-scale=1" />
83+
<title>Blocked request</title>
84+
<style>
85+
body{font-family:system-ui,-apple-system,Segoe UI,Roboto,Ubuntu,Cantarell,Noto Sans,sans-serif;
86+
line-height:1.4;margin:2rem;color:#1f2937}
87+
code{background:#f3f4f6;padding:.15rem .35rem;border-radius:.25rem}
88+
.box{max-width:760px;margin:0 auto}
89+
h1{font-size:1.5rem;margin-bottom:.75rem}
90+
p{margin:.5rem 0}
91+
.muted{color:#6b7280}
92+
pre{background:#f9fafb;border:1px solid #e5e7eb;padding:.75rem;border-radius:.5rem;overflow:auto}
93+
</style>
94+
</head>
95+
<body>
96+
<main>
97+
<h1>Blocked request. This host ("${hostname}") is not allowed.</h1>
98+
<p>To allow this host, add it to <code>allowedHosts</code> under the <code>serve</code> target in <code>angular.json</code>.</p>
99+
<pre><code>{
100+
"serve": {
101+
"options": {
102+
"allowedHosts": ["${hostname}"]
103+
}
104+
}
105+
}</code></pre>
106+
</main>
107+
</body>
108+
</html>`;
58109
}
59110

60111
async function createEncapsulateStyle(): Promise<
@@ -109,6 +160,88 @@ export function createAngularSetupMiddlewaresPlugin(
109160
// before the built-in HTML middleware
110161
// eslint-disable-next-line @typescript-eslint/no-misused-promises
111162
return async () => {
163+
type MiddlewareStackEntry = {
164+
route?: string;
165+
handle?: (Connect.NextHandleFunction & { name?: string }) | undefined;
166+
};
167+
168+
// Vite/Connect do not expose a typed stack, cast once to a precise structural type.
169+
const mws = server.middlewares as unknown as { stack: MiddlewareStackEntry[] };
170+
const middlewareStack = mws.stack;
171+
172+
// Helper to locate Vite's host validation middleware safely
173+
const findHostValidationEntry = (
174+
stack: readonly MiddlewareStackEntry[],
175+
): MiddlewareStackEntry | undefined =>
176+
stack.find(
177+
({ handle }) =>
178+
typeof handle === 'function' &&
179+
typeof handle.name === 'string' &&
180+
handle.name.startsWith('hostValidationMiddleware'),
181+
);
182+
183+
const entry = findHostValidationEntry(middlewareStack);
184+
185+
if (entry && typeof entry.handle === 'function') {
186+
const originalHandle = entry.handle as Connect.NextHandleFunction;
187+
188+
entry.handle = function angularHostValidationMiddleware(req, res, next) {
189+
const originalWriteHead = res.writeHead.bind(res);
190+
const originalEnd = res.end.bind(res);
191+
const originalWrite = res.write.bind(res);
192+
193+
let blocked = false;
194+
let capturedStatus: number | undefined;
195+
196+
// Intercept writeHead: detect block and delay header sending
197+
res.writeHead = function (...args: Parameters<typeof res.writeHead>) {
198+
const statusCode = args[0];
199+
capturedStatus = statusCode;
200+
if (typeof statusCode === 'number' && statusCode >= 400) {
201+
blocked = true;
202+
203+
return res;
204+
}
205+
206+
return originalWriteHead.apply(res, args);
207+
} as typeof res.writeHead;
208+
209+
// Intercept write to avoid implicitly sending headers/body when blocked
210+
res.write = function (
211+
...args: Parameters<typeof res.write>
212+
): ReturnType<typeof res.write> {
213+
if (blocked) {
214+
// Swallow writes to prevent Node from implicitly sending headers
215+
return true as ReturnType<typeof res.write>;
216+
}
217+
218+
return originalWrite.apply(res, args);
219+
} as typeof res.write;
220+
221+
res.end = function (...args: Parameters<typeof res.end>): ReturnType<typeof res.end> {
222+
if (blocked) {
223+
const hostname = extractHostname(req.headers.host);
224+
const body = html403(hostname);
225+
const status = capturedStatus && capturedStatus >= 400 ? capturedStatus : 403;
226+
227+
// Headers were not sent yet because we intercepted writeHead for blocked case
228+
if (!res.headersSent) {
229+
const headers: import('node:http').OutgoingHttpHeaders = {
230+
'Content-Type': 'text/html; charset=utf-8',
231+
};
232+
originalWriteHead(status, headers);
233+
}
234+
235+
return originalEnd(body);
236+
}
237+
238+
return originalEnd.apply(res, args);
239+
} as typeof res.end;
240+
241+
return originalHandle(req, res, next);
242+
};
243+
}
244+
112245
if (ssrMode === ServerSsrMode.ExternalSsrMiddleware) {
113246
server.middlewares.use(
114247
await createAngularSsrExternalMiddleware(server, indexHtmlTransformer),

0 commit comments

Comments
 (0)