Coverage for webapp/handlers.py: 90%

157 statements  

« prev     ^ index     » next       coverage.py v7.16.2, created at 2026-10-25 22:09 +0000

1import socket 

2import time 

3from urllib.parse import unquote, urlparse, urlunparse 

4 

5import base64 

6import hashlib 

7import re 

8import sentry_sdk 

9 

10import flask 

11from flask import render_template, request 

12import webapp.template_utils as template_utils 

13from canonicalwebteam import image_template 

14from webapp import authentication 

15import webapp.helpers as helpers 

16from webapp.config import ( 

17 BSI_URL, 

18 LOGIN_URL, 

19 SENTRY_DSN, 

20 COMMIT_ID, 

21 ENVIRONMENT, 

22 WEBAPP_CONFIG, 

23 DNS_VERIFICATION_SALT, 

24 IS_DEVELOPMENT, 

25 VITE_PORT, 

26 DEFAULT_ICON_URL, 

27 STATUS_BANNER, 

28) 

29 

30from canonicalwebteam.exceptions import ( 

31 StoreApiError, 

32 StoreApiConnectionError, 

33 StoreApiResourceNotFound, 

34 StoreApiResponseDecodeError, 

35 StoreApiResponseError, 

36 StoreApiResponseErrorList, 

37 StoreApiTimeoutError, 

38 PublisherAgreementNotSigned, 

39 PublisherMacaroonRefreshRequired, 

40 PublisherMissingUsername, 

41) 

42 

43from webapp.api.exceptions import ( 

44 ApiError, 

45 ApiConnectionError, 

46 ApiResponseErrorList, 

47 ApiTimeoutError, 

48 ApiResponseDecodeError, 

49 ApiResponseError, 

50) 

51 

52from datetime import datetime 

53 

54CSP = { 

55 "default-src": ["'self'"], 

56 "img-src": [ 

57 "data: blob:", 

58 # This is needed to allow images from 

59 # https://www.google.*/ads/ga-audiences to load. 

60 "*", 

61 ], 

62 "script-src-elem": [ 

63 "'self'", 

64 "assets.ubuntu.com", 

65 "www.googletagmanager.com", 

66 "www.youtube.com", 

67 "asciinema.org", 

68 "player.vimeo.com", 

69 "plausible.io", 

70 "script.crazyegg.com", 

71 "w.usabilla.com", 

72 "connect.facebook.net", 

73 "snap.licdn.com", 

74 "challenges.cloudflare.com", 

75 # This is necessary for Google Tag Manager to function properly. 

76 "'unsafe-inline'", 

77 ], 

78 "font-src": [ 

79 "'self'", 

80 "assets.ubuntu.com", 

81 ], 

82 "script-src": [], 

83 "connect-src": [ 

84 "'self'", 

85 "ubuntu.com", 

86 "analytics.google.com", 

87 "*.analytics.google.com", 

88 "stats.g.doubleclick.net", 

89 "www.googletagmanager.com", 

90 "sentry.is.canonical.com", 

91 "www.google-analytics.com", 

92 "plausible.io", 

93 "*.crazyegg.com", 

94 "www.facebook.com", 

95 "px.ads.linkedin.com", 

96 "*.snapcraft.io", 

97 "*.snapcraftcontent.com", 

98 "marketplace-analytics.staging.canonical.com", 

99 "marketplace-analytics.canonical.com", 

100 "challenges.cloudflare.com", 

101 "www.google.com", 

102 ], 

103 "frame-src": [ 

104 "'self'", 

105 "td.doubleclick.net", 

106 "www.youtube.com", 

107 "youtube.com", 

108 "asciinema.org", 

109 "player.vimeo.com", 

110 "snapcraft.io", 

111 "www.facebook.com", 

112 "challenges.cloudflare.com", 

113 "snap:", 

114 ], 

115 "style-src": [ 

116 "'self'", 

117 "'unsafe-inline'", 

118 ], 

119 "media-src": [ 

120 "'self'", 

121 "res.cloudinary.com", 

122 ], 

123} 

124 

125CSP_SCRIPT_SRC = [ 

126 "'self'", 

127 "blob:", 

128 "'unsafe-eval'", 

129 "'unsafe-hashes'", 

130] 

131 

132# Vite integration 

133if IS_DEVELOPMENT: 

134 CSP["script-src-elem"].append(f"localhost:{VITE_PORT}") 

135 CSP["connect-src"].append(f"localhost:{VITE_PORT}") 

136 CSP["connect-src"].append(f"ws://localhost:{VITE_PORT}") 

137 CSP["style-src"].append(f"localhost:{VITE_PORT}") 

138 CSP["font-src"].append(f"localhost:{VITE_PORT}") 

139 CSP_SCRIPT_SRC.append(f"localhost:{VITE_PORT}") 

140 

141 

142def refresh_redirect(): 

143 if "macaroon_exchanged" in flask.session: 

144 authentication.reset_auth_session(flask.session) 

145 return flask.redirect( 

146 flask.url_for( 

147 "login.login_handler", 

148 next=flask.request.full_path.rstrip("?"), 

149 ) 

150 ) 

151 

152 try: 

153 macaroon_discharge = authentication.get_refreshed_discharge( 

154 flask.session["macaroon_discharge"] 

155 ) 

156 except ApiResponseError as api_response_error: 

157 if api_response_error.status_code == 401: 

158 return flask.redirect(flask.url_for("login.logout")) 

159 else: 

160 return flask.abort(502, str(api_response_error)) 

161 except ApiError as api_error: 

162 return flask.abort(502, str(api_error)) 

163 

164 flask.session["macaroon_discharge"] = macaroon_discharge 

165 return flask.redirect( 

166 flask.url_for( 

167 flask.request.endpoint, 

168 **flask.request.view_args, 

169 **flask.request.args, 

170 ) 

171 ) 

172 

173 

174def snapcraft_utility_processor(): 

175 if authentication.is_authenticated(flask.session): 

176 user_name = flask.session["publisher"]["fullname"] 

177 user_is_canonical = flask.session["publisher"].get( 

178 "is_canonical", False 

179 ) 

180 stores = flask.session["publisher"].get("stores") 

181 else: 

182 user_name = None 

183 user_is_canonical = False 

184 stores = [] 

185 

186 page_slug = template_utils.generate_slug(flask.request.path) 

187 

188 return { 

189 # Variables 

190 "LOGIN_URL": LOGIN_URL, 

191 "SENTRY_DSN": SENTRY_DSN, 

192 "COMMIT_ID": COMMIT_ID, 

193 "ENVIRONMENT": ENVIRONMENT, 

194 "host_url": flask.request.host_url, 

195 "path": flask.request.path, 

196 "page_slug": page_slug, 

197 "user_name": user_name, 

198 "VERIFIED_PUBLISHER": "verified", 

199 "STAR_DEVELOPER": "starred", 

200 "webapp_config": WEBAPP_CONFIG, 

201 "BSI_URL": BSI_URL, 

202 "now": datetime.now(), 

203 "user_is_canonical": user_is_canonical, 

204 # Functions 

205 "contains": template_utils.contains, 

206 "join": template_utils.join, 

207 "static_url": template_utils.static_url, 

208 "IS_DEVELOPMENT": IS_DEVELOPMENT, 

209 "format_number": template_utils.format_number, 

210 "format_display_name": template_utils.format_display_name, 

211 "display_name": template_utils.display_name, 

212 "install_snippet": template_utils.install_snippet, 

213 "format_date": template_utils.format_date, 

214 "format_member_role": template_utils.format_member_role, 

215 "image": image_template, 

216 "stores": stores, 

217 "format_link": template_utils.format_link, 

218 "DNS_VERIFICATION_SALT": DNS_VERIFICATION_SALT, 

219 "DEFAULT_ICON_URL": DEFAULT_ICON_URL, 

220 "STATUS_BANNER": STATUS_BANNER, 

221 } 

222 

223 

224def set_handlers(app): 

225 @app.context_processor 

226 def utility_processor(): 

227 """ 

228 This defines the set of properties and functions that will be added 

229 to the default context for processing templates. All these items 

230 can be used in all templates 

231 """ 

232 

233 return snapcraft_utility_processor() 

234 

235 # Error handlers 

236 # === 

237 @app.errorhandler(500) 

238 @app.errorhandler(501) 

239 @app.errorhandler(502) 

240 @app.errorhandler(504) 

241 @app.errorhandler(505) 

242 def internal_error(error): 

243 error_name = getattr(error, "name", type(error).__name__) 

244 return_code = getattr(error, "code", 500) 

245 

246 if not app.testing: 

247 sentry_sdk.capture_exception() 

248 

249 return ( 

250 flask.render_template("50X.html", error_name=error_name), 

251 return_code, 

252 ) 

253 

254 @app.errorhandler(503) 

255 def service_unavailable(error): 

256 return render_template("503.html"), 503 

257 

258 @app.errorhandler(404) 

259 @app.errorhandler(StoreApiResourceNotFound) 

260 def handle_resource_not_found(error): 

261 return render_template("404.html", message=str(error)), 404 

262 

263 @app.errorhandler(ApiTimeoutError) 

264 @app.errorhandler(StoreApiTimeoutError) 

265 def handle_connection_timeout(error): 

266 status_code = 504 

267 return ( 

268 render_template( 

269 "50X.html", error_message=str(error), status_code=status_code 

270 ), 

271 status_code, 

272 ) 

273 

274 @app.errorhandler(ApiResponseDecodeError) 

275 @app.errorhandler(ApiResponseError) 

276 @app.errorhandler(ApiConnectionError) 

277 @app.errorhandler(StoreApiResponseDecodeError) 

278 @app.errorhandler(StoreApiResponseError) 

279 @app.errorhandler(StoreApiConnectionError) 

280 @app.errorhandler(ApiError) 

281 @app.errorhandler(StoreApiError) 

282 def store_api_error(error): 

283 status_code = 502 

284 return ( 

285 render_template( 

286 "50X.html", error_message=str(error), status_code=status_code 

287 ), 

288 status_code, 

289 ) 

290 

291 @app.errorhandler(ApiResponseErrorList) 

292 @app.errorhandler(StoreApiResponseErrorList) 

293 def handle_api_error_list(error): 

294 if error.status_code == 404: 

295 if "snap_name" in request.path: 

296 return flask.abort(404, "Snap not found!") 

297 else: 

298 return ( 

299 render_template("404.html", message="Entity not found"), 

300 404, 

301 ) 

302 if len(error.errors) == 1 and error.errors[0]["code"] in [ 

303 "macaroon-permission-required", 

304 "macaroon-authorization-required", 

305 ]: 

306 authentication.reset_auth_session(flask.session) 

307 return flask.redirect( 

308 flask.url_for("login.login_handler", next=flask.request.path) 

309 ) 

310 

311 status_code = 502 

312 codes = [ 

313 f"{error['code']}: {error.get('message', 'No message')}" 

314 for error in error.errors 

315 ] 

316 

317 error_msg = ", ".join(codes) 

318 return ( 

319 render_template( 

320 "50X.html", error_message=error_msg, status_code=status_code 

321 ), 

322 status_code, 

323 ) 

324 

325 # Publisher error 

326 @app.errorhandler(PublisherMissingUsername) 

327 def handle_publisher_missing_name(error): 

328 return flask.redirect(flask.url_for("account.get_account_name")) 

329 

330 @app.errorhandler(PublisherAgreementNotSigned) 

331 def handle_publisher_agreement_not_signed(error): 

332 return flask.redirect(flask.url_for("account.get_agreement")) 

333 

334 @app.errorhandler(PublisherMacaroonRefreshRequired) 

335 def handle_publisher_macaroon_refresh_required(error): 

336 return refresh_redirect() 

337 

338 # Global tasks for all requests 

339 # === 

340 @app.before_request 

341 def clear_expired_unregister_authorization(): 

342 pending = flask.session.get("pending_snap_unregister") 

343 if pending and pending["expires_at"] <= time.time(): 

344 flask.session.pop("pending_snap_unregister", None) 

345 

346 @app.before_request 

347 def clear_trailing(): 

348 """ 

349 Remove trailing slashes from all routes 

350 We like our URLs without slashes 

351 """ 

352 

353 parsed_url = urlparse(unquote(flask.request.url)) 

354 path = parsed_url.path 

355 

356 if path != "/" and path.endswith("/"): 

357 new_uri = urlunparse(parsed_url._replace(path=path[:-1])) 

358 

359 return flask.redirect(new_uri) 

360 

361 # Calculate the SHA256 hash of the script content and encode it in base64. 

362 def calculate_sha256_base64(script_content): 

363 sha256_hash = hashlib.sha256(script_content.encode()).digest() 

364 return "sha256-" + base64.b64encode(sha256_hash).decode() 

365 

366 def get_csp_directive(content, regex): 

367 directive_items = set() 

368 pattern = re.compile(regex) 

369 matched_contents = pattern.findall(content) 

370 for matched_content in matched_contents: 

371 hash_value = f"'{calculate_sha256_base64(matched_content)}'" 

372 directive_items.add(hash_value) 

373 return list(directive_items) 

374 

375 # Find all script elements in the response and add their hashes to the CSP. 

376 def add_script_hashes_to_csp(response): 

377 response.freeze() 

378 decoded_content = b"".join(response.response).decode( 

379 "utf-8", errors="replace" 

380 ) 

381 

382 CSP["script-src"] = CSP_SCRIPT_SRC + get_csp_directive( 

383 decoded_content, r'onclick\s*=\s*"(.*?)"' 

384 ) 

385 return CSP 

386 

387 @app.after_request 

388 def add_headers(response): 

389 """ 

390 Generic rules for headers to add to all requests 

391 

392 - X-Hostname: Mention the name of the host/pod running the application 

393 - Cache-Control: Add cache-control headers for public and private pages 

394 - Content-Security-Policy: Restrict resources (e.g., JavaScript, CSS, 

395 Images) and URLs 

396 - Referrer-Policy: Limit referrer data for security while preserving 

397 full referrer for same-origin requests 

398 - Cross-Origin-Embedder-Policy: allows embedding cross-origin 

399 resources 

400 - Cross-Origin-Opener-Policy: enable the page to open pop-ups while 

401 maintaining same-origin policy 

402 - Cross-Origin-Resource-Policy: allowing cross-origin requests to 

403 access the resource 

404 - X-Permitted-Cross-Domain-Policies: disallows cross-domain access to 

405 resources 

406 """ 

407 

408 response.headers["X-Hostname"] = socket.gethostname() 

409 

410 if flask.request.endpoint == "status_check": 

411 response.headers["Cache-Control"] = "no-store" 

412 elif response.status_code == 200: 

413 if flask.session: 

414 response.headers["Cache-Control"] = "private" 

415 else: 

416 # Only add caching headers to successful responses 

417 if not response.headers.get("Cache-Control"): 

418 response.headers["Cache-Control"] = ", ".join( 

419 { 

420 "public", 

421 "max-age=61", 

422 "stale-while-revalidate=300", 

423 "stale-if-error=86400", 

424 } 

425 ) 

426 csp = add_script_hashes_to_csp(response) 

427 response.headers["Content-Security-Policy"] = helpers.get_csp_as_str( 

428 csp 

429 ) 

430 response.headers["Referrer-Policy"] = "strict-origin-when-cross-origin" 

431 response.headers["Cross-Origin-Embedder-Policy"] = "unsafe-none" 

432 response.headers["Cross-Origin-Opener-Policy"] = ( 

433 "same-origin-allow-popups" 

434 ) 

435 response.headers["Cross-Origin-Resource-Policy"] = "cross-origin" 

436 response.headers["X-Permitted-Cross-Domain-Policies"] = "none" 

437 return response