Coverage for ivatar/ivataraccount/forms.py: 87%

126 statements  

« prev     ^ index     » next       coverage.py v7.11.3, created at 2025-11-14 00:08 +0000

1""" 

2Classes for our ivatar.ivataraccount.forms 

3""" 

4 

5from urllib.parse import urlsplit, urlunsplit 

6 

7from django import forms 

8from django.utils.translation import gettext_lazy as _ 

9from django.core.exceptions import ValidationError 

10 

11from ipware import get_client_ip 

12 

13from ivatar import settings 

14from ivatar.settings import MIN_LENGTH_EMAIL, MAX_LENGTH_EMAIL 

15from ivatar.settings import MIN_LENGTH_URL, MAX_LENGTH_URL 

16from ivatar.settings import ENABLE_FILE_SECURITY_VALIDATION 

17from ivatar.file_security import validate_uploaded_file, FileUploadSecurityError 

18from .models import UnconfirmedEmail, ConfirmedEmail, Photo 

19from .models import UnconfirmedOpenId, ConfirmedOpenId 

20from .models import UserPreference 

21import logging 

22 

23# Initialize logger 

24logger = logging.getLogger("ivatar.ivataraccount.forms") 

25 

26 

27MAX_NUM_UNCONFIRMED_EMAILS_DEFAULT = 5 

28 

29 

30class AddEmailForm(forms.Form): 

31 """ 

32 Form to handle adding email addresses 

33 """ 

34 

35 email = forms.EmailField( 

36 label=_("Email"), 

37 min_length=MIN_LENGTH_EMAIL, 

38 max_length=MAX_LENGTH_EMAIL, 

39 ) 

40 

41 def clean_email(self): 

42 """ 

43 Enforce lowercase email 

44 """ 

45 # TODO: Domain restriction as in libravatar? 

46 return self.cleaned_data["email"].lower() 

47 

48 def save(self, request): 

49 """ 

50 Save the model, ensuring some safety 

51 """ 

52 user = request.user 

53 # Enforce the maximum number of unconfirmed emails a user can have 

54 num_unconfirmed = user.unconfirmedemail_set.count() 

55 

56 max_num_unconfirmed_emails = getattr( 

57 settings, "MAX_NUM_UNCONFIRMED_EMAILS", MAX_NUM_UNCONFIRMED_EMAILS_DEFAULT 

58 ) 

59 

60 if num_unconfirmed >= max_num_unconfirmed_emails: 

61 self.add_error(None, _("Too many unconfirmed mail addresses!")) 

62 return False 

63 

64 # Check whether or not a confirmation email has been 

65 # sent by this user already 

66 if UnconfirmedEmail.objects.filter( # pylint: disable=no-member 

67 user=user, email=self.cleaned_data["email"] 

68 ).exists(): 

69 self.add_error("email", _("Address already added, currently unconfirmed")) 

70 return False 

71 

72 # Check whether or not the email is already confirmed (by someone) 

73 check_mail = ConfirmedEmail.objects.filter(email=self.cleaned_data["email"]) 

74 if check_mail.exists(): 

75 msg = _("Address already confirmed (by someone else)") 

76 if check_mail.first().user == request.user: 

77 msg = _("Address already confirmed (by you)") 

78 self.add_error("email", msg) 

79 return False 

80 

81 unconfirmed = UnconfirmedEmail() 

82 unconfirmed.email = self.cleaned_data["email"] 

83 unconfirmed.user = user 

84 unconfirmed.save() 

85 unconfirmed.send_confirmation_mail(url=request.build_absolute_uri("/")[:-1]) 

86 return True 

87 

88 

89class UploadPhotoForm(forms.Form): 

90 """ 

91 Form handling photo upload with enhanced security validation 

92 """ 

93 

94 photo = forms.FileField( 

95 label=_("Photo"), 

96 error_messages={"required": _("You must choose an image to upload.")}, 

97 ) 

98 not_porn = forms.BooleanField( 

99 label=_("suitable for all ages (i.e. no offensive content)"), 

100 required=True, 

101 error_messages={ 

102 "required": _( 

103 'We only host "G-rated" images and so this field must be checked.' 

104 ) 

105 }, 

106 ) 

107 can_distribute = forms.BooleanField( 

108 label=_("can be freely copied"), 

109 required=True, 

110 error_messages={ 

111 "required": _( 

112 "This field must be checked since we need to be able to distribute photos to third parties." 

113 ) 

114 }, 

115 ) 

116 

117 def clean_photo(self): 

118 """ 

119 Enhanced photo validation with security checks 

120 """ 

121 photo = self.cleaned_data.get("photo") 

122 

123 if not photo: 

124 raise ValidationError(_("No file provided")) 

125 

126 # Read file data 

127 try: 

128 # Handle different file types 

129 if hasattr(photo, "read"): 

130 file_data = photo.read() 

131 elif hasattr(photo, "file"): 

132 file_data = photo.file.read() 

133 else: 

134 file_data = bytes(photo) 

135 filename = photo.name 

136 except Exception as e: 

137 logger.error(f"Error reading uploaded file: {e}") 

138 raise ValidationError(_("Error reading uploaded file")) 

139 

140 # Perform comprehensive security validation (if enabled) 

141 if ENABLE_FILE_SECURITY_VALIDATION: 

142 try: 

143 is_valid, validation_results, sanitized_data = validate_uploaded_file( 

144 file_data, filename 

145 ) 

146 

147 if not is_valid: 

148 # Log security violation 

149 logger.warning( 

150 f"File upload security violation: {validation_results['errors']}" 

151 ) 

152 

153 # Only reject truly malicious files at the form level 

154 # Allow basic format issues to pass through to Photo.save() for original error handling 

155 if validation_results.get("security_score", 100) < 30: 

156 raise ValidationError( 

157 _("File appears to be malicious and cannot be uploaded") 

158 ) 

159 else: 

160 # For format issues, don't raise ValidationError - let Photo.save() handle it 

161 # This preserves the original error handling behavior 

162 logger.info( 

163 f"File format issue detected, allowing Photo.save() to handle: {validation_results['errors']}" 

164 ) 

165 # Store the validation results for potential use, but don't reject the form 

166 self.validation_results = validation_results 

167 self.file_data = file_data 

168 else: 

169 # Store sanitized data for later use 

170 self.sanitized_data = sanitized_data 

171 self.validation_results = validation_results 

172 # Store original file data for fallback 

173 self.file_data = file_data 

174 

175 # Log successful validation 

176 logger.info( 

177 f"File upload validated successfully: {filename}, security_score: {validation_results.get('security_score', 100)}" 

178 ) 

179 

180 except FileUploadSecurityError as e: 

181 logger.error(f"File upload security error: {e}") 

182 raise ValidationError(_("File security validation failed")) 

183 except Exception as e: 

184 logger.error(f"Unexpected error during file validation: {e}") 

185 raise ValidationError(_("File validation failed")) 

186 else: 

187 # Security validation disabled (e.g., in tests) 

188 logger.debug(f"File upload security validation disabled for: {filename}") 

189 self.file_data = file_data 

190 

191 return photo 

192 

193 def save(self, request, data): 

194 """ 

195 Save the model and assign it to the current user with enhanced security 

196 """ 

197 # Link this file to the user's profile 

198 photo = Photo() 

199 photo.user = request.user 

200 photo.ip_address = get_client_ip(request)[0] 

201 

202 # Use sanitized data if available, otherwise use stored file data 

203 if hasattr(self, "sanitized_data"): 

204 photo.data = self.sanitized_data 

205 elif hasattr(self, "file_data"): 

206 photo.data = self.file_data 

207 else: 

208 # Fallback: try to read from the file object 

209 try: 

210 photo.data = data.read() 

211 except Exception as e: 

212 logger.error(f"Failed to read file data: {e}") 

213 photo.data = b"" 

214 

215 photo.save() 

216 return photo if photo.pk else None 

217 

218 

219class AddOpenIDForm(forms.Form): 

220 """ 

221 Form to handle adding OpenID 

222 """ 

223 

224 openid = forms.URLField( 

225 label=_("OpenID"), 

226 min_length=MIN_LENGTH_URL, 

227 max_length=MAX_LENGTH_URL, 

228 initial="http://", 

229 ) 

230 

231 def clean_openid(self): 

232 """ 

233 Enforce restrictions 

234 """ 

235 # Lowercase hostname port of the URL 

236 url = urlsplit(self.cleaned_data["openid"]) 

237 return urlunsplit( 

238 ( 

239 url.scheme.lower(), 

240 url.netloc.lower(), 

241 url.path, 

242 url.query, 

243 url.fragment, 

244 ) 

245 ) 

246 

247 def save(self, user): 

248 """ 

249 Save the model, ensuring some safety 

250 """ 

251 if ConfirmedOpenId.objects.filter( # pylint: disable=no-member 

252 openid=self.cleaned_data["openid"] 

253 ).exists(): 

254 self.add_error("openid", _("OpenID already added and confirmed!")) 

255 return False 

256 

257 if UnconfirmedOpenId.objects.filter( # pylint: disable=no-member 

258 openid=self.cleaned_data["openid"] 

259 ).exists(): 

260 self.add_error("openid", _("OpenID already added, but not confirmed yet!")) 

261 return False 

262 

263 unconfirmed = UnconfirmedOpenId() 

264 unconfirmed.openid = self.cleaned_data["openid"] 

265 unconfirmed.user = user 

266 unconfirmed.save() 

267 

268 return unconfirmed.pk 

269 

270 

271class UpdatePreferenceForm(forms.ModelForm): 

272 """ 

273 Form for updating user preferences 

274 """ 

275 

276 class Meta: # pylint: disable=too-few-public-methods 

277 """ 

278 Meta class for UpdatePreferenceForm 

279 """ 

280 

281 model = UserPreference 

282 fields = ["theme"] 

283 

284 

285class UploadLibravatarExportForm(forms.Form): 

286 """ 

287 Form handling libravatar user export upload 

288 """ 

289 

290 export_file = forms.FileField( 

291 label=_("Export file"), 

292 error_messages={"required": _("You must choose an export file to upload.")}, 

293 ) 

294 not_porn = forms.BooleanField( 

295 label=_("suitable for all ages (i.e. no offensive content)"), 

296 required=True, 

297 error_messages={ 

298 "required": _( 

299 'We only host "G-rated" images and so this field must be checked.' 

300 ) 

301 }, 

302 ) 

303 can_distribute = forms.BooleanField( 

304 label=_("can be freely copied"), 

305 required=True, 

306 error_messages={ 

307 "required": _( 

308 "This field must be checked since we need to be able to\ 

309 distribute photos to third parties." 

310 ) 

311 }, 

312 ) 

313 

314 

315class DeleteAccountForm(forms.Form): 

316 password = forms.CharField( 

317 label=_("Password"), required=False, widget=forms.PasswordInput() 

318 )