Skip to main content

proxy_watch/pac/
subprocess.rs

1//! `pac-subprocess`: PAC evaluation in a worker process the caller names, killed at the
2//! deadline.
3//!
4//! The parent side ([`SubprocessEvaluator`]) runs no JavaScript and needs no engine feature.
5//! The worker side (`serve_worker`, which needs the QuickJS engine too) is what the
6//! `proxy-watch-pac-worker` binary runs. Both halves are this module, so a worker built from
7//! the same release speaks the same protocol; the version in the header catches the rest.
8//!
9//! Once it has read a script, the worker is treated as untrusted: the script may have taken
10//! it over. The worker returns the raw string `FindProxyForURL` produced, and the parent
11//! parses it. The worker resolves no names itself: each lookup is a request the parent
12//! answers under its own [`PacPolicy`], so a worker cannot widen DNS access or the
13//! internal-address filter by lying about the policy.
14//!
15//! Before it reads a script, the worker confines itself and reports whether it could. That
16//! report is trusted, and only the worker binary vouches for it, so the confinement holds
17//! for a worker from this release and not for an arbitrary executable the caller names. On
18//! Linux (x86-64 and AArch64) the confinement is a seccomp filter: the worker keeps its pipes
19//! to the parent, memory, the clock and randomness, and any other system call kills it, so a
20//! worker a memory-safety bug has taken over cannot open a file or a socket. Elsewhere the
21//! worker cannot confine itself, and the parent sends it no script unless
22//! [`SubprocessEvaluator::allow_unsandboxed`] says to.
23//!
24//! Wire format, both directions: an 8-byte header (`PWPAC\0` and a little-endian `u16`
25//! version), then frames. A frame is a little-endian `u32` body length, a tag byte, and
26//! fields, each a little-endian `u32` length and UTF-8 bytes.
27
28use std::io::{self, Read, Write};
29use std::path::{Path, PathBuf};
30use std::process::{Child, Command, Stdio};
31use std::sync::Arc;
32use std::sync::mpsc::{self, RecvTimeoutError};
33use std::thread;
34use std::time::{Instant, UNIX_EPOCH};
35
36use url::Url;
37
38use crate::error::Error;
39use crate::resolve::ProxyStep;
40
41use super::policy::PacPolicy;
42use super::result::parse_find_proxy_result;
43use super::{PacEvaluator, PacScript, budget, hostfn};
44
45const MAGIC: [u8; 6] = *b"PWPAC\0";
46const VERSION: u16 = 2;
47
48// Parent to worker.
49const EVALUATE: u8 = 1;
50const ANSWER: u8 = 2;
51// Worker to parent.
52const LOOKUP: u8 = 3;
53const DONE: u8 = 4;
54const FAILED: u8 = 5;
55const SANDBOX: u8 = 6;
56
57// The `SANDBOX` field of a worker under its seccomp filter. Any other value is the reason it
58// is not confined.
59const SECCOMP: &str = "seccomp";
60
61// The largest frame the parent reads from a worker. A `FindProxyForURL` answer or an error
62// message is far smaller; a worker announcing more is broken or hostile, and the parent
63// refuses it before allocating. That also caps an answer at 1 MiB, where the in-process
64// engine passes any length on.
65const MAX_WORKER_FRAME: usize = 1024 * 1024;
66
67// Memory a worker may commit (Windows, a Job Object) or map (Linux, `RLIMIT_AS`). QuickJS
68// already stops a script at 64 MiB of heap; these catch growth outside its accounting. Address
69// space counts every mapped library and reservation, hence the larger Linux figure. macOS
70// ignores `RLIMIT_AS`, so there the QuickJS heap limit is the only one.
71// Limitation: fixed limits; a `SubprocessEvaluator` knob when a real script needs more.
72#[cfg(windows)]
73const WORKER_COMMIT_LIMIT: usize = 512 * 1024 * 1024;
74#[cfg(target_os = "linux")]
75const WORKER_ADDRESS_SPACE_LIMIT: u64 = 1024 * 1024 * 1024;
76
77/// [`PacEvaluator`] that runs each evaluation in a fresh worker process and kills it at the
78/// deadline.
79///
80/// `worker` is the absolute path of a `proxy-watch-pac-worker` binary from this same release
81/// (built with the `pac-subprocess` and `pac-quickjs` features). A relative path is refused
82/// with [`Error::Io`] at evaluation: a bare name would be looked up on the `PATH`, and whoever
83/// controls the lookup would choose the program that sees every script and URL.
84///
85/// Of [`PacPolicy`]'s settings, the timeout covers the whole evaluation (starting the
86/// worker, running the script, and every name lookup it asks for) and a worker still
87/// running at the deadline is killed. DNS and the internal-address filter are applied here
88/// in the parent. `myIpAddress`, the clock and the UTC offset are handed to the worker. The
89/// worker runs QuickJS with a 64 MiB heap, and on Windows and Linux the process has an OS
90/// memory limit as well.
91///
92/// Each running worker holds one of the process-wide evaluation slots QuickJS uses too, so
93/// [`Error::PacSaturated`] bounds worker processes as it bounds threads. A name lookup still
94/// running at the deadline keeps its slot until it returns.
95///
96/// The worker starts with an empty environment (Windows keeps `SystemRoot`) and its own
97/// directory as the working directory, and its standard error is discarded.
98///
99/// On Linux (x86-64 and AArch64) the worker runs the script under a seccomp filter that
100/// leaves it no system call to open a file, a socket or a process. The parent knows this
101/// only from the worker's own report, so the guarantee is the worker binary's. On other
102/// platforms, or where the kernel refuses the filter, the worker is unconfined, and
103/// evaluation fails with [`Error::Io`] ([`io::ErrorKind::PermissionDenied`]) before the
104/// script is sent, unless [`allow_unsandboxed`](Self::allow_unsandboxed) was called. A
105/// worker the filter kills fails the evaluation with [`Error::PacEvaluation`].
106#[derive(Debug, Clone)]
107pub struct SubprocessEvaluator {
108    worker: PathBuf,
109    policy: PacPolicy,
110    allow_unsandboxed: bool,
111}
112
113impl SubprocessEvaluator {
114    /// An evaluator that starts `worker` for each evaluation and runs scripts under `policy`.
115    #[must_use]
116    pub fn new(worker: impl Into<PathBuf>, policy: PacPolicy) -> Self {
117        Self {
118            worker: worker.into(),
119            policy,
120            allow_unsandboxed: false,
121        }
122    }
123
124    /// Also run scripts in a worker that could not confine itself: everywhere but Linux on
125    /// x86-64 and AArch64, any worker. The worker keeps the memory limit and the deadline,
126    /// but one a memory-safety bug has taken over can reach the network and the files of
127    /// the user it runs as.
128    #[must_use]
129    pub fn allow_unsandboxed(mut self) -> Self {
130        self.allow_unsandboxed = true;
131        self
132    }
133
134    /// The worker binary this evaluator starts.
135    #[must_use]
136    pub fn worker(&self) -> &Path {
137        &self.worker
138    }
139
140    /// The policy this evaluator applies.
141    #[must_use]
142    pub fn policy(&self) -> &PacPolicy {
143        &self.policy
144    }
145}
146
147impl PacEvaluator for SubprocessEvaluator {
148    fn evaluate(&self, script: &PacScript, url: &Url, host: &str) -> Result<Vec<ProxyStep>, Error> {
149        let url = crate::pac::sanitize_url(url);
150        let timeout = self.policy.timeout();
151        if let Some(timeout) = timeout.filter(|timeout| timeout.is_zero()) {
152            return Err(Error::PacTimeout { timeout });
153        }
154        let deadline = timeout.and_then(|timeout| Instant::now().checked_add(timeout));
155        // Shared with the conversation thread and freed when both let go: this side holds it
156        // until the worker is reaped, the thread until a name lookup it is in finishes.
157        let slot =
158            Arc::new(
159                budget::SLOTS
160                    .acquire(deadline)
161                    .ok_or_else(|| Error::PacSaturated {
162                        timeout: timeout.unwrap_or_default(),
163                        limit: budget::SLOTS.limit(),
164                    })?,
165            );
166
167        let mut worker = Worker::start(&self.worker)?;
168        let request = evaluate_request(script.source(), url.as_str(), host, &self.policy);
169        let (input, output) = worker.pipes();
170        let policy = self.policy;
171        let allow_unsandboxed = self.allow_unsandboxed;
172        let (sender, receiver) = mpsc::sync_channel(1);
173        // The conversation runs on its own thread so that this one can kill the worker at
174        // the deadline whatever the conversation is blocked on. Killing closes the pipes,
175        // which ends the thread, unless it is inside a name lookup, which then finishes on
176        // its own.
177        let thread_slot = Arc::clone(&slot);
178        let spawned = thread::Builder::new()
179            .name("proxy-watch-pac-worker".to_owned())
180            .spawn(move || {
181                let _slot = thread_slot;
182                let _ = sender.send(converse(
183                    output,
184                    input,
185                    &request,
186                    &policy,
187                    allow_unsandboxed,
188                ));
189            });
190        if let Err(source) = spawned {
191            return Err(Error::io("spawning the PAC worker thread", source));
192        }
193
194        let outcome = match deadline {
195            Some(deadline) => {
196                receiver.recv_timeout(deadline.saturating_duration_since(Instant::now()))
197            }
198            None => receiver.recv().map_err(|_| RecvTimeoutError::Disconnected),
199        };
200        match outcome {
201            Ok(Ok(raw)) => parse_find_proxy_result(&raw),
202            Ok(Err(error)) => {
203                #[cfg(target_os = "linux")]
204                if worker.killed_by_seccomp() {
205                    return Err(Error::pac_evaluation(
206                        "the PAC worker's seccomp filter killed it: the script led it to a \
207                         system call outside what an evaluation needs",
208                    ));
209                }
210                Err(error)
211            }
212            Err(RecvTimeoutError::Timeout) => Err(Error::PacTimeout {
213                timeout: timeout.unwrap_or_default(),
214            }),
215            Err(RecvTimeoutError::Disconnected) => Err(Error::pac_evaluation(
216                "the PAC worker thread ended without producing a result",
217            )),
218        }
219    }
220}
221
222// A started worker process, killed and reaped on drop, also after it answered: it has
223// nothing left to do, and waiting for it to exit on its own would let a hostile one keep
224// its slot, which `evaluate` drops only after this.
225struct Worker {
226    child: Child,
227    // Held until the worker is reaped. Closing it kills anything still in the job.
228    #[cfg(windows)]
229    _job: Option<job::Job>,
230}
231
232impl Worker {
233    fn start(path: &Path) -> Result<Self, Error> {
234        // A relative path would also be resolved against the worker's own directory, set
235        // below, rather than the caller's.
236        if !path.is_absolute() {
237            return Err(Error::io(
238                "starting the PAC worker",
239                io::Error::new(
240                    io::ErrorKind::InvalidInput,
241                    "the worker path is not absolute",
242                ),
243            ));
244        }
245        let mut command = Command::new(path);
246        command
247            .stdin(Stdio::piped())
248            .stdout(Stdio::piped())
249            .stderr(Stdio::null())
250            .env_clear();
251        #[cfg(windows)]
252        if let Some(value) = std::env::var_os("SystemRoot") {
253            command.env("SystemRoot", value);
254        }
255        if let Some(directory) = path.parent().filter(|dir| !dir.as_os_str().is_empty()) {
256            command.current_dir(directory);
257        }
258        #[cfg(target_os = "linux")]
259        limit_address_space(&mut command);
260
261        let child = command
262            .spawn()
263            .map_err(|source| Error::io("starting the PAC worker", source))?;
264        // The worker reads its request before it runs anything it was sent, and the request
265        // is written only after this returns, so no script runs outside the job.
266        #[cfg(windows)]
267        let mut child = child;
268        #[cfg(windows)]
269        let _job = match job::confine(&child) {
270            Ok(job) => Some(job),
271            Err(source) => {
272                let _ = child.kill();
273                let _ = child.wait();
274                return Err(Error::io("limiting the PAC worker's memory", source));
275            }
276        };
277        Ok(Self {
278            child,
279            #[cfg(windows)]
280            _job,
281        })
282    }
283
284    fn pipes(&mut self) -> (std::process::ChildStdin, std::process::ChildStdout) {
285        let (Some(input), Some(output)) = (self.child.stdin.take(), self.child.stdout.take())
286        else {
287            unreachable!("both pipes are requested in `start` and taken once");
288        };
289        (input, output)
290    }
291
292    // Whether the worker died of `SIGSYS`, which the seccomp filter sends. Called once the
293    // conversation has failed: a worker still running is killed, which then reads as `false`.
294    #[cfg(target_os = "linux")]
295    fn killed_by_seccomp(&mut self) -> bool {
296        use std::os::unix::process::ExitStatusExt;
297
298        let _ = self.child.kill();
299        self.child
300            .wait()
301            .is_ok_and(|status| status.signal() == Some(libc::SIGSYS))
302    }
303}
304
305impl Drop for Worker {
306    fn drop(&mut self) {
307        let _ = self.child.kill();
308        let _ = self.child.wait();
309    }
310}
311
312#[cfg(target_os = "linux")]
313fn limit_address_space(command: &mut Command) {
314    use std::os::unix::process::CommandExt;
315
316    let limit = libc::rlimit {
317        rlim_cur: WORKER_ADDRESS_SPACE_LIMIT as libc::rlim_t,
318        rlim_max: WORKER_ADDRESS_SPACE_LIMIT as libc::rlim_t,
319    };
320    // SAFETY: `setrlimit` is async-signal-safe, and the closure touches nothing else between
321    // `fork` and `exec`. Lowering the hard limit too keeps the worker from raising it back.
322    unsafe {
323        command.pre_exec(move || {
324            if libc::setrlimit(libc::RLIMIT_AS, &limit) == 0 {
325                Ok(())
326            } else {
327                Err(io::Error::last_os_error())
328            }
329        });
330    }
331}
332
333#[cfg(windows)]
334mod job {
335    use std::io;
336    use std::os::windows::io::AsRawHandle;
337    use std::process::Child;
338
339    use windows::Win32::Foundation::{CloseHandle, HANDLE};
340    use windows::Win32::System::JobObjects::{
341        AssignProcessToJobObject, CreateJobObjectW, JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE,
342        JOB_OBJECT_LIMIT_PROCESS_MEMORY, JOBOBJECT_EXTENDED_LIMIT_INFORMATION,
343        JobObjectExtendedLimitInformation, SetInformationJobObject,
344    };
345    use windows::core::PCWSTR;
346
347    use super::WORKER_COMMIT_LIMIT;
348
349    pub(super) struct Job(HANDLE);
350
351    impl Drop for Job {
352        fn drop(&mut self) {
353            // SAFETY: the handle came from `CreateJobObjectW` and is closed once.
354            let _ = unsafe { CloseHandle(self.0) };
355        }
356    }
357
358    // An unnamed job with the worker in it: a commit limit, and the worker killed when the
359    // last handle closes, including the parent dying before it could kill the worker.
360    pub(super) fn confine(child: &Child) -> io::Result<Job> {
361        let error = |error: windows::core::Error| io::Error::from_raw_os_error(error.code().0);
362        // SAFETY: no attributes and no name; the handle is owned by the returned `Job`.
363        let job = Job(unsafe { CreateJobObjectW(None, PCWSTR::null()) }.map_err(error)?);
364        let mut limits = JOBOBJECT_EXTENDED_LIMIT_INFORMATION::default();
365        limits.BasicLimitInformation.LimitFlags =
366            JOB_OBJECT_LIMIT_PROCESS_MEMORY | JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE;
367        limits.ProcessMemoryLimit = WORKER_COMMIT_LIMIT;
368        // SAFETY: `limits` is the structure the information class names, with its size.
369        unsafe {
370            SetInformationJobObject(
371                job.0,
372                JobObjectExtendedLimitInformation,
373                (&raw const limits).cast(),
374                u32::try_from(size_of_val(&limits)).expect("a fixed-size structure"),
375            )
376        }
377        .map_err(error)?;
378        // SAFETY: `child` is alive; it is reaped only after the job is dropped.
379        unsafe { AssignProcessToJobObject(job.0, HANDLE(child.as_raw_handle())) }.map_err(error)?;
380        Ok(job)
381    }
382
383    #[cfg(test)]
384    mod tests {
385        use std::path::Path;
386        use std::time::{Duration, Instant};
387
388        use windows::Win32::System::JobObjects::{IsProcessInJob, QueryInformationJobObject};
389
390        use super::super::Worker;
391        use super::*;
392
393        // `cmd.exe` with its standard input held open waits on it, so it stays alive until
394        // something kills it.
395        #[test]
396        fn a_worker_runs_under_its_commit_limit_and_dies_with_its_job() {
397            let cmd = Path::new(&std::env::var_os("SystemRoot").unwrap()).join(r"System32\cmd.exe");
398            let mut worker = Worker::start(&cmd).unwrap();
399            let job = worker._job.take().unwrap();
400
401            let mut inside = windows::core::BOOL(0);
402            // SAFETY: both handles are open, and `inside` outlives the call.
403            unsafe {
404                IsProcessInJob(
405                    HANDLE(worker.child.as_raw_handle()),
406                    Some(job.0),
407                    &mut inside,
408                )
409            }
410            .unwrap();
411            assert!(inside.as_bool());
412            let mut limits = JOBOBJECT_EXTENDED_LIMIT_INFORMATION::default();
413            // SAFETY: `limits` is the structure the information class names, with its size.
414            unsafe {
415                QueryInformationJobObject(
416                    Some(job.0),
417                    JobObjectExtendedLimitInformation,
418                    (&raw mut limits).cast(),
419                    u32::try_from(size_of_val(&limits)).unwrap(),
420                    None,
421                )
422            }
423            .unwrap();
424            assert!(
425                limits
426                    .BasicLimitInformation
427                    .LimitFlags
428                    .contains(JOB_OBJECT_LIMIT_PROCESS_MEMORY)
429            );
430            assert_eq!(limits.ProcessMemoryLimit, WORKER_COMMIT_LIMIT);
431
432            // Closing the job alone kills the worker: the parent dying does no more.
433            drop(job);
434            let started = Instant::now();
435            while worker.child.try_wait().unwrap().is_none() {
436                assert!(
437                    started.elapsed() < Duration::from_secs(5),
438                    "the worker outlived its job"
439                );
440                std::thread::sleep(Duration::from_millis(10));
441            }
442        }
443    }
444}
445
446// The request frame's fields: script, URL, host, `myIpAddress`, UTC offset, and the pinned
447// clock in signed nanoseconds since the epoch (empty for the real clock). DNS and the
448// internal-address filter stay out: the parent applies them.
449fn evaluate_request(script: &str, url: &str, host: &str, policy: &PacPolicy) -> Vec<Vec<u8>> {
450    let now = policy
451        .now()
452        .map_or_else(String::new, |now| match now.duration_since(UNIX_EPOCH) {
453            Ok(after) => after.as_nanos().to_string(),
454            Err(before) => format!("-{}", before.duration().as_nanos()),
455        });
456    [
457        script.to_owned(),
458        url.to_owned(),
459        host.to_owned(),
460        policy.my_ip_address().to_string(),
461        policy.local_utc_offset().to_string(),
462        now,
463    ]
464    .map(String::into_bytes)
465    .into()
466}
467
468// The parent's half: check the worker's confinement, send the request, answer lookups under
469// `policy`, return the raw answer. An unconfined worker is refused before the request, which
470// carries the script, is written.
471fn converse(
472    mut from_worker: impl Read,
473    mut to_worker: impl Write,
474    request: &[Vec<u8>],
475    policy: &PacPolicy,
476    allow_unsandboxed: bool,
477) -> Result<String, Error> {
478    let broken = |error: io::Error| {
479        Error::pac_evaluation(format!("the PAC worker broke the protocol: {error}"))
480    };
481    let unexpected = |tag: u8, fields: &[String]| {
482        broken(invalid(format!(
483            "unexpected frame {tag} with {} fields",
484            fields.len()
485        )))
486    };
487    write_header(&mut to_worker).map_err(broken)?;
488    read_header(&mut from_worker).map_err(broken)?;
489    match read_frame(&mut from_worker, MAX_WORKER_FRAME).map_err(broken)? {
490        (SANDBOX, fields) if fields.len() == 1 => {
491            if fields[0] != SECCOMP && !allow_unsandboxed {
492                return Err(Error::io(
493                    "starting the PAC worker",
494                    io::Error::new(
495                        io::ErrorKind::PermissionDenied,
496                        format!(
497                            "the worker is not sandboxed ({}), and the evaluator does not \
498                             allow that",
499                            fields[0]
500                        ),
501                    ),
502                ));
503            }
504        }
505        (tag, fields) => return Err(unexpected(tag, &fields)),
506    }
507    write_frame(&mut to_worker, EVALUATE, request).map_err(broken)?;
508    loop {
509        let (tag, mut fields) = read_frame(&mut from_worker, MAX_WORKER_FRAME).map_err(broken)?;
510        match (tag, fields.len()) {
511            (LOOKUP, 1) => {
512                let host = fields.remove(0);
513                let answer = hostfn::resolve_ipv4(&host, policy)
514                    .map_or_else(Vec::new, |address| address.to_string().into_bytes());
515                write_frame(&mut to_worker, ANSWER, &[answer]).map_err(broken)?;
516            }
517            (DONE, 1) => return Ok(fields.remove(0)),
518            (FAILED, 1) => return Err(Error::pac_evaluation(fields.remove(0))),
519            _ => return Err(unexpected(tag, &fields)),
520        }
521    }
522}
523
524fn invalid(message: impl Into<String>) -> io::Error {
525    io::Error::new(io::ErrorKind::InvalidData, message.into())
526}
527
528fn write_header(to: &mut impl Write) -> io::Result<()> {
529    to.write_all(&MAGIC)?;
530    to.write_all(&VERSION.to_le_bytes())?;
531    to.flush()
532}
533
534fn read_header(from: &mut impl Read) -> io::Result<()> {
535    let mut header = [0u8; 8];
536    from.read_exact(&mut header)?;
537    if header[..6] != MAGIC {
538        return Err(invalid("not a PAC worker"));
539    }
540    let version = u16::from_le_bytes([header[6], header[7]]);
541    if version != VERSION {
542        return Err(invalid(format!(
543            "protocol version {version}, expected {VERSION}; the worker is from another release"
544        )));
545    }
546    Ok(())
547}
548
549fn write_frame(to: &mut impl Write, tag: u8, fields: &[impl AsRef<[u8]>]) -> io::Result<()> {
550    let length = |len: usize| {
551        u32::try_from(len)
552            .map(u32::to_le_bytes)
553            .map_err(|_| invalid("a frame too large to send"))
554    };
555    let mut body = vec![tag];
556    for field in fields {
557        let field = field.as_ref();
558        body.extend(length(field.len())?);
559        body.extend(field);
560    }
561    to.write_all(&length(body.len())?)?;
562    to.write_all(&body)?;
563    to.flush()
564}
565
566// A frame of at most `max` bytes, split into its tag and UTF-8 fields.
567fn read_frame(from: &mut impl Read, max: usize) -> io::Result<(u8, Vec<String>)> {
568    let mut length = [0u8; 4];
569    from.read_exact(&mut length)?;
570    let length = u32::from_le_bytes(length) as usize;
571    if length == 0 || length > max {
572        return Err(invalid(format!("a frame of {length} bytes")));
573    }
574    let mut body = vec![0u8; length];
575    from.read_exact(&mut body)?;
576
577    let (&tag, mut rest) = body.split_first().expect("length is not zero");
578    let mut fields = Vec::new();
579    while !rest.is_empty() {
580        let (size, tail) = rest
581            .split_first_chunk::<4>()
582            .ok_or_else(|| invalid("a truncated field length"))?;
583        let size = u32::from_le_bytes(*size) as usize;
584        if size > tail.len() {
585            return Err(invalid("a field longer than its frame"));
586        }
587        let (field, tail) = tail.split_at(size);
588        fields.push(
589            String::from_utf8(field.to_vec()).map_err(|_| invalid("a field that is not UTF-8"))?,
590        );
591        rest = tail;
592    }
593    Ok((tag, fields))
594}
595
596#[cfg(pac_quickjs)]
597pub use self::worker::serve_worker;
598
599// The worker's half. The lookup hook lives in a thread local because `hostfn` is reached from
600// inside the engine, and the worker evaluates on its main thread only.
601#[cfg(pac_quickjs)]
602mod worker {
603    use std::cell::RefCell;
604    use std::io::{self, StdinLock, StdoutLock};
605    use std::net::{IpAddr, Ipv4Addr};
606    use std::process::ExitCode;
607    use std::time::Duration;
608
609    use super::*;
610
611    thread_local! {
612        static PARENT: RefCell<Option<(StdinLock<'static>, StdoutLock<'static>)>> =
613            const { RefCell::new(None) };
614    }
615
616    /// Run one evaluation for a [`SubprocessEvaluator`] over standard input and output, then
617    /// return. This is the whole of the `proxy-watch-pac-worker` binary; a program that embeds
618    /// the worker elsewhere calls it the same way, as the first and only thing its `main` does.
619    ///
620    /// Exits with failure when the parent is not a [`SubprocessEvaluator`] of this release or
621    /// hangs up; the parent reads that as a worker that produced no answer.
622    #[must_use]
623    pub fn serve_worker() -> ExitCode {
624        let mut input = io::stdin().lock();
625        let mut output = io::stdout().lock();
626        let sandbox = confine();
627        let request = write_header(&mut output)
628            .and_then(|()| write_frame(&mut output, SANDBOX, &[sandbox]))
629            .and_then(|()| read_header(&mut input))
630            .and_then(|()| read_frame(&mut input, usize::MAX))
631            .and_then(|frame| match frame {
632                (EVALUATE, fields) if fields.len() == 6 => Ok(fields),
633                _ => Err(invalid("the first frame is not a request")),
634            })
635            .and_then(|fields| Ok((policy(&fields)?, fields)));
636        let Ok((policy, fields)) = request else {
637            return ExitCode::FAILURE;
638        };
639
640        PARENT.set(Some((input, output)));
641        let result =
642            super::super::quickjs::run_raw(&fields[0], &fields[1], &fields[2], policy, None);
643        let Some((_, mut output)) = PARENT.take() else {
644            return ExitCode::FAILURE;
645        };
646        let sent = match result {
647            Ok(raw) => write_frame(&mut output, DONE, &[raw]),
648            // The reason alone: the parent wraps it in `Error::PacEvaluation` again, and the
649            // `Display` form would arrive with the prefix twice and its tail cut by the
650            // second length cap.
651            Err(Error::PacEvaluation { reason }) => write_frame(&mut output, FAILED, &[reason]),
652            Err(error) => write_frame(&mut output, FAILED, &[error.to_string()]),
653        };
654        if sent.is_ok() {
655            ExitCode::SUCCESS
656        } else {
657            ExitCode::FAILURE
658        }
659    }
660
661    // What the worker reports in its `SANDBOX` frame: `SECCOMP` once the filter is on, or why
662    // it is not.
663    #[cfg(all(
664        target_os = "linux",
665        any(target_arch = "x86_64", target_arch = "aarch64")
666    ))]
667    fn confine() -> String {
668        match super::seccomp::confine() {
669            Ok(()) => SECCOMP.to_owned(),
670            Err(error) => format!("the kernel refused the seccomp filter: {error}"),
671        }
672    }
673
674    #[cfg(not(all(
675        target_os = "linux",
676        any(target_arch = "x86_64", target_arch = "aarch64")
677    )))]
678    fn confine() -> String {
679        "the worker has no sandbox on this platform".to_owned()
680    }
681
682    // The worker's policy: what the host functions read besides DNS. No timeout here: the
683    // parent kills the worker at its deadline, and that also covers a lookup in flight.
684    fn policy(fields: &[String]) -> io::Result<PacPolicy> {
685        let bad = |what: &str| invalid(format!("an unreadable {what}"));
686        let my_ip: IpAddr = fields[3].parse().map_err(|_| bad("myIpAddress"))?;
687        let offset: i32 = fields[4].parse().map_err(|_| bad("UTC offset"))?;
688        let mut policy = PacPolicy::new()
689            .with_timeout(None)
690            .with_my_ip_address(my_ip)
691            .with_local_utc_offset(offset);
692        if !fields[5].is_empty() {
693            let (negative, digits) = match fields[5].strip_prefix('-') {
694                Some(digits) => (true, digits),
695                None => (false, fields[5].as_str()),
696            };
697            let nanos: u128 = digits.parse().map_err(|_| bad("clock"))?;
698            let offset = Duration::new(
699                u64::try_from(nanos / 1_000_000_000).map_err(|_| bad("clock"))?,
700                (nanos % 1_000_000_000) as u32,
701            );
702            let now = if negative {
703                UNIX_EPOCH.checked_sub(offset)
704            } else {
705                UNIX_EPOCH.checked_add(offset)
706            };
707            policy = policy.with_now(now.ok_or_else(|| bad("clock"))?);
708        }
709        Ok(policy)
710    }
711
712    // `Some` answer from the parent inside a worker, `None` anywhere else. A parent that
713    // hangs up or answers with anything but an address ends the worker: there is no parent
714    // connection left to report to.
715    pub(in crate::pac) fn ask_parent(host: &str) -> Option<Option<Ipv4Addr>> {
716        PARENT.with_borrow_mut(|parent| {
717            let (input, output) = parent.as_mut()?;
718            let answer = write_frame(output, LOOKUP, &[host])
719                .and_then(|()| read_frame(input, usize::MAX))
720                .and_then(|frame| match frame {
721                    (ANSWER, fields) if fields.len() == 1 && fields[0].is_empty() => Ok(None),
722                    (ANSWER, fields) if fields.len() == 1 => fields[0]
723                        .parse()
724                        .map(Some)
725                        .map_err(|_| invalid("an unreadable address")),
726                    _ => Err(invalid("a reply that is not an answer")),
727                });
728            match answer {
729                Ok(answer) => Some(answer),
730                Err(_) => std::process::exit(1),
731            }
732        })
733    }
734}
735
736#[cfg(pac_quickjs)]
737pub(super) use self::worker::ask_parent;
738
739// The worker's seccomp filter. What an evaluation needs is its pipes to the parent
740// (standard input to read, standard output and error to write), memory, the signal calls
741// std's own handlers make, the clock and randomness; any other call kills the whole process
742// with `SIGSYS`. Killing rather than failing the call matters: std's `SIGSEGV` handler
743// reinstalls the default action and returns to fault again, so a refused `rt_sigaction`
744// would turn that into a loop that no deadline ends when the policy has none.
745#[cfg(all(
746    pac_quickjs,
747    target_os = "linux",
748    any(target_arch = "x86_64", target_arch = "aarch64")
749))]
750mod seccomp {
751    use std::io;
752
753    use libc::{
754        BPF_ABS, BPF_JEQ, BPF_JGE, BPF_JMP, BPF_K, BPF_LD, BPF_RET, BPF_W, SECCOMP_RET_ALLOW,
755        SECCOMP_RET_KILL_PROCESS, c_long, sock_filter, sock_fprog,
756    };
757
758    // `AUDIT_ARCH_*`, which `libc` does not carry.
759    #[cfg(target_arch = "x86_64")]
760    const ARCH: u32 = 0xC000_003E;
761    #[cfg(target_arch = "aarch64")]
762    const ARCH: u32 = 0xC000_00B7;
763
764    // Offsets into `seccomp_data`. Both architectures are little-endian, so an argument's low
765    // half comes first; the kernel reads a file descriptor as `unsigned int`, so the low half
766    // is all of it.
767    const NR: u32 = 0;
768    const ARCH_AT: u32 = 4;
769    const fn argument(index: u32) -> u32 {
770        16 + 8 * index
771    }
772
773    const ALLOWED: &[c_long] = &[
774        libc::SYS_exit_group,
775        libc::SYS_exit,
776        libc::SYS_brk,
777        libc::SYS_munmap,
778        libc::SYS_mremap,
779        libc::SYS_madvise,
780        libc::SYS_mprotect,
781        libc::SYS_futex,
782        libc::SYS_rt_sigreturn,
783        libc::SYS_rt_sigprocmask,
784        libc::SYS_rt_sigaction,
785        libc::SYS_sigaltstack,
786        libc::SYS_clock_gettime,
787        libc::SYS_gettimeofday,
788        #[cfg(target_arch = "x86_64")]
789        libc::SYS_time,
790        libc::SYS_getrandom,
791    ];
792
793    // Calls allowed only with one of the listed values as the given argument.
794    const WITH_ARGUMENT: &[(c_long, u32, &[u32])] = &[
795        (libc::SYS_read, 0, &[0]),
796        (libc::SYS_write, 0, &[1, 2]),
797        (libc::SYS_writev, 0, &[1, 2]),
798        // Anonymous memory only (descriptor -1): a mapping of an inherited file would read it.
799        (libc::SYS_mmap, 4, &[u32::MAX]),
800    ];
801
802    fn statement(code: u32, k: u32) -> sock_filter {
803        jump(code, k, 0, 0)
804    }
805
806    fn jump(code: u32, k: u32, jt: usize, jf: usize) -> sock_filter {
807        let offset = |n: usize| u8::try_from(n).expect("a short filter");
808        sock_filter {
809            code: u16::try_from(code).expect("a BPF opcode"),
810            jt: offset(jt),
811            jf: offset(jf),
812            k,
813        }
814    }
815
816    pub(super) fn filter() -> Vec<sock_filter> {
817        let load = |at: u32| statement(BPF_LD | BPF_W | BPF_ABS, at);
818        let equal = |k: u32, jt: usize, jf: usize| jump(BPF_JMP | BPF_JEQ | BPF_K, k, jt, jf);
819        let allow = statement(BPF_RET | BPF_K, SECCOMP_RET_ALLOW);
820        let kill = statement(BPF_RET | BPF_K, SECCOMP_RET_KILL_PROCESS);
821        let mut program = vec![load(ARCH_AT), equal(ARCH, 1, 0), kill, load(NR)];
822        // x32 calls share x86-64's architecture value and set this bit in the number.
823        if cfg!(target_arch = "x86_64") {
824            program.extend([jump(BPF_JMP | BPF_JGE | BPF_K, 0x4000_0000, 0, 1), kill]);
825        }
826        for &call in ALLOWED {
827            program.extend([equal(call as u32, 0, 1), allow]);
828        }
829        for &(call, index, values) in WITH_ARGUMENT {
830            let count = values.len();
831            program.extend([equal(call as u32, 0, count + 3), load(argument(index))]);
832            for (position, &value) in values.iter().enumerate() {
833                program.push(equal(value, count - position, 0));
834            }
835            program.extend([kill, allow]);
836        }
837        program.push(kill);
838        program
839    }
840
841    // Applied to every thread of the process (`TSYNC`): `serve_worker` may run in a program
842    // that has started others.
843    pub(super) fn install(program: &[sock_filter]) -> io::Result<()> {
844        let program = sock_fprog {
845            len: u16::try_from(program.len()).expect("a short filter"),
846            filter: program.as_ptr().cast_mut(),
847        };
848        // SAFETY: `prctl` takes plain integers here. `seccomp` reads `program`, which points
849        // at a live slice of the length it names, and keeps its own copy.
850        let result = unsafe {
851            if libc::prctl(libc::PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0) != 0 {
852                return Err(io::Error::last_os_error());
853            }
854            libc::syscall(
855                libc::SYS_seccomp,
856                libc::c_ulong::from(libc::SECCOMP_SET_MODE_FILTER),
857                libc::SECCOMP_FILTER_FLAG_TSYNC,
858                &raw const program,
859            )
860        };
861        match result {
862            0 => Ok(()),
863            -1 => Err(io::Error::last_os_error()),
864            thread => Err(io::Error::other(format!(
865                "thread {thread} could not take the filter"
866            ))),
867        }
868    }
869
870    unsafe extern "C" {
871        fn tzset();
872    }
873
874    // `Date`'s local time reads the zone file the first time it is needed, and the filter
875    // forbids opening it, so it is read here first.
876    pub(super) fn confine() -> io::Result<()> {
877        // SAFETY: `tzset` takes no arguments and serializes itself; it reads the
878        // environment, which the worker does not write.
879        unsafe { tzset() };
880        install(&filter())
881    }
882}
883
884#[cfg(test)]
885mod tests {
886    use std::io::Cursor;
887    use std::time::Duration;
888
889    use super::*;
890
891    // A confined worker saying `frames` after its header and `SANDBOX` frame.
892    fn from_worker(frames: &[(u8, &[&str])]) -> Vec<u8> {
893        let confined: &[(u8, &[&str])] = &[(SANDBOX, &[SECCOMP])];
894        from_unconfined(&[confined, frames].concat())
895    }
896
897    fn from_unconfined(frames: &[(u8, &[&str])]) -> Vec<u8> {
898        let mut bytes = Vec::new();
899        write_header(&mut bytes).unwrap();
900        for (tag, fields) in frames {
901            write_frame(&mut bytes, *tag, fields).unwrap();
902        }
903        bytes
904    }
905
906    fn talk(worker_said: Vec<u8>, policy: &PacPolicy) -> (Result<String, Error>, Vec<u8>) {
907        talk_allowing(worker_said, policy, false)
908    }
909
910    fn talk_allowing(
911        worker_said: Vec<u8>,
912        policy: &PacPolicy,
913        allow_unsandboxed: bool,
914    ) -> (Result<String, Error>, Vec<u8>) {
915        let mut sent = Vec::new();
916        let request = evaluate_request("script", "http://example.net/", "example.net", policy);
917        let result = converse(
918            Cursor::new(worker_said),
919            &mut sent,
920            &request,
921            policy,
922            allow_unsandboxed,
923        );
924        (result, sent)
925    }
926
927    fn broken(result: Result<String, Error>) -> String {
928        match result {
929            Err(Error::PacEvaluation { reason }) => reason,
930            other => panic!("expected a protocol failure, got {other:?}"),
931        }
932    }
933
934    // The worker inherits none of the parent's environment but `SystemRoot`. `cmd.exe` sets
935    // `COMSPEC`, `PATHEXT` and `PROMPT` itself when it finds them missing.
936    #[cfg(windows)]
937    #[test]
938    fn a_worker_starts_with_an_empty_environment() {
939        let cmd = Path::new(&std::env::var_os("SystemRoot").unwrap()).join(r"System32\cmd.exe");
940        let mut worker = Worker::start(&cmd).unwrap();
941        let (mut input, mut output) = worker.pipes();
942        input.write_all(b"set\r\nexit\r\n").unwrap();
943        drop(input);
944        let mut said = String::new();
945        output.read_to_string(&mut said).unwrap();
946        let mut names: Vec<&str> = said
947            .lines()
948            .filter_map(|line| line.split_once('=').map(|(name, _)| name))
949            .filter(|name| !["COMSPEC", "PATHEXT", "PROMPT"].contains(name))
950            .collect();
951        names.sort_unstable();
952        assert_eq!(names, ["SystemRoot"], "{said}");
953    }
954
955    #[test]
956    fn an_answer_is_returned_raw_and_a_failure_as_an_evaluation_error() {
957        let policy = PacPolicy::new();
958        let (result, _) = talk(from_worker(&[(DONE, &["PROXY a:1; DIRECT"])]), &policy);
959        assert_eq!(result.unwrap(), "PROXY a:1; DIRECT");
960        let (result, _) = talk(from_worker(&[(FAILED, &["boom"])]), &policy);
961        assert!(broken(result).contains("boom"));
962    }
963
964    // The parent answers under its own policy, whatever the worker would have liked. The
965    // three policies split one lookup three ways, so an answer given under any policy but
966    // the one passed in shows. The address is a literal, so no row asks real DNS.
967    #[test]
968    fn a_lookup_is_answered_under_the_parent_policy() {
969        let dns = PacPolicy::new().with_dns_resolution(true);
970        for (policy, answer) in [
971            (PacPolicy::new(), ""),
972            (dns, ""),
973            (dns.with_internal_addresses(true), "127.0.0.1"),
974        ] {
975            let said = from_worker(&[(LOOKUP, &["127.0.0.1"]), (DONE, &["DIRECT"])]);
976            let (result, sent) = talk(said, &policy);
977            assert_eq!(result.unwrap(), "DIRECT");
978            let mut sent = Cursor::new(sent);
979            read_header(&mut sent).unwrap();
980            assert_eq!(read_frame(&mut sent, usize::MAX).unwrap().0, EVALUATE);
981            assert_eq!(
982                read_frame(&mut sent, usize::MAX).unwrap(),
983                (ANSWER, vec![answer.to_owned()]),
984                "{policy:?}"
985            );
986        }
987    }
988
989    #[test]
990    fn a_worker_that_breaks_the_protocol_is_an_evaluation_error() {
991        let policy = PacPolicy::new();
992        // Another program entirely.
993        assert!(
994            broken(talk(b"Microsoft Windows\r\n".to_vec(), &policy).0).contains("not a PAC worker")
995        );
996        // Another release.
997        let mut other = MAGIC.to_vec();
998        other.extend((VERSION + 1).to_le_bytes());
999        assert!(broken(talk(other, &policy).0).contains(&format!("version {}", VERSION + 1)));
1000        // No word on its confinement before its answer.
1001        assert!(
1002            broken(talk(from_unconfined(&[(DONE, &["DIRECT"])]), &policy).0)
1003                .contains("unexpected frame")
1004        );
1005        // Exits without answering.
1006        assert!(!broken(talk(from_worker(&[]), &policy).0).is_empty());
1007        // A length the parent must not allocate.
1008        let mut huge = from_worker(&[]);
1009        huge.extend(u32::MAX.to_le_bytes());
1010        assert!(broken(talk(huge, &policy).0).contains("4294967295 bytes"));
1011        // A frame cut short.
1012        let mut cut = from_worker(&[(DONE, &["DIRECT"])]);
1013        cut.pop();
1014        assert!(!broken(talk(cut, &policy).0).is_empty());
1015        // Unknown tags, a request tag coming back, and a wrong field count.
1016        for said in [
1017            from_worker(&[(9, &["x"])]),
1018            from_worker(&[(EVALUATE, &["x"])]),
1019            from_worker(&[(DONE, &["a", "b"])]),
1020        ] {
1021            assert!(broken(talk(said, &policy).0).contains("unexpected frame"));
1022        }
1023        // A field that is not UTF-8.
1024        let mut bytes = from_worker(&[]);
1025        write_frame(&mut bytes, DONE, &[[0xffu8]]).unwrap();
1026        assert!(broken(talk(bytes, &policy).0).contains("UTF-8"));
1027    }
1028
1029    // Refused with only the header sent, so the script never reaches the worker; allowed, the
1030    // conversation goes on as with a confined one.
1031    #[test]
1032    fn an_unconfined_worker_gets_the_script_only_when_allowed() {
1033        let policy = PacPolicy::new();
1034        let said = from_unconfined(&[(SANDBOX, &["no sandbox here"]), (DONE, &["DIRECT"])]);
1035
1036        let (result, sent) = talk(said.clone(), &policy);
1037        match result {
1038            Err(Error::Io { source, .. }) => {
1039                assert_eq!(source.kind(), io::ErrorKind::PermissionDenied);
1040                assert!(source.to_string().contains("no sandbox here"), "{source}");
1041            }
1042            other => panic!("expected a refusal, got {other:?}"),
1043        }
1044        let mut header = Vec::new();
1045        write_header(&mut header).unwrap();
1046        assert_eq!(sent, header);
1047
1048        let (result, sent) = talk_allowing(said, &policy, true);
1049        assert_eq!(result.unwrap(), "DIRECT");
1050        let mut sent = Cursor::new(sent);
1051        read_header(&mut sent).unwrap();
1052        assert_eq!(read_frame(&mut sent, usize::MAX).unwrap().0, EVALUATE);
1053    }
1054
1055    // Each case runs in a child forked after the filter is built: it installs the filter,
1056    // makes one call through `syscall`, and exits. Only raw calls happen between `fork` and
1057    // `_exit`.
1058    #[cfg(all(
1059        pac_quickjs,
1060        target_os = "linux",
1061        any(target_arch = "x86_64", target_arch = "aarch64")
1062    ))]
1063    #[test]
1064    fn the_seccomp_filter_kills_every_call_it_does_not_allow() {
1065        let program = seccomp::filter();
1066        let status_after = |call: &dyn Fn()| {
1067            // SAFETY: the child makes raw system calls only and leaves through `_exit`.
1068            unsafe {
1069                let pid = libc::fork();
1070                assert!(pid >= 0, "{}", io::Error::last_os_error());
1071                if pid == 0 {
1072                    if seccomp::install(&program).is_err() {
1073                        libc::_exit(2);
1074                    }
1075                    call();
1076                    libc::_exit(0);
1077                }
1078                let mut status = 0;
1079                assert_eq!(libc::waitpid(pid, &mut status, 0), pid);
1080                status
1081            }
1082        };
1083        let survives =
1084            |status: libc::c_int| libc::WIFEXITED(status) && libc::WEXITSTATUS(status) == 0;
1085        let killed = |status: libc::c_int| {
1086            libc::WIFSIGNALED(status) && libc::WTERMSIG(status) == libc::SIGSYS
1087        };
1088        let null = std::ptr::null_mut::<libc::c_void>();
1089
1090        // SAFETY (each closure): the calls take plain integers or null, with zero lengths.
1091        let allowed: [&dyn Fn(); 4] = [
1092            &|| {},
1093            &|| unsafe {
1094                libc::syscall(libc::SYS_read, 0, null, 0);
1095            },
1096            &|| unsafe {
1097                libc::syscall(libc::SYS_write, 2, null, 0);
1098            },
1099            &|| unsafe {
1100                libc::syscall(
1101                    libc::SYS_mmap,
1102                    null,
1103                    4096,
1104                    libc::PROT_READ,
1105                    libc::MAP_PRIVATE | libc::MAP_ANONYMOUS,
1106                    -1,
1107                    0,
1108                );
1109            },
1110        ];
1111        for (index, call) in allowed.into_iter().enumerate() {
1112            let status = status_after(call);
1113            assert!(survives(status), "allowed call {index}: status {status:#x}");
1114        }
1115
1116        let refused: [&dyn Fn(); 5] = [
1117            &|| unsafe {
1118                libc::syscall(libc::SYS_getpid);
1119            },
1120            &|| unsafe {
1121                libc::syscall(
1122                    libc::SYS_openat,
1123                    libc::AT_FDCWD,
1124                    c"/".as_ptr(),
1125                    libc::O_RDONLY,
1126                );
1127            },
1128            &|| unsafe {
1129                libc::syscall(libc::SYS_socket, libc::AF_INET, libc::SOCK_STREAM, 0);
1130            },
1131            // A descriptor other than the pipes to the parent.
1132            &|| unsafe {
1133                libc::syscall(libc::SYS_write, 3, null, 0);
1134            },
1135            &|| unsafe {
1136                libc::syscall(
1137                    libc::SYS_mmap,
1138                    null,
1139                    4096,
1140                    libc::PROT_READ,
1141                    libc::MAP_PRIVATE,
1142                    0,
1143                    0,
1144                );
1145            },
1146        ];
1147        for (index, call) in refused.into_iter().enumerate() {
1148            let status = status_after(call);
1149            assert!(killed(status), "refused call {index}: status {status:#x}");
1150        }
1151    }
1152
1153    #[test]
1154    fn a_pinned_clock_before_the_epoch_is_sent_signed() {
1155        let policy = PacPolicy::new().with_now(UNIX_EPOCH - Duration::from_millis(1500));
1156        let request = evaluate_request("", "", "", &policy);
1157        assert_eq!(request[5], b"-1500000000");
1158    }
1159}