4.11 Review Client-Side Validation
4.11 - Review Client-Side Validation
Client-side validation gaps appear when HTML attributes, JavaScript checks, or front-end frameworks enforce rules that the server never repeats. Start from forms, SPA APIs, mobile clients, and admin tools. Trace each validated field from browser to controller.
What This Vulnerability Is
Missing server-side validation is a business logic and input-trust flaw. Browsers can enforce pattern, required, maxlength, and JavaScript checks for user experience. Attackers bypass these controls with modified requests, custom HTTP clients, or browser devtools.
The unsafe assumption is that well-behaved clients are the only callers. Without server-side enforcement, attackers submit negative quantities, past expiration dates, unauthorized role values, or oversized payloads. This relates to CWE-602 (Client-Side Enforcement of Server-Side Security) and CWE-20 (Improper Input Validation).
Vulnerability Characteristics (Where to Identify Them)
| Signal | Where to look |
|---|---|
| Feature type | Checkout, registration, transfers, profile update, admin forms, SPA JSON APIs |
| Client-only guards | HTML5 pattern, min, max, required; React/Vue validators with no server mirror |
| Hidden/trusted fields | role, price, userId, discount in POST bodies accepted without server recomputation |
| API parity gaps | Mobile and third-party callers hit endpoints with weaker validation than the web UI |
| Missing server libs | Handlers with no Bean Validation, Pydantic, FluentValidation, or Go validator tags |
| Partial persistence | Invalid input rejected in UI but partially saved when API calls skip validation |
Attack Payloads
Use these in authorized tests to bypass client-only checks. Send requests directly to the server API with tools such as curl, Burp, or Postman—never rely on the browser form alone.
Pattern 1: Omit or tamper with hidden/trusted fields
{"card_number":"4111111111111111","amount":0.01,"tier":"enterprise","account_id":999}
{"gift_code":"INTERNAL","balance":99999,"is_verified":true}
Pattern 2: Type and range violations
{"months":-12}
{"gift_amount":99999999999}
{"pin":"abc"}
{"recipient_email":"not-an-email"}
Pattern 3: Bypass HTML5 constraints
POST /gift-cards/redeem HTTP/1.1
Content-Type: application/json
{"amount":0,"pin":""}
Remove required, pattern, min, and max attributes have no effect on raw HTTP.
Pattern 4: Oversized and malformed input
recipient_name=AAAA...(100000 chars)...AAAA
message=<binary without client size check>
{"note":"<script>alert(1)</script>"}
Pattern 5: Replay and step-skipping
POST /api/subscription/activate
{"subscription_id":555,"status":"active","payment_captured":true}
Skip wizard steps the UI enforces in JavaScript only.
Pattern 6: Alternate API versions and content types
POST /api/v2/gift-cards/redeem
Content-Type: application/x-www-form-urlencoded
amount=1000&tier=enterprise&email=attacker@example.com
Mobile or legacy endpoints may lack validators present in the SPA.
Language-Specific Sinks and Dangerous APIs
Client-side validation improves UX but is not a security control. Review both the browser-side APIs below and confirm each field has a matching server-side check.
HTML (form attributes)
<input type="number" min="1" max="10" required>
<input pattern="[A-Za-z]+" name="username">
<form novalidate> <!-- browser checks disabled — server must still validate -->
<select required name="role">...</select>
JavaScript (browser validation)
if (!form.checkValidity()) return;
if (quantity < 1 || quantity > 10) showError();
const schema = z.object({ email: z.string().email() });
schema.parse(formData); // client-only — not enforced server-side
JavaScript (React / Vue)
// React — client rules only
const errors = validate(values);
if (errors.quantity) return;
// Vue — Vuelidate / vee-validate without API mirror
rules: { amount: { minValue: minValue(0) } }
Python (missing server validation)
@app.route("/gift-cards/redeem", methods=["POST"])
def redeem_gift_card():
data = request.get_json() # no pydantic/marshmallow
balance = data["amount"] + data.get("bonus", 0)
Java (Bean Validation gap)
// DTO without @Valid on controller parameter
public Order create(@RequestBody OrderRequest req) { ... }
// Client sends @NotNull fields as null via raw JSON
@NotBlank String email; // never enforced if @Valid missing
C# (DataAnnotations gap)
public IActionResult Save([FromBody] ProfileModel model)
{
// Missing ModelState.IsValid check
_repo.Save(model);
}
Go (missing validator tags)
type Checkout struct {
Quantity int `json:"quantity"` // no validate:"gte=1"
}
json.NewDecoder(r.Body).Decode(&req) // no validator.Struct(req)
SQL (trust from prior tier)
-- Batch job trusts JSON column written by API with client-only validation
INSERT INTO orders SELECT * FROM json_populate_record(NULL::orders, client_json);
Sample Vulnerable Code in Python
from flask import Flask, request, session, jsonify
app = Flask(__name__)
@app.route("/gift-cards/redeem", methods=["POST"])
def redeem_gift_card():
data = request.get_json()
# React form validates amount and PIN client-side only — server trusts JSON
credit = data["amount"] + data.get("bonus", 0)
redemption = GiftRedemption(
user_id=session["user_id"],
credit=credit,
pin=data.get("pin"),
)
db.session.add(redemption)
db.session.commit()
return jsonify({"credit": credit})
Step-by-Step Review Walkthrough
- Inventory client validation. List forms and API fields with HTML5 constraints, JavaScript checks, or mobile validators.
- Open the matching server handler. In the sample,
redeem_gift_cardreads JSON and computes credit without range checks. Ask whether amount, bonus, or PIN are validated server-side; they are not. - Compare client and server rules. Required fields, numeric ranges, regex patterns, and max lengths must match—or the server must be stricter.
- Review hidden and disabled fields. Attackers can POST
role,price, oruserIdeven when the UI hides them. - Check SPA-only APIs. Absence of browser forms does not remove the need for server validation.
- Trace validation libraries. Confirm Pydantic, Marshmallow, Bean Validation, or Go validator tags run before database calls.
- Confirm negative tests. Send invalid payloads directly to APIs without going through the front end.
Risk Impact Analysis
Financial fraud. Client-trusted quantities, prices, and coupons allow negative totals, free orders, or unauthorized discounts.
Authorization bypass. Hidden role or permission fields accepted from the body may elevate privileges when the server does not recompute them.
Data corruption. Out-of-range or malformed values may violate database constraints or produce inconsistent business state.
Injection and downstream flaws. Unvalidated input that reaches SQL, shell, or template sinks inherits those vulnerability classes.
Vulnerable Examples in Other Languages
Java
@PostMapping("/gift-cards/redeem")
public ResponseEntity<?> redeem(@RequestBody RedeemRequest req) {
// Front-end enforces amount > 0 and PIN format; server skips validation
giftCardService.redeem(req.getPin(), req.getAmount(), req.getBonus());
return ResponseEntity.ok().build();
}
@PostMapping("/subscriptions/upgrade")
public String upgrade(@RequestParam String tier, @RequestParam int months) {
subscriptionService.upgrade(currentUser(), tier, months); // no server-side tier policy
return "redirect:/account";
}
C
[HttpPost("gift-cards/redeem")]
public IActionResult RedeemGiftCard(RedeemDto dto)
{
// Blazor form validates PIN format; API endpoint accepts raw dto
_service.Redeem(UserId, dto);
return Ok();
}
public class RedeemDto
{
public string Pin { get; set; }
public decimal Amount { get; set; } // no [Range], [Required], or length limits
}
JavaScript
function validateRedeem() {
const amount = Number(document.querySelector('[name="amount"]').value);
if (amount < 5 || amount > 500) return false;
return true; // bypass with curl; server must re-validate
}
document.getElementById("redeem").addEventListener("submit", (e) => {
if (!validateRedeem()) e.preventDefault();
// hidden bonus/tier fields sent without server-side recomputation
});
HTML
<form action="/gift-cards/redeem" method="post">
<input type="number" name="amount" min="5" max="500" required>
<input type="hidden" name="bonus" value="0">
<input type="hidden" name="tier" value="standard">
<!-- min/max/required are browser hints only; not enforced on server -->
</form>
Fix: Safer Patterns and Libraries to Use
Python
Validate at the API boundary with Pydantic. Recompute trusted fields server-side.
from pydantic import BaseModel, Field, EmailStr, ConfigDict
class GiftCardRedeemRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
amount: float = Field(ge=5, le=500)
pin: str = Field(min_length=8, max_length=16)
@app.route("/gift-cards/redeem", methods=["POST"])
def redeem_gift_card():
req = GiftCardRedeemRequest.model_validate(request.get_json())
credit = gift_cards.redeem(req.pin, req.amount) # server-computed, not from client bonus field
redemption = GiftRedemption(user_id=session["user_id"], credit=credit)
db.session.add(redemption)
db.session.commit()
return jsonify({"credit": credit})
Important: Client-side validation is UX only. Every security-relevant rule must exist on the server.
# Marshmallow alternative:
from marshmallow import Schema, fields, validate
class RedeemSchema(Schema):
amount = fields.Decimal(required=True, validate=validate.Range(min=5, max=500))
pin = fields.Str(required=True, validate=validate.Length(min=8, max=16))
Java
Apply Jakarta Bean Validation on request DTOs. Reject invalid input before the service layer.
public record GiftCardRedeemRequest(
@NotBlank @Pattern(regexp = "^[A-Z0-9]{8,16}$") String pin,
@NotNull @DecimalMin("5.00") @DecimalMax("500.00") BigDecimal amount
) {}
@PostMapping("/gift-cards/redeem")
public ResponseEntity<?> redeem(@Valid @RequestBody GiftCardRedeemRequest req) {
giftCardService.redeem(req.pin(), req.amount());
return ResponseEntity.ok().build();
}
Important: Use @Valid on every mutating controller parameter. Recompute price, role, and owner from server context.
C
Use DataAnnotations or FluentValidation. Check ModelState on every mutating action.
public class RedeemDto
{
[Required, RegularExpression("^[A-Z0-9]{8,16}$")]
public string Pin { get; set; } = "";
[Required, Range(5, 500)]
public decimal Amount { get; set; }
}
[HttpPost("gift-cards/redeem")]
public IActionResult RedeemGiftCard([FromBody] RedeemDto dto)
{
if (!ModelState.IsValid)
return BadRequest(ModelState);
_service.Redeem(UserId, dto);
return Ok();
}
Important: Never trust disabled UI fields. Authorization-sensitive properties come from server claims, not the request body.
Go
Validate struct tags after JSON decode. Reject unknown fields.
import "github.com/go-playground/validator/v10"
type RedeemGiftCardRequest struct {
Amount float64 `json:"amount" validate:"required,gte=5,lte=500"`
Pin string `json:"pin" validate:"required,min=8,max=16"`
}
func redeemGiftCard(w http.ResponseWriter, r *http.Request) {
var req RedeemGiftCardRequest
dec := json.NewDecoder(r.Body)
dec.DisallowUnknownFields()
if err := dec.Decode(&req); err != nil {
http.Error(w, "invalid json", http.StatusBadRequest)
return
}
if err := validate.Struct(req); err != nil {
http.Error(w, "validation failed", http.StatusBadRequest)
return
}
credit := giftcards.Redeem(req.Pin, req.Amount)
db.Exec("INSERT INTO redemptions (user_id, credit) VALUES ($1,$2)", userID(r), credit)
}
Important: Shared validation middleware beats ad hoc checks scattered across handlers.
Verify During Review
- Every user-editable field has equivalent server-side validation before business logic runs.
- HTML5, JavaScript, and mobile validations are treated as UX only, not security controls.
- Trusted values (price, role, user ID, discount eligibility) are computed server-side, not read from the client.
- Invalid input returns consistent 400 responses with safe error messages; handlers do not partially persist bad data.
- API endpoints used by SPAs and mobile apps enforce the same rules as server-rendered forms.
- Security tests bypass the front end and send out-of-range, missing, and malformed fields to each endpoint.