Skip to content

File DLP uploads, phase 1: the upload inspection proxy (#344) - #373

Merged
neamul602 merged 2 commits into
devfrom
feat/344-upload-proxy
Nov 2, 2026
Merged

neamul602 merged 2 commits into
devfrom
feat/344-upload-proxy

Conversation

@neamul602

Copy link
Copy Markdown
Contributor

Uploads go with option C, a local TLS-inspecting proxy (decision on #335, go-ahead on #344 from @Warhammer4000). This is phase 1 from #344: the explicit proxy. A client pointed at it has its uploads to the hosts in scope decrypted, and each uploaded file is decided by the file policies on channel file.upload. Everything else is tunnelled untouched. No OS redirect yet, and the service doesn't run it. It runs by hand with sentinelgo dlp proxy.

Design

  • CA (uploadproxy/ca.go):
    • one ECDSA P-256 CA per device; its key is a root-only file written atomically, and it never leaves the device;
    • renewed 30 days before its 2-year expiry;
    • MaxPathLenZero: it can sign host certificates only;
    • host certificates live 24 h and are cached.
  • Proxy (proxy.go):
    • CONNECT and plain-HTTP proxying, with HTTP/1.1 and HTTP/2 on both sides.
    • Real servers are verified against the system roots; InsecureSkipVerify is forbidden by a test, and an untrusted server gets nothing (502).
    • It never chains to an environment proxy.
    • A request on an intercepted connection can't be steered to another host (400).
    • HostScope takes exact hosts and *.subdomains, on 443/80 only.
  • Inspector (inspect.go):
    • The body goes to a root-only spill file, up to max_file_bytes (the same limit the scanner applies).
    • Multipart file parts are decided one by one (the event carries their file name); plain form fields are decided together; other bodies whole.
    • The request is then forwarded byte for byte. A body over the limit is decided as too large (on_unscannable, allow by default), and the rest streams on unread.
    • Files are scanned by the sandboxed workers; the proxy itself only splits multipart parts.
  • Verdict (filedlp.Decider.Decide):
    • It blocks only when the hook can block (the Linux monitor can't), the module isn't audit-only, and a matching policy's action is block (or on_unscannable/on_timeout say block).
    • The event action is then blocked, and the client gets HTTP 403 with the block message, naming detector ids, never matches.
    • Events carry destination.host, never the URL.
    • Uploads skip the verdict cache, because their spill files' inodes get reused.
  • sentinelgo dlp proxy -hosts H1,*.h2 [-listen 127.0.0.1:3129] [-ca-out FILE]: loopback only (it refuses other addresses); empties the spill directory at start; prints events as JSON lines. As root on Linux it scans in the sandboxed workers; otherwise in-process, with a warning (a manual tool, never the service).

Not in this phase

  • Redirecting traffic: WFP / PAC on Windows, nftables on Linux, a Network Extension on macOS. Blocking QUIC for intercepted apps.
  • Installing the CA in trust stores (including Firefox's own store and the snap).
  • Per-site handling of resumable or chunked uploads (Google Drive, Dropbox and so on).
  • Attributing a connection to a process or user: events have no user or process yet.
  • The service running it with a scope from the bundle (BrainStation-23/Compliance-OS#532).

Test plan

  • Tests over real TLS, with an HTTPS test server (HTTP/2 enabled) as the site and a client trusting the device CA:
    • a sensitive multipart upload gets 403 over HTTP/2, the site sees nothing, and the event (channel, blocked, host, file name) is right;
    • audit-only is forwarded byte for byte, upstream over HTTP/2, with a monitored event;
    • a clean upload passes with no event; a raw JSON body is inspected; plain HTTP is inspected;
    • an oversized body is forwarded in full, or refused with on_unscannable: block;
    • an out-of-scope host is tunnelled (the client sees the site's own certificate);
    • an untrusted upstream gets 502 and nothing sent; a mismatched Host gets 400;
    • the CA is created 0600, reloaded, renewed near expiry, and an unreadable CA is rejected; host certificates verify, are cached, and IP hosts work.
  • Decider: Decide blocks only when it may (hook can't block, audit-only, monitor action, fail modes); block message.
  • The real command, run end to end as root in a Linux container, with curl through it:
    • an upload of a file containing a test secret to example.com (in scope) got 403 and never left the machine;
    • a GET to example.com got 200 with its real certificate verified;
    • a site out of scope got 200, tunnelled;
    • workers ran as sentinelgo-scan, and no spill files were left.
  • golangci-lint: 0 issues (linux, darwin, windows). gosec v2.30.0: 0. Race tests, make verify-cross and the changelog check pass.
  • Not tried with real browsers (Chrome, Firefox): the File DLP: inspect HTTPS uploads with a local TLS-inspecting proxy (WFP redirect / nftables / NE transparent proxy) #344 spike asks for that, plus measured latency and a list of sites that break. That needs a desktop machine.

Refs #344 #335

🤖 Generated with Claude Code

Uploads go with option C (#335): a local TLS-inspecting proxy. This is
phase 1 from #344, the explicit proxy: a client pointed at it has its
uploads to the hosts in scope decrypted and each uploaded file decided by
the file policies (channel file.upload); everything else is tunnelled
untouched. No OS redirect yet, and the service doesn't run it.

- uploadproxy.CA: a per-device ECDSA CA, its key a root-only file
  written atomically, never leaving the device; renewed before expiry;
  MaxPathLenZero. 24-hour host certificates, cached.
- uploadproxy.Proxy: CONNECT (HTTP/1.1 and HTTP/2 to the client and the
  server) and plain-HTTP proxying. Servers are verified with the system
  roots (a test forbids InsecureSkipVerify). It never chains to an
  environment proxy. A request on an intercepted connection can't be
  steered to another host (400). HostScope lists hosts and *.subdomains on
  443/80.
- uploadproxy.Inspector: the body goes to a root-only spill file up to
  max_file_bytes (taken from the same policy the scanner uses). Multipart
  file parts are decided one by one and plain fields together, other
  bodies whole, and the request is forwarded byte for byte. A body over the
  limit is decided as too large and the rest streams on unread. Scanning is
  by the sandboxed workers (the proxy only splits multipart parts).
- filedlp.Decider.Decide returns a verdict. It blocks only when the hook
  can (the Linux monitor can't), the module isn't audit-only, and a
  matching policy says block (or on_unscannable/on_timeout do). The event
  action is then blocked; the user sees the block message with detector
  ids, never matches. Uploads skip the verdict cache (spill inodes are
  reused). Events carry destination.host.
- sentinelgo dlp proxy -hosts …: runs it by hand for testing (loopback
  only, -ca-out to export the CA, events as JSON lines).

Refs #344 #335

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Nov 2, 2026 •

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

Run configuration
  • Source mode: Auto
  • Repository: Unavailable
  • Branch: Unavailable
  • File: No configuration file from this repository applied
  • Commit: Unavailable
  • Layers: Repository UI (base), Organization UI (inherited)
  • Review profile: CHILL
  • Plan: Advanced
  • Run ID: 056622b0-6a7e-4a29-b172-227512725144

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • Trigger review
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

Share

Windows has no Unix modes, so the CA key file was readable by anyone the
config directory let in (CI caught it: -rw-rw-rw-). The key's temp file
now gets winsec's ACL (SYSTEM, Administrators, the service's account)
before the key is written, and creating the CA fails if it can't be
restricted. The spill directory gets the same ACL, inherited by its
files. Mode checks in the tests are Unix-only; winsec has its own tests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@sonarqubecloud

sonarqubecloud Bot commented Nov 2, 2026

Copy link
Copy Markdown

Quality Gate Failed Quality Gate failed

Failed conditions
C Security Rating on New Code (required ≥ A)

See analysis details on SonarQube Cloud

Catch issues before they fail your Quality Gate with our IDE extension SonarQube for IDE

@neamul602
neamul602 merged commit 63996f3 into dev Nov 2, 2026
22 of 23 checks passed
@neamul602
neamul602 deleted the feat/344-upload-proxy branch November 2, 2026 10:56
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant