diff --git a/Cargo.lock b/Cargo.lock
index 0fc1f21..e70b989 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -730,7 +730,7 @@ dependencies = [
[[package]]
name = "ngc-bundler"
-version = "0.10.8"
+version = "0.10.12"
dependencies = [
"dashmap",
"ngc-diagnostics",
@@ -755,7 +755,7 @@ dependencies = [
[[package]]
name = "ngc-dev-server"
-version = "0.10.8"
+version = "0.10.12"
dependencies = [
"ngc-diagnostics",
"serde_json",
@@ -766,7 +766,7 @@ dependencies = [
[[package]]
name = "ngc-diagnostics"
-version = "0.10.8"
+version = "0.10.12"
dependencies = [
"serde_json",
"thiserror",
@@ -774,7 +774,7 @@ dependencies = [
[[package]]
name = "ngc-linker"
-version = "0.10.8"
+version = "0.10.12"
dependencies = [
"dashmap",
"insta",
@@ -792,7 +792,7 @@ dependencies = [
[[package]]
name = "ngc-npm-resolver"
-version = "0.10.8"
+version = "0.10.12"
dependencies = [
"dashmap",
"ngc-diagnostics",
@@ -807,7 +807,7 @@ dependencies = [
[[package]]
name = "ngc-project-resolver"
-version = "0.10.8"
+version = "0.10.12"
dependencies = [
"dashmap",
"glob",
@@ -823,7 +823,7 @@ dependencies = [
[[package]]
name = "ngc-rs"
-version = "0.10.8"
+version = "0.10.12"
dependencies = [
"base64",
"clap",
@@ -857,7 +857,7 @@ dependencies = [
[[package]]
name = "ngc-template-compiler"
-version = "0.10.8"
+version = "0.10.12"
dependencies = [
"insta",
"ngc-diagnostics",
@@ -879,7 +879,7 @@ dependencies = [
[[package]]
name = "ngc-ts-transform"
-version = "0.10.8"
+version = "0.10.12"
dependencies = [
"ngc-diagnostics",
"oxc_allocator",
@@ -898,7 +898,7 @@ dependencies = [
[[package]]
name = "ngc-watch"
-version = "0.10.8"
+version = "0.10.12"
dependencies = [
"ngc-diagnostics",
"notify",
diff --git a/Cargo.toml b/Cargo.toml
index c7006c5..3408cfc 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -3,7 +3,7 @@ resolver = "2"
members = ["crates/cli", "crates/diagnostics", "crates/project-resolver", "crates/ts-transform", "crates/bundler", "crates/template-compiler", "crates/npm-resolver", "crates/linker", "crates/watch", "crates/dev-server"]
[workspace.package]
-version = "0.10.8"
+version = "0.10.12"
edition = "2021"
license = "MIT OR Apache-2.0"
authors = ["lukekania"]
diff --git a/crates/cli/src/main.rs b/crates/cli/src/main.rs
index d497255..2dc755a 100644
--- a/crates/cli/src/main.rs
+++ b/crates/cli/src/main.rs
@@ -235,6 +235,16 @@ enum Commands {
/// to use the same value as its ``.
#[arg(long = "serve-path")]
serve_path: Option,
+ /// Comma-separated list of host names the dev server's
+ /// `Host:`-header check accepts. Loopback hosts (`localhost`,
+ /// `127.0.0.1`, `[::1]`) are always allowed. Pass `all` to
+ /// disable the check entirely, or `auto` to additionally accept
+ /// the configured bind host. Use this when fronting the dev
+ /// server with a tunneling proxy (ngrok, Cloudflare Tunnel,
+ /// GitHub Codespaces) or a non-default local hostname
+ /// (`*.localhost`, `app.local`).
+ #[arg(long = "allowed-hosts", value_delimiter = ',', num_args = 0..)]
+ allowed_hosts: Vec,
},
/// Extract translatable messages from every component template in the
/// project and emit a translation file (XLIFF 2.0 by default; XLIFF 1.2
@@ -335,6 +345,7 @@ fn main() {
host,
open,
serve_path,
+ allowed_hosts,
} => {
if let Err(e) = serve_cmd::run(
&project,
@@ -343,6 +354,7 @@ fn main() {
port,
open,
serve_path.as_deref(),
+ &allowed_hosts,
) {
eprintln!("{} {e}", "Error:".red().bold());
process::exit(1);
diff --git a/crates/cli/src/serve_cmd.rs b/crates/cli/src/serve_cmd.rs
index aad8e89..80a1d0f 100644
--- a/crates/cli/src/serve_cmd.rs
+++ b/crates/cli/src/serve_cmd.rs
@@ -34,6 +34,7 @@ pub fn run(
port: u16,
open: bool,
serve_path: Option<&str>,
+ allowed_hosts: &[String],
) -> NgcResult<()> {
run_with_stop(
project,
@@ -42,6 +43,7 @@ pub fn run(
port,
open,
serve_path,
+ allowed_hosts,
install_ctrlc,
)
}
@@ -50,6 +52,7 @@ pub fn run(
/// armed. Tests use a no-op installer so the watcher loop can be exited via
/// the returned [`Arc`] without touching the real signal
/// machinery (which would interfere with `cargo test`'s own handlers).
+#[allow(clippy::too_many_arguments)]
pub(crate) fn run_with_stop(
project: &Path,
configuration: Option<&str>,
@@ -57,6 +60,7 @@ pub(crate) fn run_with_stop(
port: u16,
open: bool,
serve_path: Option<&str>,
+ allowed_hosts: &[String],
install_stop: impl FnOnce(Arc),
) -> NgcResult<()> {
let out_dir = crate::resolve_out_dir(project, None, configuration)?;
@@ -90,7 +94,8 @@ pub(crate) fn run_with_stop(
let cfg = DevServerConfig::new(&out_dir)
.with_host(host.to_string())
.with_port(port)
- .with_serve_path(normalized_serve_path.as_deref());
+ .with_serve_path(normalized_serve_path.as_deref())
+ .with_allowed_hosts(allowed_hosts.iter().cloned());
let server = DevServer::start(cfg, event_rx)?;
let url = match server.serve_path() {
Some(prefix) => format!("http://{}{}", server.addr(), prefix),
diff --git a/crates/cli/tests/serve_integration.rs b/crates/cli/tests/serve_integration.rs
index 56a2669..1bf10ef 100644
--- a/crates/cli/tests/serve_integration.rs
+++ b/crates/cli/tests/serve_integration.rs
@@ -83,6 +83,7 @@ fn serve_help_lists_all_flags() {
"--host",
"--open",
"--serve-path",
+ "--allowed-hosts",
] {
assert!(
stdout.contains(flag),
diff --git a/crates/dev-server/src/lib.rs b/crates/dev-server/src/lib.rs
index 800ff73..de1c226 100644
--- a/crates/dev-server/src/lib.rs
+++ b/crates/dev-server/src/lib.rs
@@ -93,6 +93,9 @@ pub struct DevServerConfig {
/// server mounts at `/`. Mirrors `@angular/build:dev-server`'s
/// `servePath` option for subpath deploys.
pub serve_path: Option,
+ /// User-supplied `allowedHosts` patterns. Empty (= default) means
+ /// `auto`: loopback hosts plus the bind host. See [`AllowedHosts`].
+ pub allowed_hosts: Vec,
}
impl DevServerConfig {
@@ -104,6 +107,7 @@ impl DevServerConfig {
host: "127.0.0.1".to_string(),
port: 4200,
serve_path: None,
+ allowed_hosts: Vec::new(),
}
}
@@ -126,6 +130,17 @@ impl DevServerConfig {
self.serve_path = serve_path.and_then(normalize_serve_path);
self
}
+
+ /// Replace the `allowedHosts` patterns the dev server's Host-header
+ /// check accepts. See [`AllowedHosts`] for the matching semantics.
+ pub fn with_allowed_hosts(mut self, hosts: I) -> Self
+ where
+ I: IntoIterator- ,
+ S: Into,
+ {
+ self.allowed_hosts = hosts.into_iter().map(Into::into).collect();
+ self
+ }
}
/// Normalize a `servePath` string to the canonical `/foo/` form.
@@ -154,6 +169,140 @@ pub fn normalize_serve_path(raw: &str) -> Option {
}
}
+/// Decides whether an incoming HTTP request's `Host:` header is permitted.
+///
+/// Mirrors `@angular/build:dev-server`'s `allowedHosts` option (which in
+/// turn matches Vite's `server.allowedHosts`). Loopback hosts
+/// (`localhost`, `127.0.0.1`, `[::1]`) are always accepted regardless of
+/// configuration — local development must always work. On top of that:
+///
+/// * The literal pattern `"all"` disables the check entirely.
+/// * The literal pattern `"auto"` (or an empty configuration) additionally
+/// accepts the bind host, so a server bound to `192.168.1.10` accepts
+/// `Host: 192.168.1.10` without further configuration.
+/// * Anything else is an exact, case-insensitive hostname match. The
+/// port portion of the `Host:` header is stripped before comparison.
+#[derive(Debug, Clone)]
+pub struct AllowedHosts {
+ accept_all: bool,
+ explicit: Vec,
+ bind_host: Option,
+}
+
+impl AllowedHosts {
+ /// Resolve the user-supplied `allowedHosts` patterns against the
+ /// `bind_host` the dev server is listening on.
+ ///
+ /// Empty input is treated as `"auto"` so callers that never opt in
+ /// still get the historical "loopback + bind host" behavior.
+ pub fn resolve(patterns: &[String], bind_host: &str) -> Self {
+ let mut accept_all = false;
+ let mut auto = false;
+ let mut explicit: Vec = Vec::new();
+ let mut any = false;
+ for p in patterns {
+ any = true;
+ let trimmed = p.trim();
+ if trimmed.is_empty() {
+ continue;
+ }
+ let lower = trimmed.to_ascii_lowercase();
+ match lower.as_str() {
+ "all" => accept_all = true,
+ "auto" => auto = true,
+ _ => explicit.push(lower),
+ }
+ }
+ // Default (no patterns supplied) == "auto" — accept the bind host
+ // on top of the loopback defaults so projects that bind to a LAN
+ // IP still respond to that IP without explicit allow-listing.
+ if !any {
+ auto = true;
+ }
+ let bind_host = if auto {
+ normalized_bind_host(bind_host)
+ } else {
+ None
+ };
+ Self {
+ accept_all,
+ explicit,
+ bind_host,
+ }
+ }
+
+ /// Returns `true` when the dev server is configured to accept every
+ /// `Host:` header (i.e. the user passed `"all"`).
+ pub fn accepts_all(&self) -> bool {
+ self.accept_all
+ }
+
+ /// Decide whether a request bearing this `Host:` header value should
+ /// be served. A missing or empty header counts as a mismatch.
+ pub fn is_allowed(&self, host_header: &str) -> bool {
+ if self.accept_all {
+ return true;
+ }
+ let stripped = strip_port(host_header.trim());
+ if stripped.is_empty() {
+ return false;
+ }
+ let host = stripped.to_ascii_lowercase();
+ if is_loopback_host(&host) {
+ return true;
+ }
+ if let Some(bh) = &self.bind_host {
+ if &host == bh {
+ return true;
+ }
+ }
+ self.explicit.iter().any(|p| p == &host)
+ }
+}
+
+/// Lowercase + lookup-normalize a `bind_host` for use in `AllowedHosts`.
+///
+/// Returns `None` when the bind host is a wildcard (`0.0.0.0`, `::`, `[::]`)
+/// or a loopback alias — there's nothing useful to add beyond the
+/// loopback defaults the allowlist already accepts.
+fn normalized_bind_host(bind_host: &str) -> Option {
+ let host = bind_host.trim().to_ascii_lowercase();
+ if host.is_empty()
+ || matches!(host.as_str(), "0.0.0.0" | "::" | "[::]")
+ || is_loopback_host(&host)
+ {
+ return None;
+ }
+ Some(host)
+}
+
+fn is_loopback_host(host: &str) -> bool {
+ matches!(host, "localhost" | "127.0.0.1" | "[::1]" | "::1")
+}
+
+/// Strip the port from a `Host:` header value, leaving the hostname
+/// (or IP literal) intact.
+///
+/// Handles three shapes:
+/// * `host` → `host`
+/// * `host:port` → `host`
+/// * `[v6]:port` → `[v6]` (brackets preserved so the value can be
+/// compared against the canonical IPv6 loopback literal `[::1]`)
+fn strip_port(host: &str) -> &str {
+ if let Some(rest) = host.strip_prefix('[') {
+ if let Some(end_rel) = rest.find(']') {
+ // end_rel is the position of `]` within `rest`; +2 accounts
+ // for the opening `[` we stripped and the `]` itself.
+ return &host[..end_rel + 2];
+ }
+ return host;
+ }
+ match host.rfind(':') {
+ Some(i) => &host[..i],
+ None => host,
+ }
+}
+
/// Handle to a running dev server.
///
/// Dropping the handle stops the server and closes any open SSE connections.
@@ -211,9 +360,19 @@ impl DevServer {
let request_clients = Arc::clone(&clients);
let serve_path = config.serve_path.clone();
let serve_path_for_loop = serve_path.clone();
+ let allowed_hosts = Arc::new(AllowedHosts::resolve(&config.allowed_hosts, &config.host));
+ let allowed_hosts_for_loop = Arc::clone(&allowed_hosts);
let join = thread::Builder::new()
.name("ngc-dev-server-accept".into())
- .spawn(move || serve_loop(request_server, root, request_clients, serve_path_for_loop))
+ .spawn(move || {
+ serve_loop(
+ request_server,
+ root,
+ request_clients,
+ serve_path_for_loop,
+ allowed_hosts_for_loop,
+ )
+ })
.map_err(|e| NgcError::ServeError {
message: format!("could not spawn accept thread: {e}"),
})?;
@@ -341,13 +500,22 @@ pub fn sse_frame(event: &DevServerEvent) -> String {
}
}
-fn serve_loop(server: Arc, root: PathBuf, clients: SseClients, serve_path: Option) {
+fn serve_loop(
+ server: Arc,
+ root: PathBuf,
+ clients: SseClients,
+ serve_path: Option,
+ allowed_hosts: Arc,
+) {
for request in server.incoming_requests() {
let root = root.clone();
let clients = Arc::clone(&clients);
let serve_path = serve_path.clone();
+ let allowed_hosts = Arc::clone(&allowed_hosts);
thread::spawn(move || {
- if let Err(e) = handle_request(request, &root, &clients, serve_path.as_deref()) {
+ if let Err(e) =
+ handle_request(request, &root, &clients, serve_path.as_deref(), &allowed_hosts)
+ {
tracing::warn!(error = %e, "dev server request failed");
}
});
@@ -359,12 +527,18 @@ fn handle_request(
root: &Path,
clients: &SseClients,
serve_path: Option<&str>,
+ allowed_hosts: &AllowedHosts,
) -> NgcResult<()> {
if !matches!(request.method(), Method::Get | Method::Head) {
let resp = Response::from_string("method not allowed").with_status_code(StatusCode(405));
return request.respond(resp).map_err(io_err);
}
+ let host_header = host_header_value(&request);
+ if !allowed_hosts.is_allowed(&host_header) {
+ return respond_disallowed_host(request, &host_header);
+ }
+
let url = request.url().to_string();
let path = url.split('?').next().unwrap_or("/");
@@ -383,6 +557,41 @@ fn handle_request(
serve_static(request, root, stripped, serve_path)
}
+/// Read the request's `Host:` header value, or return the empty string when
+/// the client didn't send one. HTTP/1.1 requires the header, but a misbehaving
+/// client (or a port scanner sending an HTTP/1.0 request) could omit it — in
+/// that case the allow-list check treats it as a mismatch.
+fn host_header_value(request: &tiny_http::Request) -> String {
+ request
+ .headers()
+ .iter()
+ .find(|h| h.field.equiv("Host"))
+ .map(|h| h.value.as_str().to_string())
+ .unwrap_or_default()
+}
+
+/// Render the 403 returned for a `Host:` header that's not in the allow
+/// list. The body is plain text and points the user at the two knobs that
+/// fix it — same wording for the CLI flag and the builder option so a
+/// search of either turns up the same hit.
+fn respond_disallowed_host(request: tiny_http::Request, host_header: &str) -> NgcResult<()> {
+ let display = if host_header.is_empty() {
+ "".to_string()
+ } else {
+ host_header.to_string()
+ };
+ let body = format!(
+ "ngc-rs dev server: blocked request for host \"{display}\".\n\n\
+ The host is not in the dev server's allowedHosts list.\n\
+ To allow it, either:\n\
+ - add it to `architect.serve.options.allowedHosts` in angular.json, or\n\
+ - pass `--allowed-hosts {display}` to `ngc-rs serve`.\n\
+ Use `\"all\"` to disable the host check entirely.\n"
+ );
+ let resp = Response::from_string(body).with_status_code(StatusCode(403));
+ request.respond(resp).map_err(io_err)
+}
+
/// Strip the `serve_path` prefix from `path`, returning the remainder
/// (always starting with `/`). Returns `None` when `path` falls outside
/// the prefix and should be served as 404.
@@ -974,4 +1183,112 @@ mod tests {
assert!(script.contains("addEventListener('reload'"));
assert!(script.contains("addEventListener('build-failed'"));
}
+
+ #[test]
+ fn strip_port_handles_bare_hostname() {
+ assert_eq!(strip_port("example.com"), "example.com");
+ assert_eq!(strip_port("localhost"), "localhost");
+ }
+
+ #[test]
+ fn strip_port_drops_port_from_ipv4_and_hostname() {
+ assert_eq!(strip_port("example.com:4200"), "example.com");
+ assert_eq!(strip_port("127.0.0.1:4200"), "127.0.0.1");
+ }
+
+ #[test]
+ fn strip_port_preserves_ipv6_brackets() {
+ assert_eq!(strip_port("[::1]"), "[::1]");
+ assert_eq!(strip_port("[::1]:4200"), "[::1]");
+ assert_eq!(strip_port("[2001:db8::1]:8080"), "[2001:db8::1]");
+ }
+
+ #[test]
+ fn allowed_hosts_default_accepts_loopback_and_bind_host() {
+ let ah = AllowedHosts::resolve(&[], "192.168.1.10");
+ assert!(ah.is_allowed("localhost"));
+ assert!(ah.is_allowed("localhost:4200"));
+ assert!(ah.is_allowed("127.0.0.1"));
+ assert!(ah.is_allowed("[::1]:4200"));
+ assert!(ah.is_allowed("192.168.1.10"));
+ assert!(ah.is_allowed("192.168.1.10:4200"));
+ assert!(!ah.is_allowed("my-app.ngrok.io"));
+ assert!(!ah.is_allowed("evil.example.com"));
+ }
+
+ #[test]
+ fn allowed_hosts_all_accepts_anything() {
+ let ah = AllowedHosts::resolve(&["all".to_string()], "127.0.0.1");
+ assert!(ah.accepts_all());
+ assert!(ah.is_allowed("evil.example.com"));
+ assert!(ah.is_allowed("my-app.ngrok.io:443"));
+ // An empty Host header still counts as accepted when the user
+ // opted in to "all" — that's the documented bypass.
+ assert!(ah.is_allowed(""));
+ }
+
+ #[test]
+ fn allowed_hosts_explicit_matches_exact_hostnames_case_insensitively() {
+ let ah = AllowedHosts::resolve(&["my-app.ngrok.io".to_string()], "127.0.0.1");
+ assert!(ah.is_allowed("my-app.ngrok.io"));
+ assert!(ah.is_allowed("My-App.NgRoK.io"));
+ assert!(ah.is_allowed("my-app.ngrok.io:8443"));
+ assert!(!ah.is_allowed("other.ngrok.io"));
+ assert!(!ah.is_allowed("evil.com"));
+ // Loopback is always accepted on top of explicit entries.
+ assert!(ah.is_allowed("localhost"));
+ assert!(ah.is_allowed("127.0.0.1"));
+ }
+
+ #[test]
+ fn allowed_hosts_explicit_without_auto_does_not_accept_bind_host() {
+ // Without "auto", the bind host is NOT auto-allowed — the user
+ // explicitly listed which non-loopback hosts to trust.
+ let ah = AllowedHosts::resolve(
+ &["my-app.ngrok.io".to_string()],
+ "192.168.1.10",
+ );
+ assert!(!ah.is_allowed("192.168.1.10"));
+ assert!(ah.is_allowed("my-app.ngrok.io"));
+ }
+
+ #[test]
+ fn allowed_hosts_auto_re_enables_bind_host_alongside_explicit_entries() {
+ let ah = AllowedHosts::resolve(
+ &["auto".to_string(), "my-app.ngrok.io".to_string()],
+ "192.168.1.10",
+ );
+ assert!(ah.is_allowed("192.168.1.10"));
+ assert!(ah.is_allowed("my-app.ngrok.io"));
+ assert!(!ah.is_allowed("evil.com"));
+ }
+
+ #[test]
+ fn allowed_hosts_rejects_missing_host_header_by_default() {
+ let ah = AllowedHosts::resolve(&[], "127.0.0.1");
+ assert!(!ah.is_allowed(""));
+ assert!(!ah.is_allowed(" "));
+ }
+
+ #[test]
+ fn allowed_hosts_skips_wildcard_bind_address() {
+ // Binding to 0.0.0.0 doesn't auto-allow "0.0.0.0" as a hostname —
+ // that's never a meaningful Host: header value. Loopback still works.
+ let ah = AllowedHosts::resolve(&[], "0.0.0.0");
+ assert!(ah.is_allowed("localhost"));
+ assert!(ah.is_allowed("127.0.0.1"));
+ assert!(!ah.is_allowed("0.0.0.0"));
+ assert!(!ah.is_allowed("192.168.1.10"));
+ }
+
+ #[test]
+ fn allowed_hosts_ignores_empty_and_whitespace_patterns() {
+ let ah = AllowedHosts::resolve(
+ &["".to_string(), " ".to_string(), "ok.example".to_string()],
+ "127.0.0.1",
+ );
+ assert!(ah.is_allowed("ok.example"));
+ assert!(ah.is_allowed("localhost"));
+ assert!(!ah.is_allowed("nope.example"));
+ }
}
diff --git a/crates/dev-server/tests/integration.rs b/crates/dev-server/tests/integration.rs
index a8e9638..b043474 100644
--- a/crates/dev-server/tests/integration.rs
+++ b/crates/dev-server/tests/integration.rs
@@ -443,6 +443,116 @@ fn unprefixed_request_returns_404_when_serve_path_set() {
assert_eq!(http_get(fx.server.addr(), "/__ngc_reload").status, 404);
}
+fn http_get_with_host(
+ addr: std::net::SocketAddr,
+ path: &str,
+ host_header: &str,
+) -> HttpResponse {
+ let mut stream = TcpStream::connect(addr).expect("connect");
+ stream
+ .set_read_timeout(Some(Duration::from_secs(5)))
+ .expect("read timeout");
+ let req =
+ format!("GET {path} HTTP/1.1\r\nHost: {host_header}\r\nConnection: close\r\n\r\n");
+ stream.write_all(req.as_bytes()).expect("write");
+ stream.flush().expect("flush");
+
+ let mut reader = BufReader::new(stream);
+ let mut status_line = String::new();
+ reader.read_line(&mut status_line).expect("status line");
+ let status: u16 = status_line
+ .split_whitespace()
+ .nth(1)
+ .and_then(|s| s.parse().ok())
+ .expect("status code");
+
+ let mut headers = Vec::new();
+ loop {
+ let mut line = String::new();
+ reader.read_line(&mut line).expect("header line");
+ if line == "\r\n" || line.is_empty() {
+ break;
+ }
+ if let Some((k, v)) = line.trim_end_matches("\r\n").split_once(':') {
+ headers.push((k.trim().to_string(), v.trim().to_string()));
+ }
+ }
+ let mut body = Vec::new();
+ reader.read_to_end(&mut body).expect("body");
+ HttpResponse {
+ status,
+ headers,
+ body,
+ }
+}
+
+fn allowed_hosts_fixture(patterns: &[&str]) -> Fixture {
+ let root = TempDir::new().expect("tempdir");
+ write_file(
+ root.path(),
+ "index.html",
+ b"
hi
",
+ );
+ let cfg = DevServerConfig::new(root.path())
+ .with_port(0)
+ .with_allowed_hosts(patterns.iter().copied());
+ let (_tx, rx) = channel::();
+ let server = DevServer::start(cfg, rx).expect("start dev server");
+ Fixture {
+ server,
+ _root: root,
+ }
+}
+
+#[test]
+fn default_allowed_hosts_accept_loopback_and_403_others() {
+ let fx = allowed_hosts_fixture(&[]);
+ assert_eq!(http_get_with_host(fx.server.addr(), "/", "localhost").status, 200);
+ assert_eq!(http_get_with_host(fx.server.addr(), "/", "127.0.0.1").status, 200);
+ assert_eq!(http_get_with_host(fx.server.addr(), "/", "[::1]").status, 200);
+ let blocked = http_get_with_host(fx.server.addr(), "/", "my-app.ngrok.io");
+ assert_eq!(blocked.status, 403);
+ let body = std::str::from_utf8(&blocked.body).unwrap_or("");
+ assert!(
+ body.contains("my-app.ngrok.io") && body.contains("allowedHosts"),
+ "403 body should name the host and point at allowedHosts: {body}"
+ );
+}
+
+#[test]
+fn explicit_allowed_host_lets_ngrok_traffic_through() {
+ let fx = allowed_hosts_fixture(&["my-app.ngrok.io"]);
+ assert_eq!(
+ http_get_with_host(fx.server.addr(), "/", "my-app.ngrok.io").status,
+ 200
+ );
+ // Port stripping: a tunneling proxy may forward Host with a port.
+ assert_eq!(
+ http_get_with_host(fx.server.addr(), "/", "my-app.ngrok.io:8443").status,
+ 200
+ );
+ // Loopback still works.
+ assert_eq!(http_get_with_host(fx.server.addr(), "/", "localhost").status, 200);
+ // Anything else is still blocked.
+ assert_eq!(
+ http_get_with_host(fx.server.addr(), "/", "other.ngrok.io").status,
+ 403
+ );
+}
+
+#[test]
+fn allowed_hosts_all_disables_check() {
+ let fx = allowed_hosts_fixture(&["all"]);
+ assert_eq!(
+ http_get_with_host(fx.server.addr(), "/", "anything.example.com").status,
+ 200
+ );
+ assert_eq!(
+ http_get_with_host(fx.server.addr(), "/", "my-app.ngrok.io").status,
+ 200
+ );
+}
+
#[test]
fn prefixed_sse_channel_is_reachable_under_prefix() {
let fx = prefixed_fixture("/admin/");
diff --git a/packages/builder/schemas/dev-server.json b/packages/builder/schemas/dev-server.json
index abb9ad3..449ab8b 100644
--- a/packages/builder/schemas/dev-server.json
+++ b/packages/builder/schemas/dev-server.json
@@ -63,6 +63,11 @@
"servePath": {
"type": "string",
"description": "URL path prefix to mount the dev server under (e.g. \"/admin/\"). Mirrors @angular/build:dev-server's servePath option for projects deployed behind a subpath. When set, the served index.html is rewritten to use the same value as unless angular.json already configures one explicitly."
+ },
+ "allowedHosts": {
+ "type": "array",
+ "items": { "type": "string" },
+ "description": "List of host names the dev server's Host-header check accepts. Loopback hosts (localhost, 127.0.0.1, [::1]) are always allowed. The special value \"all\" disables the check entirely. The special value \"auto\" additionally accepts the configured bind host. Use this to expose the dev server through tunneling proxies (ngrok, Cloudflare Tunnel, GitHub Codespaces) or non-default local hostnames (*.localhost, app.local)."
}
},
"additionalProperties": false
diff --git a/packages/builder/src/serve/__tests__/options.test.ts b/packages/builder/src/serve/__tests__/options.test.ts
index f6a919a..9acd993 100644
--- a/packages/builder/src/serve/__tests__/options.test.ts
+++ b/packages/builder/src/serve/__tests__/options.test.ts
@@ -100,6 +100,41 @@ describe('translateOptions', () => {
translateOptions({ ...base, servePath: '' }, '/ws').args,
).not.toContain('--serve-path');
});
+
+ it('forwards a non-empty allowedHosts list as a comma-joined --allowed-hosts arg', () => {
+ const t = translateOptions(
+ { ...base, allowedHosts: ['my-app.ngrok.io', 'app.local'] },
+ '/ws',
+ );
+ const idx = t.args.indexOf('--allowed-hosts');
+ expect(idx).toBeGreaterThanOrEqual(0);
+ expect(t.args[idx + 1]).toBe('my-app.ngrok.io,app.local');
+ });
+
+ it('passes through the "all" sentinel verbatim', () => {
+ const t = translateOptions({ ...base, allowedHosts: ['all'] }, '/ws');
+ const idx = t.args.indexOf('--allowed-hosts');
+ expect(t.args[idx + 1]).toBe('all');
+ });
+
+ it('drops empty / whitespace-only allowedHosts entries and dedupes case-insensitively', () => {
+ const t = translateOptions(
+ {
+ ...base,
+ allowedHosts: ['', ' ', 'foo.example', 'Foo.Example', 'bar.example'],
+ },
+ '/ws',
+ );
+ const idx = t.args.indexOf('--allowed-hosts');
+ expect(t.args[idx + 1]).toBe('foo.example,bar.example');
+ });
+
+ it('omits --allowed-hosts when the list is empty or unset', () => {
+ expect(
+ translateOptions({ ...base, allowedHosts: [] }, '/ws').args,
+ ).not.toContain('--allowed-hosts');
+ expect(translateOptions(base, '/ws').args).not.toContain('--allowed-hosts');
+ });
});
describe('formatUrl', () => {
diff --git a/packages/builder/src/serve/options.ts b/packages/builder/src/serve/options.ts
index b9c36c8..1f7a613 100644
--- a/packages/builder/src/serve/options.ts
+++ b/packages/builder/src/serve/options.ts
@@ -15,6 +15,7 @@ export interface DevServerOptions extends json.JsonObject {
define: { [key: string]: string } | null;
watch: boolean | null;
servePath: string | null;
+ allowedHosts: string[] | null;
}
export interface TranslatedServeArgs {
@@ -79,6 +80,10 @@ export function translateOptions(
if (servePath) {
args.push('--serve-path', servePath);
}
+ const allowedHosts = normalizeAllowedHosts(raw.allowedHosts);
+ if (allowedHosts.length > 0) {
+ args.push('--allowed-hosts', allowedHosts.join(','));
+ }
return {
args,
@@ -114,6 +119,35 @@ function normalizeServePath(raw: string | null | undefined): string | null {
return out;
}
+// Strip empty/whitespace-only entries and dedupe (case-insensitive on the
+// host portion) so the downstream CLI receives a clean comma-joined list.
+// Order of distinct entries is preserved, since order shouldn't matter for
+// a set-membership check but stable args make `--help` traces easier to
+// diff between runs.
+function normalizeAllowedHosts(raw: string[] | null | undefined): string[] {
+ if (!raw || raw.length === 0) {
+ return [];
+ }
+ const seen = new Set();
+ const out: string[] = [];
+ for (const entry of raw) {
+ if (typeof entry !== 'string') {
+ continue;
+ }
+ const trimmed = entry.trim();
+ if (!trimmed) {
+ continue;
+ }
+ const key = trimmed.toLowerCase();
+ if (seen.has(key)) {
+ continue;
+ }
+ seen.add(key);
+ out.push(trimmed);
+ }
+ return out;
+}
+
function parseConfigurationFromBuildTarget(buildTarget?: string): string | null {
if (!buildTarget) {
return null;