Skip to content

Commit a38527a

Browse files
SecAI-Hubclaude
andcommitted
Fix C5 and C6: CSP nonce-based script policy, eliminate shell-to-Python interpolation
C5 — CSP unsafe-inline removed for script-src: - Generate a per-request cryptographic nonce (secrets.token_urlsafe) - Inject nonce into all 9 template <script> tags via {{ csp_nonce }} - CSP header now uses script-src 'self' 'nonce-{nonce}' instead of 'unsafe-inline' — inline scripts only execute with the correct nonce - style-src retains 'unsafe-inline' because 37 inline style= attributes across 7 templates would require a CSS-class refactor to eliminate - Nonce exposed to templates via Flask g object + context_processor C6 — All shell-to-Python interpolation eliminated: - Consolidated 4 separate python3 -c blocks (MANIFEST_POPULATED, REQUIRED_PYTHON_VERSION, SUPPORTED_ARCHES) into a single env-var-based call using _SECAI_MANIFEST environment variable - Every python3 -c invocation now uses single-quoted heredocs (no shell expansion) and reads values via os.environ - Verified: zero instances of ${...} inside any Python code block 909 tests pass, 22 skipped, ruff clean. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent d72d27c commit a38527a

11 files changed

Lines changed: 37 additions & 39 deletions

File tree

files/scripts/secai-enable-diffusion.sh

Lines changed: 13 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -247,35 +247,24 @@ if [ ! -f "$MANIFEST" ]; then
247247
rollback "Manifest not found: ${MANIFEST}"
248248
fi
249249

250-
# Check if the manifest has been populated with real hashes
251-
MANIFEST_POPULATED=$(python3 -c "
252-
import yaml
253-
with open('${MANIFEST}') as f:
250+
# Read all manifest metadata in a single safe Python call (no shell interpolation)
251+
CURRENT_PYTHON_VERSION=$(python3 -c 'import sys; print(f"{sys.version_info.major}.{sys.version_info.minor}")')
252+
CURRENT_ARCH=$(uname -m)
253+
254+
eval "$(_SECAI_MANIFEST="$MANIFEST" python3 -c '
255+
import os, sys, yaml
256+
with open(os.environ["_SECAI_MANIFEST"]) as f:
254257
m = yaml.safe_load(f)
255-
print('yes' if m.get('populated', False) else 'no')
256-
")
258+
print(f"MANIFEST_POPULATED={\"yes\" if m.get(\"populated\", False) else \"no\"}")
259+
print(f"REQUIRED_PYTHON_VERSION={m.get(\"python_version\", \"\")}")
260+
arches = m.get("supported_architectures", [])
261+
print(f"SUPPORTED_ARCHES={\" \".join(arches)}")
262+
')"
263+
257264
if [ "$MANIFEST_POPULATED" != "yes" ]; then
258265
rollback "Diffusion runtime manifest is not yet populated with real package hashes. Run scripts/refresh-diffusion-locks.sh on a Linux machine with the target Python version to generate the manifest, then commit the result."
259266
fi
260267

261-
REQUIRED_PYTHON_VERSION=$(python3 -c "
262-
import yaml
263-
with open('${MANIFEST}') as f:
264-
m = yaml.safe_load(f)
265-
print(m.get('python_version', ''))
266-
")
267-
268-
CURRENT_PYTHON_VERSION=$(python3 -c "import sys; print(f'{sys.version_info.major}.{sys.version_info.minor}')")
269-
CURRENT_ARCH=$(uname -m)
270-
271-
SUPPORTED_ARCHES=$(python3 -c "
272-
import yaml
273-
with open('${MANIFEST}') as f:
274-
m = yaml.safe_load(f)
275-
arches = m.get('supported_architectures', [])
276-
print(' '.join(arches))
277-
")
278-
279268
if [ -n "$REQUIRED_PYTHON_VERSION" ] && [ "$CURRENT_PYTHON_VERSION" != "$REQUIRED_PYTHON_VERSION" ]; then
280269
rollback "Python version mismatch: have ${CURRENT_PYTHON_VERSION}, need ${REQUIRED_PYTHON_VERSION}"
281270
fi

services/ui/ui/app.py

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,9 @@
2424

2525
import requests
2626
import yaml
27-
from flask import Flask, Response, jsonify, render_template, request, session
27+
import secrets as _secrets_mod
28+
29+
from flask import Flask, Response, g, jsonify, render_template, request, session
2830

2931
# Add services/ to path so we can import common.audit_chain
3032
_services_root = str(Path(__file__).resolve().parent.parent.parent)
@@ -146,20 +148,27 @@ def csrf_protect():
146148
return None
147149

148150

151+
@app.before_request
152+
def generate_csp_nonce():
153+
"""Generate a per-request CSP nonce for inline scripts and styles."""
154+
g.csp_nonce = _secrets_mod.token_urlsafe(24)
155+
156+
149157
@app.context_processor
150-
def inject_csrf_token():
151-
"""Expose CSRF token to all templates."""
158+
def inject_template_globals():
159+
"""Expose CSRF token and CSP nonce to all templates."""
152160
token = session.get("csrf_token", "")
153-
return {"csrf_token": token}
161+
return {"csrf_token": token, "csp_nonce": getattr(g, "csp_nonce", "")}
154162

155163

156164
@app.after_request
157165
def add_security_headers(response):
158166
"""Add defense-in-depth HTTP security headers to every response."""
167+
nonce = getattr(g, "csp_nonce", "")
159168
response.headers["Content-Security-Policy"] = (
160169
"default-src 'self'; "
161-
"script-src 'self' 'unsafe-inline'; "
162-
"style-src 'self' 'unsafe-inline'; "
170+
f"script-src 'self' 'nonce-{nonce}'; "
171+
f"style-src 'self' 'nonce-{nonce}' 'unsafe-inline'; "
163172
"img-src 'self' data:; "
164173
"media-src 'self' data:; "
165174
"font-src 'self'; "

services/ui/ui/templates/base.html

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -620,7 +620,7 @@ <h3 id="modal-title"></h3>
620620
</div>
621621
</div>
622622

623-
<script>
623+
<script nonce="{{ csp_nonce }}">
624624
/* -- CSRF: auto-include token on all non-GET fetch requests -- */
625625
var csrfToken = document.querySelector('meta[name="csrf-token"]')?.content || '';
626626
var _origFetch = window.fetch;

services/ui/ui/templates/generate.html

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,7 @@
6363
{% endblock %}
6464

6565
{% block scripts %}
66-
<script>
66+
<script nonce="{{ csp_nonce }}">
6767
function switchTab(tab, el) {
6868
document.querySelectorAll('.tab').forEach(function(t) { t.classList.remove('active'); });
6969
document.querySelectorAll('[id^="panel-"]').forEach(function(p) { p.classList.add('hidden'); });

services/ui/ui/templates/index.html

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -125,7 +125,7 @@ <h3>Local-first. Private by default.</h3>
125125
{% endblock %}
126126

127127
{% block scripts %}
128-
<script>
128+
<script nonce="{{ csp_nonce }}">
129129
var chatContainer = document.getElementById('chat-container');
130130
var userInput = document.getElementById('user-input');
131131
var sendBtn = document.getElementById('send-btn');

services/ui/ui/templates/login.html

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -173,7 +173,7 @@ <h1 id="title">Unlock Appliance</h1>
173173
</div>
174174
</div>
175175

176-
<script>
176+
<script nonce="{{ csp_nonce }}">
177177
var isSetup = false;
178178

179179
async function checkSetup() {

services/ui/ui/templates/models.html

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@
4141
{% endblock %}
4242

4343
{% block scripts %}
44-
<script>
44+
<script nonce="{{ csp_nonce }}">
4545
var uploadZone = document.getElementById('upload-zone');
4646
var fileInput = document.getElementById('file-input');
4747

services/ui/ui/templates/security.html

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -163,7 +163,7 @@
163163
{% endblock %}
164164

165165
{% block scripts %}
166-
<script>
166+
<script nonce="{{ csp_nonce }}">
167167
async function loadServiceHealth() {
168168
try {
169169
var resp = await fetch('/api/status');

services/ui/ui/templates/settings.html

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -74,7 +74,7 @@
7474
{% endblock %}
7575

7676
{% block scripts %}
77-
<script>
77+
<script nonce="{{ csp_nonce }}">
7878
async function loadSession() {
7979
try {
8080
var resp = await fetch('/api/auth/status');

services/ui/ui/templates/setup.html

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -213,7 +213,7 @@ <h3>Start Chatting</h3>
213213
</p>
214214
</div>
215215

216-
<script>
216+
<script nonce="{{ csp_nonce }}">
217217
var servicesOk = false;
218218
var modelReady = false;
219219

0 commit comments

Comments
 (0)