Skip to content
Open
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
49 changes: 46 additions & 3 deletions website/api/optimiser/_client/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
package client

import (
"errors"
"fmt"
"io"
"net/http"
Expand All @@ -10,10 +11,52 @@ import (
constants "github.com/nusmodifications/nusmods/website/api/optimiser/_constants"
)

var httpClient = &http.Client{Timeout: 10 * time.Second}
var httpClient = &http.Client{Timeout: constants.HTTPRequestTimeout}

// HTTP request to get Module data
// badStatusError signals a non-200 response. It carries the status code so the
// retry logic can decide whether the failure is worth retrying (5xx) or is a
// permanent client error such as an unknown module (4xx).
type badStatusError struct {
module string
statusCode int
}

func (e *badStatusError) Error() string {
return fmt.Sprintf("failed to fetch module %s: status %d", e.module, e.statusCode)
}

// GetModuleData fetches a module's JSON data, retrying transient failures.
//
// Network errors and timeouts (the origin being briefly slow to respond) and
// 5xx responses are retried with a fixed backoff. A 4xx response is treated as
// permanent (e.g. an unknown module returns 404) and is not retried.
func GetModuleData(acadYear string, module string) ([]byte, error) {
var lastErr error
for attempt := 1; attempt <= constants.HTTPMaxAttempts; attempt++ {
body, err := fetchModuleData(acadYear, module)
if err == nil {
return body, nil
}
lastErr = err

// Don't retry permanent client errors (4xx) — retrying won't help.
var statusErr *badStatusError
if errors.As(err, &statusErr) &&
statusErr.statusCode >= http.StatusBadRequest &&
statusErr.statusCode < http.StatusInternalServerError {
return nil, err
}

// Don't sleep after the final attempt.
if attempt < constants.HTTPMaxAttempts {
time.Sleep(constants.HTTPRetryBackoff)
}
}
return nil, fmt.Errorf("giving up after %d attempts: %w", constants.HTTPMaxAttempts, lastErr)
}

// fetchModuleData performs a single HTTP request for a module's JSON data.
func fetchModuleData(acadYear string, module string) ([]byte, error) {
url := fmt.Sprintf(constants.ModulesURL, acadYear, module)
res, err := httpClient.Get(url)
if err != nil {
Expand All @@ -22,7 +65,7 @@ func GetModuleData(acadYear string, module string) ([]byte, error) {
defer res.Body.Close()

if res.StatusCode != http.StatusOK {
return nil, fmt.Errorf("failed to fetch module %s: status %d", module, res.StatusCode)
return nil, &badStatusError{module: module, statusCode: res.StatusCode}
}

body, err := io.ReadAll(res.Body)
Expand Down
17 changes: 17 additions & 0 deletions website/api/optimiser/_constants/constants.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,27 @@ package constants

import (
_ "embed"
"time"

models "github.com/nusmodifications/nusmods/website/api/optimiser/_models"
)

// HTTP client settings for fetching module data from the NUSMods API.
const (
// HTTPRequestTimeout bounds a single request (connect + awaiting headers +
// reading the body), applied per attempt rather than across all retries.
// Observed failures are connection/transit stalls on the way to the origin
// (the origin itself serves in milliseconds), not slow origin compute, so
// this is kept short to fail fast and let a retry try a fresh connection
// instead of blocking the whole budget on one stalled attempt.
HTTPRequestTimeout = 4 * time.Second
// HTTPMaxAttempts is the total number of attempts (1 initial + retries) for
// a single module before giving up.
HTTPMaxAttempts = 3
// HTTPRetryBackoff is the fixed delay between attempts.
HTTPRetryBackoff = 300 * time.Millisecond
)

// Ensure in sync with all E-Venues in NUSMods
var EVenues = map[string]struct{}{
"E-Learn_A": {},
Expand Down
153 changes: 100 additions & 53 deletions website/api/optimiser/_modules/modules.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"sort"
"strconv"
"strings"
"sync"

client "github.com/nusmodifications/nusmods/website/api/optimiser/_client"
constants "github.com/nusmodifications/nusmods/website/api/optimiser/_constants"
Expand Down Expand Up @@ -36,72 +37,118 @@ func GetAllModuleSlots(

// These are default or backup slots for the partial timetable so that we can display some random slot for unallocated lessons
defaultSlots := make(models.ModuleDefaultSlotsMap)
for _, module := range optimiserRequest.Modules {

body, err := client.GetModuleData(optimiserRequest.AcadYear, strings.ToUpper(module))
if err != nil {
return nil, nil, nil, err
}
// Fetch and process each module concurrently. The modules are independent of
// one another, so a single slow fetch no longer blocks the others; the total
// wall-clock time is bounded by the slowest module rather than their sum.
// Results are written into per-index slots to avoid concurrent map writes,
// then assembled into the maps once all goroutines have finished.
type moduleResult struct {
merged map[models.LessonType]map[models.ClassNo][]models.ModuleSlot
defaults map[models.LessonType][]models.ModuleSlot
err error
}
results := make([]moduleResult, len(optimiserRequest.Modules))

var wg sync.WaitGroup
for i, module := range optimiserRequest.Modules {
wg.Add(1)
go func(i int, module string) {
defer wg.Done()
merged, defaults, err := processModule(
optimiserRequest,
module,
venues,
recordingsMap,
freeDaysMap,
)
results[i] = moduleResult{merged: merged, defaults: defaults, err: err}
}(i, module)
}
Comment on lines +54 to +67

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Unbounded Request Fanout

When a request contains many modules, this loop starts one goroutine per module and each goroutine can make up to three outbound API attempts. A single oversized optimiser request can now create a burst of simultaneous HTTP calls, which can exhaust local resources or trigger upstream throttling instead of keeping outbound pressure bounded as the old sequential path did.

Prompt To Fix With AI
This is a comment left during a code review.
Path: website/api/optimiser/_modules/modules.go
Line: 54-67

Comment:
**Unbounded Request Fanout**

When a request contains many modules, this loop starts one goroutine per module and each goroutine can make up to three outbound API attempts. A single oversized optimiser request can now create a burst of simultaneous HTTP calls, which can exhaust local resources or trigger upstream throttling instead of keeping outbound pressure bounded as the old sequential path did.

How can I resolve this? If you propose a fix, please make it concise.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@TheMythologist I think this is a valid point also considering that we are working with not very powerful cpus in Vercel. Perhaps limiting it to some number of goroutines (ex: 4) using a Semaphore?

wg.Wait()
Comment on lines +53 to +68

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

HIGH Unbounded Concurrency in Module Processing Leads to Denial of Service (DoS) via Resource Exhaustion

The pull request changes the module processing logic in GetAllModuleSlots from sequential execution to concurrent execution using Go goroutines. However, it does not enforce any limit on the number of concurrent goroutines spawned, nor does it validate or limit the size of the modules array in the incoming OptimiserRequest.

An unauthenticated attacker can submit a request with an arbitrarily large number of modules (e.g., thousands of duplicate or unique module codes). The backend will attempt to spawn a goroutine and initiate an outbound HTTP request to the NUSMods API for each module concurrently.

This unbounded concurrency can quickly exhaust system resources, specifically:

  1. File Descriptors: Each concurrent HTTP request opens a network socket. If the number of concurrent requests exceeds the operating system's file descriptor limit (typically 1024), the process will fail to open new sockets, causing errors for all concurrent users.
  2. Memory and CPU: Allocating goroutines, HTTP request/response buffers, and parsing JSON payloads concurrently for thousands of modules will exhaust the memory and CPU of the serverless function, causing Vercel to terminate the execution environment.
  3. IP Rate Limiting: The sudden burst of thousands of concurrent requests to api.nusmods.com can trigger rate-limiting or IP blocks, disrupting the service for legitimate users.
Steps to Reproduce

An attacker can send a POST request to the optimiser endpoint with a payload containing a large number of module codes:

{
  "modules": ["CS1010S", "CS1010S", ... (repeated 5000 times) ...],
  "recordings": [],
  "freeDays": [],
  "earliestTime": "0800",
  "latestTime": "1800",
  "acadYear": "2024-2025",
  "acadSem": 1,
  "maxConsecutiveHours": 4,
  "lunchStart": "1200",
  "lunchEnd": "1300"
}

This will trigger 5,000 concurrent goroutines and outbound HTTP requests, leading to socket exhaustion or serverless function termination.

Fix with AI

Open in Cursor Open in Claude

A security vulnerability was found by Hacktron.

File: website/api/optimiser/_modules/modules.go
Lines: 53-68
Severity: high

Vulnerability: Unbounded Concurrency in Module Processing Leads to Denial of Service (DoS) via Resource Exhaustion

Description:
The pull request changes the module processing logic in `GetAllModuleSlots` from sequential execution to concurrent execution using Go goroutines. However, it does not enforce any limit on the number of concurrent goroutines spawned, nor does it validate or limit the size of the `modules` array in the incoming `OptimiserRequest`.

An unauthenticated attacker can submit a request with an arbitrarily large number of modules (e.g., thousands of duplicate or unique module codes). The backend will attempt to spawn a goroutine and initiate an outbound HTTP request to the NUSMods API for each module concurrently.

This unbounded concurrency can quickly exhaust system resources, specifically:
1. **File Descriptors**: Each concurrent HTTP request opens a network socket. If the number of concurrent requests exceeds the operating system's file descriptor limit (typically 1024), the process will fail to open new sockets, causing errors for all concurrent users.
2. **Memory and CPU**: Allocating goroutines, HTTP request/response buffers, and parsing JSON payloads concurrently for thousands of modules will exhaust the memory and CPU of the serverless function, causing Vercel to terminate the execution environment.
3. **IP Rate Limiting**: The sudden burst of thousands of concurrent requests to `api.nusmods.com` can trigger rate-limiting or IP blocks, disrupting the service for legitimate users.

Proof of Concept:
An attacker can send a POST request to the optimiser endpoint with a payload containing a large number of module codes:

```json
{
  "modules": ["CS1010S", "CS1010S", ... (repeated 5000 times) ...],
  "recordings": [],
  "freeDays": [],
  "earliestTime": "0800",
  "latestTime": "1800",
  "acadYear": "2024-2025",
  "acadSem": 1,
  "maxConsecutiveHours": 4,
  "lunchStart": "1200",
  "lunchEnd": "1300"
}
```

This will trigger 5,000 concurrent goroutines and outbound HTTP requests, leading to socket exhaustion or serverless function termination.

Affected Code:
	var wg sync.WaitGroup
	for i, module := range optimiserRequest.Modules {
		wg.Add(1)
		go func(i int, module string) {
			defer wg.Done()
			merged, defaults, err := processModule(
				optimiserRequest,
				module,
				venues,
				recordingsMap,
				freeDaysMap,
			)
			results[i] = moduleResult{merged: merged, defaults: defaults, err: err}
		}(i, module)
	}
	wg.Wait()

Acceptance criteria:
- Acceptance is defined by the **actual reported behavior**, not by tests passing.
- Reproduce the issue, or narrow the exact code path that produces it, *before* changing code. State what you confirmed.
- Fix the underlying cause. Mitigations that paper over the reported behavior do not count as a fix.
- Add a regression test that fails on the unpatched code and passes on the fix. If a regression test is genuinely impractical (e.g. race condition, infra-level issue), say so and explain why.
- Existing tests passing is **not** the bar. Do not declare done on tests-pass theatre.

Only change what is necessary to fix this vulnerability. Do not refactor adjacent code or modify unrelated files.

Triage: Reply !fp <reason> (false positive), !valid (confirmed), !accepted_risk <reason>, or !fixed (resolved). Any other reply is saved as a triage note.
Reason is optional but improves future scans — e.g. !fp internal endpoint, not user-facing.

View finding in Hacktron


var moduleData struct {
SemesterData []struct {
Semester int `json:"semester"`
Timetable []models.ModuleSlot `json:"timetable"`
} `json:"semesterData"`
}
err = json.Unmarshal(body, &moduleData)
if err != nil {
return nil, nil, nil, err
for i, module := range optimiserRequest.Modules {
if results[i].err != nil {
return nil, nil, nil, results[i].err
Comment on lines +68 to +72

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Terminal Errors Wait For All Fetches

When one module has a permanent error such as a 404, the request still waits for every already-started module goroutine to finish before returning that error. Mixed valid and invalid requests now make unnecessary external calls and can wait on unrelated slow retries, whereas the old sequential path stopped at the first failing module.

Prompt To Fix With AI
This is a comment left during a code review.
Path: website/api/optimiser/_modules/modules.go
Line: 68-72

Comment:
**Terminal Errors Wait For All Fetches**

When one module has a permanent error such as a 404, the request still waits for every already-started module goroutine to finish before returning that error. Mixed valid and invalid requests now make unnecessary external calls and can wait on unrelated slow retries, whereas the old sequential path stopped at the first failing module.

How can I resolve this? If you propose a fix, please make it concise.

}
moduleSlots[module] = results[i].merged
defaultSlots[module] = results[i].defaults
}

// Get the module timetable for the semester
var moduleTimetable []models.ModuleSlot
for _, semester := range moduleData.SemesterData {
if semester.Semester == optimiserRequest.AcadSem {
moduleTimetable = semester.Timetable
break
}
}
return moduleSlots, defaultSlots, recordingsMap, nil
}

// processModule fetches a single module's data and reduces it to its candidate
// and default slots. It is safe to run concurrently for different modules since
// it only reads the shared venues/recordings/freeDays maps and returns its
// result rather than writing to shared state.
func processModule(
optimiserRequest *models.OptimiserRequest,
module string,
venues map[string]models.Location,
recordingsMap map[string]struct{},
freeDaysMap map[string]struct{},
) (map[models.LessonType]map[models.ClassNo][]models.ModuleSlot, map[models.LessonType][]models.ModuleSlot, error) {
body, err := client.GetModuleData(optimiserRequest.AcadYear, strings.ToUpper(module))
if err != nil {
return nil, nil, err
}

// Parse the weeks
for i := range moduleTimetable {
var moduleData struct {
SemesterData []struct {
Semester int `json:"semester"`
Timetable []models.ModuleSlot `json:"timetable"`
} `json:"semesterData"`
}
err = json.Unmarshal(body, &moduleData)
if err != nil {
return nil, nil, err
}

// Note: if weeks is not a []int, then skip parsing
// Currently we are not handling week conflict for non-[]int weeks
if _, ok := moduleTimetable[i].Weeks.([]any); !ok {
continue
}
// Get the module timetable for the semester
var moduleTimetable []models.ModuleSlot
for _, semester := range moduleData.SemesterData {
if semester.Semester == optimiserRequest.AcadSem {
moduleTimetable = semester.Timetable
break
}
}

moduleTimetable[i].WeeksSet = make(map[int]struct{})
weeks := moduleTimetable[i].Weeks.([]any)
weeksStrings := make([]string, 0, len(weeks))
// Parse the weeks
for i := range moduleTimetable {

for _, week := range weeks {
weekFloat, ok := week.(float64)
if !ok {
continue
}
weekInt := int(weekFloat)
moduleTimetable[i].WeeksSet[weekInt] = struct{}{}
weeksStrings = append(weeksStrings, strconv.Itoa(weekInt))
}
moduleTimetable[i].WeeksString = strings.Join(weeksStrings, ",")
// Note: if weeks is not a []int, then skip parsing
// Currently we are not handling week conflict for non-[]int weeks
if _, ok := moduleTimetable[i].Weeks.([]any); !ok {
continue
}

// Store the module slots for the module
moduleSlots[module], defaultSlots[module] = mergeAndFilterModuleSlots(
moduleTimetable,
venues,
module,
recordingsMap,
freeDaysMap,
optimiserRequest.EarliestMin,
optimiserRequest.LatestMin,
)
moduleTimetable[i].WeeksSet = make(map[int]struct{})
weeks := moduleTimetable[i].Weeks.([]any)
weeksStrings := make([]string, 0, len(weeks))

for _, week := range weeks {
weekFloat, ok := week.(float64)
if !ok {
continue
}
weekInt := int(weekFloat)
moduleTimetable[i].WeeksSet[weekInt] = struct{}{}
weeksStrings = append(weeksStrings, strconv.Itoa(weekInt))
}
moduleTimetable[i].WeeksString = strings.Join(weeksStrings, ",")
}

return moduleSlots, defaultSlots, recordingsMap, nil
merged, defaults := mergeAndFilterModuleSlots(
moduleTimetable,
venues,
module,
recordingsMap,
freeDaysMap,
optimiserRequest.EarliestMin,
optimiserRequest.LatestMin,
)
return merged, defaults, nil
}

// Gets all venue information from venues.json
Expand Down