Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 11 additions & 1 deletion app/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,8 @@ def create_app(
static_dir = os.path.join(base_dir, 'static')
app = Flask(__name__, template_folder=template_dir, static_folder=static_dir)
app.config.from_object(config)
from .static_delivery import init_static_delivery
init_static_delivery(app)
app.extensions['runtime_lifecycle'] = RuntimeLifecycle(
max_workers=config.BACKGROUND_WORKERS
)
Expand Down Expand Up @@ -167,6 +169,10 @@ def enforce_restore_maintenance_and_session_epoch():
'error': 'WebSSH is in restore maintenance mode',
'code': 'maintenance',
}), 503
# Static files are public and user-independent. Avoid opening the
# session for them so shared caches do not receive ``Vary: Cookie``.
if request.endpoint == 'static':
return None
if (
current_user.is_authenticated
and current_user.is_ldap_managed
Expand Down Expand Up @@ -387,7 +393,11 @@ def add_security_headers(response):
if not config.DEBUG:
response.headers['Strict-Transport-Security'] = 'max-age=31536000; includeSubDomains'

if initialize_storage and session.get('_user_id') is not None:
if (
request.endpoint != 'static'
and initialize_storage
and session.get('_user_id') is not None
):
from .session_epoch import current_epoch
session['_auth_epoch'] = current_epoch()
return response
Expand Down
241 changes: 241 additions & 0 deletions app/static_delivery.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,241 @@
"""Safe delivery policy for public, application-owned static assets."""

import gzip
import hashlib
import re
from functools import lru_cache, wraps
from pathlib import Path

from flask import request, url_for
from flask.sessions import SecureCookieSessionInterface


_VERSION_PATTERN = re.compile(r"[0-9a-f]{16}")
_COMPRESSIBLE_MIMETYPES = (
"application/javascript",
"application/x-javascript",
"image/svg+xml",
"text/css",
"text/javascript",
)
_ASSET_INDEX_EXTENSION = "static_delivery_asset_paths"
_DEFERRED_CONDITIONAL_ENV = "webssh.static_delivery.deferred_conditional"
_CONDITIONAL_ENV_KEYS = (
"HTTP_IF_MATCH",
"HTTP_IF_NONE_MATCH",
"HTTP_IF_MODIFIED_SINCE",
"HTTP_IF_UNMODIFIED_SINCE",
)


def _remove_cookie_variance(response) -> None:
response.vary = [
value for value in response.vary
if value.casefold() != "cookie"
]


def _add_accept_encoding_variance(response) -> None:
if all(value.casefold() != "accept-encoding" for value in response.vary):
response.vary = [*response.vary, "Accept-Encoding"]


def _build_static_asset_index(app) -> dict[str, Path]:
"""Index trusted files without joining request-controlled path segments."""
if not app.static_folder:
raise RuntimeError("static delivery requires an application static folder")
try:
static_root = Path(app.static_folder).resolve(strict=True)
except OSError as exc:
raise RuntimeError("application static folder does not exist") from exc

asset_paths = {}
for candidate in static_root.rglob("*"):
try:
resolved = candidate.resolve(strict=True)
resolved.relative_to(static_root)
except (OSError, ValueError):
continue
if resolved.is_file():
asset_paths[candidate.relative_to(static_root).as_posix()] = resolved
return asset_paths


@lru_cache(maxsize=512)
def _content_version(
path: str,
modified_ns: int,
changed_ns: int,
size: int,
) -> str:
del modified_ns, changed_ns, size
digest = hashlib.sha256()
with open(path, "rb") as asset:
Comment thread
github-advanced-security[bot] marked this conversation as resolved.
Fixed
for chunk in iter(lambda: asset.read(1024 * 1024), b""):
digest.update(chunk)
return digest.hexdigest()[:16]


def static_asset_version(app, filename: str) -> str | None:
"""Return a content-derived cache key for one local static asset."""
asset_paths = app.extensions.get(_ASSET_INDEX_EXTENSION, {})
asset_path = asset_paths.get(filename)
if asset_path is None:
return None
try:
stat = asset_path.stat()
Comment thread
github-advanced-security[bot] marked this conversation as resolved.
Fixed
return _content_version(
str(asset_path),
stat.st_mtime_ns,
stat.st_ctime_ns,
stat.st_size,
)
except OSError:
return None


def _compress_static_response(response):
"""Apply deterministic gzip with an encoding-specific validator."""
_add_accept_encoding_variance(response)
if (
request.method not in {"GET", "HEAD"}
or response.status_code != 200
Comment thread
bifrost0x marked this conversation as resolved.
or "Content-Range" in response.headers
or "Content-Encoding" in response.headers
or request.accept_encodings.best_match(("gzip", "identity")) != "gzip"
or (
response.content_length is not None
and response.content_length < 1024
)
):
return response

response.direct_passthrough = False
plain = response.get_data()
compressed = gzip.compress(plain, compresslevel=6, mtime=0)
if len(compressed) >= len(plain):
return response

etag, weak = response.get_etag()
if etag:
response.set_etag(f"{etag}:gzip", weak=weak)
response.set_data(compressed)
response.headers["Content-Encoding"] = "gzip"
response.headers["Content-Length"] = str(len(compressed))
return response.make_conditional(request)


def _apply_deferred_static_condition(response):
deferred = request.environ.pop(_DEFERRED_CONDITIONAL_ENV, False)
if not deferred or response.status_code != 200:
return response
return response.make_conditional(request)


class _StaticAwareSessionInterface(SecureCookieSessionInterface):
"""Remove artificial cookie variance after Flask saves the session."""

def save_session(self, app, session, response) -> None:
super().save_session(app, session, response)
if request.endpoint != "static":
return
if response.headers.getlist("Set-Cookie"):
response.headers["Cache-Control"] = "private, no-store"
else:
_remove_cookie_variance(response)


def _has_current_asset_version(app) -> bool:
"""Return whether the only query key matches the current asset content."""
versions = request.args.getlist("v")
if (
len(request.args) != 1
or len(versions) != 1
or _VERSION_PATTERN.fullmatch(versions[0]) is None
):
return False
filename = (request.view_args or {}).get("filename")
if not isinstance(filename, str):
return False
return versions[0] == static_asset_version(app, filename)


def init_static_delivery(app) -> None:
"""Enable compression and explicit caching only for Flask's static route.

Dynamic HTML and API responses are deliberately excluded so secrets, CSRF
tokens, and user-specific data never share a compression context.
"""
if not isinstance(app.session_interface, SecureCookieSessionInterface):
raise RuntimeError(
"static delivery requires Flask's secure-cookie session interface"
)
app.session_interface = _StaticAwareSessionInterface()
app.extensions[_ASSET_INDEX_EXTENSION] = _build_static_asset_index(app)

static_view = app.view_functions.get("static")
if static_view is None:
raise RuntimeError("static delivery requires Flask's static endpoint")

@wraps(static_view)
def serve_static_with_negotiated_conditionals(**values):
deferred_headers = {}
if (
request.method in {"GET", "HEAD"}
and "Range" not in request.headers
and request.accept_encodings.best_match(("gzip", "identity")) == "gzip"
):
for key in _CONDITIONAL_ENV_KEYS:
value = request.environ.pop(key, None)
if value is not None:
deferred_headers[key] = value
if deferred_headers:
request.environ[_DEFERRED_CONDITIONAL_ENV] = True
try:
return static_view(**values)
finally:
request.environ.update(deferred_headers)

app.view_functions["static"] = serve_static_with_negotiated_conditionals

def static_asset_url(filename: str) -> str:
version = static_asset_version(app, filename)
if version is None:
raise FileNotFoundError(f"static asset does not exist: {filename}")
return url_for("static", filename=filename, v=version)

app.jinja_env.globals["static_asset_url"] = static_asset_url

@app.after_request
def optimize_static_delivery(response):
if request.endpoint != "static":
return response

# Flask-Login checks its remember-cookie marker after every response,
# which marks the session as accessed even though static content does
# not depend on it. Remove that artificial variance for shared caches.
_remove_cookie_variance(response)

if response.mimetype in _COMPRESSIBLE_MIMETYPES:
response = _compress_static_response(response)
response = _apply_deferred_static_condition(response)

if response.headers.getlist("Set-Cookie"):
response.headers["Cache-Control"] = "private, no-store"
elif (
request.method in {"GET", "HEAD"}
and response.status_code in {200, 206, 304}
and _has_current_asset_version(app)
):
response.headers["Cache-Control"] = (
"public, max-age=31536000, immutable"
)
elif request.method not in {"GET", "HEAD"} or request.args:
response.headers["Cache-Control"] = "no-store"
else:
response.headers["Cache-Control"] = (
"public, max-age=0, must-revalidate"
)
response.headers.pop("Pragma", None)
response.headers.pop("Expires", None)
return response
4 changes: 3 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,10 @@
"scripts": {
"vendor": "node scripts/vendor.js",
"vendor:check": "node scripts/vendor.js --check",
"i18n:auth": "node scripts/build-auth-i18n.js",
"i18n:auth:check": "node scripts/build-auth-i18n.js --check",
"lint:js": "eslint static/js",
"test:js": "node --test tests/js/*.test.js",
"test:js": "npm run i18n:auth:check && node --test tests/js/*.test.js",
"test:e2e": "playwright test --forbid-only"
},
"dependencies": {
Expand Down
97 changes: 97 additions & 0 deletions scripts/build-auth-i18n.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
#!/usr/bin/env node

const fs = require('node:fs');
const path = require('node:path');
const vm = require('node:vm');

const projectRoot = path.resolve(__dirname, '..');
const sourcePath = path.join(projectRoot, 'static/js/i18n.js');
const outputPath = path.join(projectRoot, 'static/js/i18n-auth.js');
const surfacePaths = [
'templates/login.html',
'templates/register.html',
'templates/change_password.html',
'static/js/auth.js',
'static/js/webauthn.js',
'static/js/security-ui.js',
];

function read(relativePath) {
return fs.readFileSync(path.join(projectRoot, relativePath), 'utf8');
}

function parseTranslations(source) {
const declaration = 'const translations = ';
const runtimeMarker = 'const BrowserPreferences = ';
const objectStart = source.indexOf(declaration);
const runtimeStart = source.indexOf(runtimeMarker);
if (objectStart < 0 || runtimeStart < 0 || runtimeStart <= objectStart) {
throw new Error('Unable to locate the translation table and runtime.');
}
const objectLiteral = source
.slice(objectStart + declaration.length, runtimeStart)
.trim()
.replace(/;$/, '');
const translations = vm.runInNewContext(`(${objectLiteral})`, Object.create(null));
return {
translations,
runtime: source.slice(runtimeStart).trimStart(),
};
}

function referencedTranslationKeys(translations) {
const availableKeys = new Set(Object.keys(translations.en || {}));
const keys = new Set();
const keyPattern = /["']([a-z][a-zA-Z0-9_-]*\.[a-zA-Z0-9_.-]+)["']/g;

for (const relativePath of surfacePaths) {
const source = read(relativePath);
let match;
while ((match = keyPattern.exec(source)) !== null) {
if (availableKeys.has(match[1])) {
keys.add(match[1]);
}
}
}
return [...keys].sort();
}

function build() {
const fullSource = fs.readFileSync(sourcePath, 'utf8');
const { translations, runtime } = parseTranslations(fullSource);
const locales = Object.keys(translations);
const keys = referencedTranslationKeys(translations);
if (locales.length === 0 || keys.length === 0) {
throw new Error('Refusing to generate an empty authentication bundle.');
}

const selected = Object.fromEntries(locales.map(locale => {
const localeTranslations = translations[locale];
const missing = keys.filter(key => !(key in localeTranslations));
if (missing.length > 0) {
throw new Error(`${locale} is missing authentication keys: ${missing.join(', ')}`);
}
return [locale, Object.fromEntries(keys.map(key => [key, localeTranslations[key]]))];
}));

return [
'// Generated by scripts/build-auth-i18n.js. Do not edit directly.',
`const translations = ${JSON.stringify(selected, null, 4)};`,
'',
runtime.trimEnd(),
'',
].join('\n');
}

const generated = build();
if (process.argv.includes('--check')) {
const committed = fs.existsSync(outputPath)
? fs.readFileSync(outputPath, 'utf8')
: '';
if (committed !== generated) {
console.error('static/js/i18n-auth.js is stale; run npm run i18n:auth.');
process.exitCode = 1;
}
} else {
fs.writeFileSync(outputPath, generated, 'utf8');
}
Loading
Loading