Coverage for webapp/handlers.py: 87%

148 statements  

« prev     ^ index     » next       coverage.py v7.15.0, created at 2026-07-08 13:26 +0000

1import socket 

2from urllib.parse import unquote, urlparse, urlunparse 

3 

4import base64 

5import hashlib 

6import re 

7import sentry_sdk 

8 

9import flask 

10from flask import render_template, request 

11import webapp.template_utils as template_utils 

12from canonicalwebteam import image_template 

13from webapp import authentication 

14import webapp.helpers as helpers 

15from webapp.config import ( 

16 BSI_URL, 

17 LOGIN_URL, 

18 SENTRY_DSN, 

19 COMMIT_ID, 

20 ENVIRONMENT, 

21 WEBAPP_CONFIG, 

22 DNS_VERIFICATION_SALT, 

23 IS_DEVELOPMENT, 

24 VITE_PORT, 

25 DEFAULT_ICON_URL, 

26 STATUS_BANNER, 

27) 

28 

29from canonicalwebteam.exceptions import ( 

30 StoreApiError, 

31 StoreApiConnectionError, 

32 StoreApiResourceNotFound, 

33 StoreApiResponseDecodeError, 

34 StoreApiResponseError, 

35 StoreApiResponseErrorList, 

36 StoreApiTimeoutError, 

37 PublisherAgreementNotSigned, 

38 PublisherMacaroonRefreshRequired, 

39 PublisherMissingUsername, 

40) 

41 

42from webapp.api.exceptions import ( 

43 ApiError, 

44 ApiConnectionError, 

45 ApiResponseErrorList, 

46 ApiTimeoutError, 

47 ApiResponseDecodeError, 

48 ApiResponseError, 

49) 

50 

51from datetime import datetime 

52 

53CSP = { 

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

55 "img-src": [ 

56 "data: blob:", 

57 # This is needed to allow images from 

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

59 "*", 

60 ], 

61 "script-src-elem": [ 

62 "'self'", 

63 "assets.ubuntu.com", 

64 "www.googletagmanager.com", 

65 "www.youtube.com", 

66 "asciinema.org", 

67 "player.vimeo.com", 

68 "plausible.io", 

69 "script.crazyegg.com", 

70 "w.usabilla.com", 

71 "connect.facebook.net", 

72 "snap.licdn.com", 

73 "challenges.cloudflare.com", 

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

75 "'unsafe-inline'", 

76 ], 

77 "font-src": [ 

78 "'self'", 

79 "assets.ubuntu.com", 

80 ], 

81 "script-src": [], 

82 "connect-src": [ 

83 "'self'", 

84 "ubuntu.com", 

85 "analytics.google.com", 

86 "*.analytics.google.com", 

87 "stats.g.doubleclick.net", 

88 "www.googletagmanager.com", 

89 "sentry.is.canonical.com", 

90 "www.google-analytics.com", 

91 "plausible.io", 

92 "*.crazyegg.com", 

93 "www.facebook.com", 

94 "px.ads.linkedin.com", 

95 "*.snapcraft.io", 

96 "*.snapcraftcontent.com", 

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

98 "marketplace-analytics.canonical.com", 

99 "challenges.cloudflare.com", 

100 "www.google.com", 

101 ], 

102 "frame-src": [ 

103 "'self'", 

104 "td.doubleclick.net", 

105 "www.youtube.com", 

106 "youtube.com", 

107 "asciinema.org", 

108 "player.vimeo.com", 

109 "snapcraft.io", 

110 "www.facebook.com", 

111 "challenges.cloudflare.com", 

112 "snap:", 

113 ], 

114 "style-src": [ 

115 "'self'", 

116 "'unsafe-inline'", 

117 ], 

118 "media-src": [ 

119 "'self'", 

120 "res.cloudinary.com", 

121 ], 

122} 

123 

124CSP_SCRIPT_SRC = [ 

125 "'self'", 

126 "blob:", 

127 "'unsafe-eval'", 

128 "'unsafe-hashes'", 

129] 

130 

131# Vite integration 

132if IS_DEVELOPMENT: 

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

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

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

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

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

138 

139 

140def refresh_redirect(): 

141 if "macaroon_exchanged" in flask.session: 

142 authentication.reset_auth_session(flask.session) 

143 return flask.redirect( 

144 flask.url_for( 

145 "login.login_handler", 

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

147 ) 

148 ) 

149 

150 try: 

151 macaroon_discharge = authentication.get_refreshed_discharge( 

152 flask.session["macaroon_discharge"] 

153 ) 

154 except ApiResponseError as api_response_error: 

155 if api_response_error.status_code == 401: 

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

157 else: 

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

159 except ApiError as api_error: 

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

161 

162 flask.session["macaroon_discharge"] = macaroon_discharge 

163 return flask.redirect( 

164 flask.url_for( 

165 flask.request.endpoint, 

166 **flask.request.view_args, 

167 **flask.request.args, 

168 ) 

169 ) 

170 

171 

172def snapcraft_utility_processor(): 

173 if authentication.is_authenticated(flask.session): 

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

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

176 "is_canonical", False 

177 ) 

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

179 else: 

180 user_name = None 

181 user_is_canonical = False 

182 stores = [] 

183 

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

185 

186 return { 

187 # Variables 

188 "LOGIN_URL": LOGIN_URL, 

189 "SENTRY_DSN": SENTRY_DSN, 

190 "COMMIT_ID": COMMIT_ID, 

191 "ENVIRONMENT": ENVIRONMENT, 

192 "host_url": flask.request.host_url, 

193 "path": flask.request.path, 

194 "page_slug": page_slug, 

195 "user_name": user_name, 

196 "VERIFIED_PUBLISHER": "verified", 

197 "STAR_DEVELOPER": "starred", 

198 "webapp_config": WEBAPP_CONFIG, 

199 "BSI_URL": BSI_URL, 

200 "now": datetime.now(), 

201 "user_is_canonical": user_is_canonical, 

202 # Functions 

203 "contains": template_utils.contains, 

204 "join": template_utils.join, 

205 "static_url": template_utils.static_url, 

206 "IS_DEVELOPMENT": IS_DEVELOPMENT, 

207 "format_number": template_utils.format_number, 

208 "format_display_name": template_utils.format_display_name, 

209 "display_name": template_utils.display_name, 

210 "install_snippet": template_utils.install_snippet, 

211 "format_date": template_utils.format_date, 

212 "format_member_role": template_utils.format_member_role, 

213 "image": image_template, 

214 "stores": stores, 

215 "format_link": template_utils.format_link, 

216 "DNS_VERIFICATION_SALT": DNS_VERIFICATION_SALT, 

217 "DEFAULT_ICON_URL": DEFAULT_ICON_URL, 

218 "STATUS_BANNER": STATUS_BANNER, 

219 } 

220 

221 

222def set_handlers(app): 

223 @app.context_processor 

224 def utility_processor(): 

225 """ 

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

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

228 can be used in all templates 

229 """ 

230 

231 return snapcraft_utility_processor() 

232 

233 # Error handlers 

234 # === 

235 @app.errorhandler(500) 

236 @app.errorhandler(501) 

237 @app.errorhandler(502) 

238 @app.errorhandler(504) 

239 @app.errorhandler(505) 

240 def internal_error(error): 

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

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

243 

244 if not app.testing: 

245 sentry_sdk.capture_exception() 

246 

247 return ( 

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

249 return_code, 

250 ) 

251 

252 @app.errorhandler(503) 

253 def service_unavailable(error): 

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

255 

256 @app.errorhandler(404) 

257 @app.errorhandler(StoreApiResourceNotFound) 

258 def handle_resource_not_found(error): 

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

260 

261 @app.errorhandler(ApiTimeoutError) 

262 @app.errorhandler(StoreApiTimeoutError) 

263 def handle_connection_timeout(error): 

264 status_code = 504 

265 return ( 

266 render_template( 

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

268 ), 

269 status_code, 

270 ) 

271 

272 @app.errorhandler(ApiResponseDecodeError) 

273 @app.errorhandler(ApiResponseError) 

274 @app.errorhandler(ApiConnectionError) 

275 @app.errorhandler(StoreApiResponseDecodeError) 

276 @app.errorhandler(StoreApiResponseError) 

277 @app.errorhandler(StoreApiConnectionError) 

278 @app.errorhandler(ApiError) 

279 @app.errorhandler(StoreApiError) 

280 def store_api_error(error): 

281 status_code = 502 

282 return ( 

283 render_template( 

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

285 ), 

286 status_code, 

287 ) 

288 

289 @app.errorhandler(ApiResponseErrorList) 

290 @app.errorhandler(StoreApiResponseErrorList) 

291 def handle_api_error_list(error): 

292 if error.status_code == 404: 

293 if "snap_name" in request.path: 

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

295 else: 

296 return ( 

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

298 404, 

299 ) 

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

301 "macaroon-permission-required", 

302 "macaroon-authorization-required", 

303 ]: 

304 authentication.reset_auth_session(flask.session) 

305 return flask.redirect( 

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

307 ) 

308 

309 status_code = 502 

310 codes = [ 

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

312 for error in error.errors 

313 ] 

314 

315 error_msg = ", ".join(codes) 

316 return ( 

317 render_template( 

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

319 ), 

320 status_code, 

321 ) 

322 

323 # Publisher error 

324 @app.errorhandler(PublisherMissingUsername) 

325 def handle_publisher_missing_name(error): 

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

327 

328 @app.errorhandler(PublisherAgreementNotSigned) 

329 def handle_publisher_agreement_not_signed(error): 

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

331 

332 @app.errorhandler(PublisherMacaroonRefreshRequired) 

333 def handle_publisher_macaroon_refresh_required(error): 

334 return refresh_redirect() 

335 

336 # Global tasks for all requests 

337 # === 

338 @app.before_request 

339 def clear_trailing(): 

340 """ 

341 Remove trailing slashes from all routes 

342 We like our URLs without slashes 

343 """ 

344 

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

346 path = parsed_url.path 

347 

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

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

350 

351 return flask.redirect(new_uri) 

352 

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

354 def calculate_sha256_base64(script_content): 

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

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

357 

358 def get_csp_directive(content, regex): 

359 directive_items = set() 

360 pattern = re.compile(regex) 

361 matched_contents = pattern.findall(content) 

362 for matched_content in matched_contents: 

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

364 directive_items.add(hash_value) 

365 return list(directive_items) 

366 

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

368 def add_script_hashes_to_csp(response): 

369 response.freeze() 

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

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

372 ) 

373 

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

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

376 ) 

377 return CSP 

378 

379 @app.after_request 

380 def add_headers(response): 

381 """ 

382 Generic rules for headers to add to all requests 

383 

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

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

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

387 Images) and URLs 

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

389 full referrer for same-origin requests 

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

391 resources 

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

393 maintaining same-origin policy 

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

395 access the resource 

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

397 resources 

398 """ 

399 

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

401 

402 if response.status_code == 200: 

403 if flask.session: 

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

405 else: 

406 # Only add caching headers to successful responses 

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

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

409 { 

410 "public", 

411 "max-age=61", 

412 "stale-while-revalidate=300", 

413 "stale-if-error=86400", 

414 } 

415 ) 

416 csp = add_script_hashes_to_csp(response) 

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

418 csp 

419 ) 

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

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

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

423 "same-origin-allow-popups" 

424 ) 

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

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

427 return response