Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Fix #54 cors #67

Open
wants to merge 9 commits into
base: master
Choose a base branch
from
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
52 changes: 52 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -173,6 +173,7 @@ Below is the complete list of available options that can be used to customize yo
| `PCRE_MATCHLIMIT` | Maximum PCRE match calls. Defaults to `100000` |
| `PCRE_RECMATCHLIMIT` | Maximum recursive match calls to PCRE. Defaults to `2000` |
| `SIGNATURE_CHECKS` | How many times per day to check for a new database signature. Must be between 1 and 50. Defaults to `2` |
| `ALLOW_ORIGINS` | A semicolon (`;`) split string of allowed origins. Defaults to `*` |

## Networking

Expand Down Expand Up @@ -243,6 +244,57 @@ docker run -p 9000:9000 -p 9443:9443 -itd --name clamav-rest clamav-rest

Note that the `docker build` command also takes care of compiling the source. Therefore you do not need to perform the manual build steps from above nor do you need a local go development environment.

## Protocol Support

Go 1.24 added unencrypted "HTTP/2 with Prior Knowledge" support into the `net/http` standard library, which is useful for microservices behind firewalls and load balancers.

Simple checks on the docker container running locally:

```bash
$ curl -i -k --http1.1 -F "file=@clamrest.go" https://localhost:9443/v2/scan

HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8
Date: Fri, 28 Feb 2025 21:49:54 GMT
Content-Length: 59

[{"Status":"OK","Description":"","FileName":"clamrest.go"}]
```

```bash
$ curl -i -k --http2-prior-knowledge -F "file=@clamrest.go" http://localhost:9000/v2/scan

HTTP/2 200
content-type: application/json; charset=utf-8
content-length: 59
date: Fri, 28 Feb 2025 21:49:17 GMT

[{"Status":"OK","Description":"","FileName":"clamrest.go"}]
```

```bash
$ curl -i -k --http2 -F "file=@clamrest.go" https://localhost:9443/v2/scan

HTTP/2 200
content-type: application/json; charset=utf-8
content-length: 59
date: Fri, 28 Feb 2025 21:49:33 GMT

[{"Status":"OK","Description":"","FileName":"clamrest.go"}]
```

## Python Tests

Some very quick notes about running the python tests:

- Create a virtual environment (e.g. `python -m venv pyenv`)
- Activate the environment (`source pyenv/bin/activate` for linux/macOS)
- Install packages (`pip install -r tests/requirements.txt`)
- Run clam-av locally (`docker compose -f 'docker-compose.test.yml' up -d --build`).
- Run tests `behave tests/features`

You can then deactivate the python environment with `deactivate`, and shutdown the container with `docker compose -f 'docker-compose.test.yml' down`.

## Updates

2025-02-07: Improved documentation.
Expand Down
85 changes: 66 additions & 19 deletions clamrest.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import (
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/collectors"
"github.com/prometheus/client_golang/prometheus/promhttp"
"github.com/rs/cors"
)

var opts map[string]string
Expand Down Expand Up @@ -85,6 +86,8 @@ func scanPathHandler(w http.ResponseWriter, r *http.Request) {
paths, ok := r.URL.Query()["path"]
if !ok || len(paths[0]) < 1 {
log.Println("Url Param 'path' is missing")
w.WriteHeader(http.StatusBadRequest)
w.Write([]byte("URL param 'path' is missing"))
return
}

Expand Down Expand Up @@ -185,7 +188,7 @@ func scanner(w http.ResponseWriter, r *http.Request, version int) {
eachResp := scanResponse{Status: s.Status, Description: s.Description}
if version == 2 {
eachResp.FileName = part.FileName()
fmt.Printf("scanned file %v", part.FileName())
fmt.Printf("%v Scanned file %v\n", time.Now().Format(time.RFC3339), part.FileName())
}
//Set each possible status and then send the most appropriate one
eachResp.httpStatus = getHttpStatusByClamStatus(s)
Expand All @@ -198,14 +201,14 @@ func scanner(w http.ResponseWriter, r *http.Request, version int) {
if version == 2 {
jsonRes, jErr := json.Marshal(resp)
if jErr != nil {
fmt.Printf("Error marshalling json, %v\n", jErr)
fmt.Printf("%v Error marshalling json, %v\n", time.Now().Format(time.RFC3339), jErr)
}
fmt.Fprint(w, string(jsonRes))
} else {
for _, v := range resp {
jsonRes, jErr := json.Marshal(v)
if jErr != nil {
fmt.Printf("Error marshalling json, %v\n", jErr)
fmt.Printf("%v Error marshalling json, %v\n", time.Now().Format(time.RFC3339), jErr)
}
fmt.Fprint(w, string(jsonRes))
}
Expand Down Expand Up @@ -279,10 +282,10 @@ func scanHandlerBody(w http.ResponseWriter, r *http.Request) {
if err != nil {
w.WriteHeader(http.StatusRequestEntityTooLarge)
resp := scanResponse{Status: clamd.RES_PARSE_ERROR, Description: "File size limit exceeded"}
fmt.Printf("%v Clamd returned error, broken pipe and closed connection can indicate too large file, %v", time.Now().Format(time.RFC3339), err)
fmt.Printf("%v Clamd returned error, broken pipe and closed connection can indicate too large file, %v\n", time.Now().Format(time.RFC3339), err)
jsonResp, jErr := json.Marshal(resp)
if jErr != nil {
fmt.Printf("%v Error marshalling json, %v", time.Now().Format(time.RFC3339), jErr)
fmt.Printf("%v Error marshalling json, %v\n", time.Now().Format(time.RFC3339), jErr)
}
fmt.Fprint(w, string(jsonResp))
return
Expand All @@ -308,22 +311,21 @@ func waitForClamD(port string, times int) {

if err != nil {
if times < 30 {
fmt.Printf("clamD not running, waiting times [%v]\n", times)
fmt.Printf("%v clamD not running, waiting times [%v]\n", time.Now().Format(time.RFC3339), times)
time.Sleep(time.Second * 4)
waitForClamD(port, times+1)
} else {
fmt.Printf("Error getting clamd version: %v\n", err)
fmt.Printf("%v Error getting clamd version: %v\n", time.Now().Format(time.RFC3339), err)
os.Exit(1)
}
} else {
for version_string := range version {
fmt.Printf("Clamd version: %#v\n", version_string.Raw)
fmt.Printf("%v Clamd version: %#v\n", time.Now().Format(time.RFC3339), version_string.Raw)
}
}
}

func main() {

opts = make(map[string]string)

// https://github.com/prometheus/client_golang/blob/main/examples/gocollector/main.go
Expand All @@ -348,26 +350,71 @@ func main() {
waitForClamD(opts["CLAMD_PORT"], 1)

fmt.Printf("Connected to clamd on %v\n", opts["CLAMD_PORT"])
mux := http.NewServeMux()
//Add cors middleware
c := cors.New(getCorsPolicy())

http.HandleFunc("/scan", scanHandler)
http.HandleFunc("/v2/scan", v2ScanHandler)
http.HandleFunc("/scanPath", scanPathHandler)
http.HandleFunc("/scanHandlerBody", scanHandlerBody)
http.HandleFunc("/version", clamversion)
http.HandleFunc("/", home)
mux.HandleFunc("POST /scan", scanHandler)
mux.HandleFunc("POST /v2/scan", v2ScanHandler)
mux.HandleFunc("GET /scanPath", scanPathHandler)
mux.HandleFunc("POST /scanHandlerBody", scanHandlerBody)
mux.HandleFunc("GET /version", clamversion)
mux.HandleFunc("GET /", home)

// Prometheus metrics
http.Handle("/metrics", promhttp.HandlerFor(
mux.Handle("GET /metrics", promhttp.HandlerFor(
reg,
promhttp.HandlerOpts{
// Opt into OpenMetrics to support exemplars.
EnableOpenMetrics: true,
},
))

// Start the HTTPS server in a goroutine
go http.ListenAndServeTLS(fmt.Sprintf(":%s", opts["SSL_PORT"]), "/etc/ssl/clamav-rest/server.crt", "/etc/ssl/clamav-rest/server.key", nil)
//Attach the cors middleware to the middleware chain/request pipeline
handler := c.Handler(mux)

// Configure the HTTPS server
tlsServer := &http.Server{
Addr: fmt.Sprintf(":%s", opts["SSL_PORT"]),
Handler: handler,
}

// Configure the HTTP server with h2c support
var protocols http.Protocols
protocols.SetHTTP1(true)
protocols.SetUnencryptedHTTP2(true) // Enable h2c support
protocols.SetHTTP2(true)

httpServer := &http.Server{
Addr: fmt.Sprintf(":%s", opts["PORT"]),
Handler: handler,
Protocols: &protocols,
}
// Start the HTTPS server in a goroutine
go func() {
log.Fatal(tlsServer.ListenAndServeTLS("/etc/ssl/clamav-rest/server.crt", "/etc/ssl/clamav-rest/server.key"))
}()
// Start the HTTP server
http.ListenAndServe(fmt.Sprintf(":%s", opts["PORT"]), nil)
log.Fatal(httpServer.ListenAndServe())
}

func getCorsPolicy() cors.Options {
envs := os.Environ()
var allow_origins []string

for _, env := range envs {
e := strings.Split(env, "=")
if strings.EqualFold(e[0], "allow_origins") {
allow_origins = strings.Split(e[1], ";")
}
}
if len(allow_origins) == 0 {
allow_origins = []string{"*"}
}
return cors.Options{
AllowedOrigins: allow_origins,
AllowedHeaders: []string{"Content-Type", "Accept-Language", "Accept"},
AllowedMethods: []string{http.MethodGet, http.MethodPost, http.MethodOptions},
AllowCredentials: false,
}
}
8 changes: 8 additions & 0 deletions docker-compose.test.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
services:
clamav-rest:
build:
context: .
dockerfile: Dockerfile
ports:
- "9000:9000"
- "9443:9443"
1 change: 1 addition & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ require (
github.com/prometheus/client_model v0.6.1 // indirect
github.com/prometheus/common v0.62.0 // indirect
github.com/prometheus/procfs v0.15.1 // indirect
github.com/rs/cors v1.11.1 // indirect
golang.org/x/sys v0.30.0 // indirect
google.golang.org/protobuf v1.36.5 // indirect
)
2 changes: 2 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,8 @@ github.com/prometheus/procfs v0.12.0 h1:jluTpSng7V9hY0O2R9DzzJHYb2xULk9VTR1V1R/k
github.com/prometheus/procfs v0.12.0/go.mod h1:pcuDEFsWDnvcgNzo4EEweacyhjeA9Zk3cnaOZAZEfOo=
github.com/prometheus/procfs v0.15.1 h1:YagwOFzUgYfKKHX6Dr+sHT7km/hxC76UB0learggepc=
github.com/prometheus/procfs v0.15.1/go.mod h1:fB45yRUv8NstnjriLhBQLuOUt+WW4BsoGhij/e3PBqk=
github.com/rs/cors v1.11.1 h1:eU3gRzXLRK57F5rKMGMZURNdIG4EoAmX8k94r9wXWHA=
github.com/rs/cors v1.11.1/go.mod h1:XyqrcTp5zjWr1wsJ8PIRZssZ8b/WMcMf71DJnit4EMU=
golang.org/x/sys v0.17.0 h1:25cE3gD+tdBA7lp7QfhuV+rJiE9YXTcS3VG1SqssI/Y=
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.22.0 h1:RI27ohtqKCnwULzJLqkv897zojh5/DwS/ENaMzUOaWI=
Expand Down
19 changes: 19 additions & 0 deletions tests/features/protocol-support.feature
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
Feature: testing protocol support for the REST API

Scenario Outline: scan files with different protocols
Given I have a file with contents <content>
When I scan the file using protocol <protocol>
Then I get a http status of <status>
And the protocol used is <protocol>

Examples: clean_files
| content | protocol | status |
| "hello world" | "http1.1" | "200" |
| "hello world" | "h2c" | "200" |
| "hello world" | "https" | "200" |

Examples: virus_files
| content | protocol | status |
| "X5O!P%@AP[4\PZX54(P^)7CC)7}$EICAR-STANDARD-ANTIVIRUS-TEST-FILE!$H+H*" | "http1.1" | "406" |
| "X5O!P%@AP[4\PZX54(P^)7CC)7}$EICAR-STANDARD-ANTIVIRUS-TEST-FILE!$H+H*" | "h2c" | "406" |
| "X5O!P%@AP[4\PZX54(P^)7CC)7}$EICAR-STANDARD-ANTIVIRUS-TEST-FILE!$H+H*" | "https" | "406" |
32 changes: 32 additions & 0 deletions tests/features/steps/protocol.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
from behave import when, then
import httpx

@when('I scan the file using protocol "{protocol}"')
def step_impl(context, protocol):
files = {'file': context.file_contents}

if protocol == "http1.1":
# Use HTTPX with HTTP/1.1
with httpx.Client(http1=True, http2=False) as client:
r = client.post(f"{context.clamrest}/v2/scan", files=files)
elif protocol == "h2c":
# Use HTTPX with HTTP/2 prior knowledge mode
with httpx.Client(http2=True, http1=False) as client:
r = client.post(f"{context.clamrest}/v2/scan", files=files)
elif protocol == "https":
# Use HTTPX with HTTPS and HTTP/2
https_url = context.clamrest.replace('http:', 'https:').replace(':9000', ':9443')
with httpx.Client(verify=False, http2=True) as client:
r = client.post(f"{https_url}/v2/scan", files=files)

context.result = r

@then('the protocol used is "{protocol}"')
def step_impl(context, protocol):
if protocol == "http1.1":
assert context.result.http_version == "HTTP/1.1", f"Expected HTTP/1.1, got {context.result.http_version}"
elif protocol in ["h2c", "https"]:
assert context.result.http_version == "HTTP/2", f"Expected HTTP/2, got {context.result.http_version}"

if protocol == "https":
assert context.result.url.scheme == "https", "Expected HTTPS URL"
4 changes: 2 additions & 2 deletions tests/features/steps/scanning.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,11 @@

@given('I have a file with contents "{contents}"')
def step_imp(context, contents):
context.file_contents = contents
context.file_contents = contents

@when('I scan the file for a virus')
def step_impl(context):
files = { 'file': context.file_contents }
files = {'file': context.file_contents}
url = context.clamrest
r = requests.post(url + "/scan", files=files)
context.result = r
Expand Down
18 changes: 18 additions & 0 deletions tests/features/steps/v2-scanning.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
from behave import *
from hamcrest import *
import requests

@given('I have a file with contents "{contents}" to scan with v2')
def step_imp(context, contents):
context.file_contents = contents

@when('I v2/scan the file for a virus')
def step_impl(context):
files = {'file': context.file_contents}
url = context.clamrest
r = requests.post(url + "/v2/scan", files=files)
context.result = r

@then('I get a http status of "{status}" from v2/scan')
def step_impl(context, status):
assert_that(int(status), equal_to(context.result.status_code))
2 changes: 1 addition & 1 deletion tests/features/virus-scan.feature
Original file line number Diff line number Diff line change
Expand Up @@ -8,4 +8,4 @@ Feature: testing virus scanning through rest API
Examples: virus_files
| content | status |
| "hello_world" | "200" |
| "X5O!P%@AP[4\PZX54(P^)7CC)7}$EICAR-STANDARD-ANTIVIRUS-TEST-FILE!$H+H*" | "500" |
| "X5O!P%@AP[4\PZX54(P^)7CC)7}$EICAR-STANDARD-ANTIVIRUS-TEST-FILE!$H+H*" | "406" |
11 changes: 11 additions & 0 deletions tests/features/virus-v2-scan.feature
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
Feature: testing virus scanning through rest API

Scenario Outline: scan files for viruses
Given I have a file with contents <content> to scan with v2
When I v2/scan the file for a virus
Then I get a http status of <status> from v2/scan

Examples: virus_files
| content | status |
| "hello_world" | "200" |
| "X5O!P%@AP[4\PZX54(P^)7CC)7}$EICAR-STANDARD-ANTIVIRUS-TEST-FILE!$H+H*" | "406" |
17 changes: 9 additions & 8 deletions tests/requirements.txt
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
PyHamcrest==1.8.5
argparse==1.2.1
behave==1.2.5
PyHamcrest>=1.10.0
argparse>=1.4.0
behave>=1.2.6
enum34==1.0.4
parse==1.6.6
parse-type==0.3.4
requests==2.7.0
six==1.9.0
wsgiref==0.1.2
parse>=1.19.0
parse-type>=0.6.0
requests>=2.31.0
httpx[http2]>=0.28.1
urllib3>=2.0.0
cryptography>=41.0.0