Claude commited on
Commit
285bc99
·
unverified ·
1 Parent(s): 55c0bf1

Run the container as a non-root user (item from review: Dockerfile hardening)

Browse files

The image ran as root with no USER directive - if a scraping dependency
or a malicious response ever led to arbitrary code execution, that code
ran as root inside the container. Also dropped a redundant
`pip install playwright` (already pulled in by requirements.txt).

- Dockerfile: added `useradd -m -u 1000 user` + `USER user`, following
Hugging Face Spaces' own documented convention for custom Docker Spaces
(this app deploys to a HF Space).
- app.py: api_lifespan now launches Chromium with `args=["--no-sandbox"]`.
Chromium's own internal sandbox needs privileges a non-root container
user doesn't have and fails to launch at all without this - the
container itself remains the isolation boundary. Added a test (written
first, confirmed failing, then made to pass) asserting the launch call
carries this flag.

Validated end-to-end rather than by inspection alone, since this is the
one change here with real risk of breaking the live deployment: started
dockerd in this sandbox, built the image, ran it, and confirmed from the
container logs that Chromium starts successfully as the non-root user and
a real /serp/search_bing request drives it all the way to a live page
navigation (it only fails on a TLS interception specific to this sandbox's
network, unrelated to the sandbox/permissions change). 86 tests pass.

Files changed (3) hide show
  1. Dockerfile +9 -2
  2. app.py +6 -1
  3. tests/test_app.py +44 -0
Dockerfile CHANGED
@@ -7,12 +7,19 @@ RUN pip install --no-cache-dir -r requirements.txt
7
 
8
  ENV PLAYWRIGHT_BROWSERS_PATH=0
9
 
10
- RUN pip install --no-cache-dir playwright && \
11
- playwright install-deps chromium && \
12
  playwright install chromium
13
 
14
  COPY . .
15
 
 
 
 
 
 
 
 
 
16
  EXPOSE 7860
17
 
18
  CMD ["python", "./app.py"]
 
7
 
8
  ENV PLAYWRIGHT_BROWSERS_PATH=0
9
 
10
+ RUN playwright install-deps chromium && \
 
11
  playwright install chromium
12
 
13
  COPY . .
14
 
15
+ # Hugging Face Spaces' documented convention for custom Docker Spaces: a
16
+ # non-root user with a fixed UID
17
+ # (https://huggingface.co/docs/hub/spaces-sdks-docker#permissions). Chromium
18
+ # is launched with --no-sandbox (see api_lifespan in app.py) since its own
19
+ # internal sandbox needs privileges this non-root user doesn't have.
20
+ RUN useradd -m -u 1000 user && chown -R user:user /app
21
+ USER user
22
+
23
  EXPOSE 7860
24
 
25
  CMD ["python", "./app.py"]
app.py CHANGED
@@ -73,7 +73,12 @@ async def api_lifespan(app: FastAPI):
73
  global playwright, pw_browser
74
  try:
75
  playwright = await async_playwright().start()
76
- pw_browser = await playwright.chromium.launch(headless=True)
 
 
 
 
 
77
  logging.info("Playwright browser started.")
78
  except Exception as e:
79
  logging.warning(f"Playwright unavailable, browser-based endpoints will fail: {e}")
 
73
  global playwright, pw_browser
74
  try:
75
  playwright = await async_playwright().start()
76
+ # --no-sandbox: the container runs as a non-root user (see the
77
+ # Dockerfile), and Chromium's own internal sandbox needs privileges
78
+ # a non-root container user doesn't have - without this it fails to
79
+ # launch at all. The container itself is still the isolation
80
+ # boundary against a compromised renderer.
81
+ pw_browser = await playwright.chromium.launch(headless=True, args=["--no-sandbox"])
82
  logging.info("Playwright browser started.")
83
  except Exception as e:
84
  logging.warning(f"Playwright unavailable, browser-based endpoints will fail: {e}")
tests/test_app.py CHANGED
@@ -194,3 +194,47 @@ async def test_ops_keyword_search_flattens_results(client, monkeypatch):
194
  data = resp.json()
195
  assert data["error"] is None
196
  assert {r["title"] for r in data["results"]} == {"OPS a", "OPS b"}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
194
  data = resp.json()
195
  assert data["error"] is None
196
  assert {r["title"] for r in data["results"]} == {"OPS a", "OPS b"}
197
+
198
+
199
+ # ------------------------------------- api_lifespan -------------------------------------
200
+
201
+
202
+ async def test_api_lifespan_launches_chromium_without_a_sandbox(monkeypatch):
203
+ """The container runs as a non-root user (see the Dockerfile), and
204
+ Chromium's own internal sandbox needs privileges a non-root container
205
+ user doesn't have - so it must be launched with --no-sandbox, or it
206
+ fails to start at all in that environment.
207
+ """
208
+ launch_calls = []
209
+
210
+ class FakeBrowser:
211
+ async def close(self):
212
+ pass
213
+
214
+ class FakeChromium:
215
+ async def launch(self, **kwargs):
216
+ launch_calls.append(kwargs)
217
+ return FakeBrowser()
218
+
219
+ class FakePlaywright:
220
+ chromium = FakeChromium()
221
+
222
+ async def stop(self):
223
+ pass
224
+
225
+ class FakePlaywrightContextManager:
226
+ async def start(self):
227
+ return FakePlaywright()
228
+
229
+ # Reset via monkeypatch (not a plain assignment) so its automatic
230
+ # teardown restores whatever these module globals held before this test,
231
+ # regardless of what api_lifespan reassigns them to while it runs.
232
+ monkeypatch.setattr(app_module, "pw_browser", None)
233
+ monkeypatch.setattr(app_module, "playwright", None)
234
+ monkeypatch.setattr(app_module, "async_playwright", lambda: FakePlaywrightContextManager())
235
+
236
+ async with app_module.api_lifespan(app_module.app):
237
+ pass
238
+
239
+ assert launch_calls[0]["headless"] is True
240
+ assert "--no-sandbox" in launch_calls[0].get("args", [])