-
Notifications
You must be signed in to change notification settings - Fork 20
Expand file tree
/
Copy pathserver_http_utils.py
More file actions
203 lines (176 loc) · 7 KB
/
Copy pathserver_http_utils.py
File metadata and controls
203 lines (176 loc) · 7 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
import base64
import hashlib
import hmac
import json
from urllib.parse import urlparse
def build_json_response(status_code, payload, allow_headers, allow_methods, extra_headers=None, allow_origin=''):
reason_map = {
200: 'OK',
204: 'No Content',
400: 'Bad Request',
401: 'Unauthorized',
403: 'Forbidden',
404: 'Not Found',
405: 'Method Not Allowed',
413: 'Payload Too Large',
429: 'Too Many Requests',
500: 'Internal Server Error',
}
body = json.dumps(payload, ensure_ascii=False, sort_keys=True).encode('utf-8')
header_lines = [
'HTTP/1.1 %s %s' % (status_code, reason_map.get(status_code, 'OK')),
'Content-Type: application/json; charset=utf-8',
'Content-Length: %s' % len(body),
'Connection: close',
'Cache-Control: no-store',
]
if extra_headers:
header_lines.extend(extra_headers)
# Only advertise CORS when an origin is explicitly allowed. The sole intended
# consumer is the Django backend over loopback, which needs no CORS at all;
# emitting a wildcard would let any page in a browser on this host read the
# trading account and reach /order.
if allow_origin:
header_lines.extend([
'Access-Control-Allow-Origin: %s' % allow_origin,
'Access-Control-Allow-Headers: %s' % allow_headers,
'Access-Control-Allow-Methods: %s' % allow_methods,
])
header_lines.extend(['', ''])
return '\r\n'.join(header_lines).encode('utf-8') + body
def extract_request_token(headers, normalize_auth_token):
token = normalize_auth_token(headers.get('x-qmt-token'))
if token is not None:
return token, None
authorization = headers.get('authorization')
if authorization:
prefix = 'bearer '
lowered = authorization.lower()
if lowered.startswith(prefix):
token = normalize_auth_token(authorization[len(prefix):])
if token is not None:
return token, None
protocol_header = headers.get('sec-websocket-protocol')
if protocol_header:
for item in protocol_header.split(','):
protocol = item.strip()
if not protocol.startswith('qmt-token.'):
continue
token = normalize_auth_token(protocol[len('qmt-token.'):])
if token is not None:
return token, protocol
return None, None
def is_request_authorized(request, configured_auth_token, normalize_auth_token):
"""Fail closed: an unset auth_token denies every request rather than allowing all.
This server can place real orders, so a missing or unreadable server_config.json
must never degrade into an open endpoint.
"""
if not configured_auth_token:
return False, None
provided_token, ws_protocol = extract_request_token(request['headers'], normalize_auth_token)
if provided_token is None:
return False, None
if len(provided_token) != len(configured_auth_token):
return False, ws_protocol
return hmac.compare_digest(provided_token, configured_auth_token), ws_protocol
def is_host_allowed(request, allowed_hosts):
"""Reject requests whose Host header is not a loopback name, blocking DNS rebinding."""
host = (request['headers'].get('host') or '').strip().lower()
if not host:
return False
return host in allowed_hosts
def build_unauthorized_response(allow_headers, allow_methods, allow_origin=''):
return build_json_response(
401, {'error': 'unauthorized'}, allow_headers, allow_methods, allow_origin=allow_origin,
)
def build_forbidden_host_response(allow_headers, allow_methods, allow_origin=''):
return build_json_response(
403, {'error': 'host_not_allowed'}, allow_headers, allow_methods, allow_origin=allow_origin,
)
def parse_http_request(request_bytes):
header_end = request_bytes.find(b'\r\n\r\n')
if header_end < 0:
return None
request_head = request_bytes[:header_end].decode('iso-8859-1', 'replace')
lines = request_head.split('\r\n')
if not lines:
return None
parts = lines[0].split()
if len(parts) < 2:
return None
headers = {}
for line in lines[1:]:
if ':' not in line:
continue
key, value = line.split(':', 1)
headers[key.strip().lower()] = value.strip()
return {
'method': parts[0].upper(),
'target': parts[1],
'headers': headers,
}
def build_websocket_handshake_response(
request,
websocket_guid,
configured_auth_token,
normalize_auth_token,
allow_headers,
allow_methods,
allowed_hosts=(),
allow_origin='',
):
if request is None:
return None, False
if request['method'] != 'GET':
return None, False
parsed = urlparse(request['target'])
if (parsed.path or '/') != '/ws':
return None, False
headers = request['headers']
if headers.get('upgrade', '').lower() != 'websocket':
return None, False
websocket_key = headers.get('sec-websocket-key')
if not websocket_key:
return None, False
if allowed_hosts and not is_host_allowed(request, allowed_hosts):
return build_forbidden_host_response(allow_headers, allow_methods, allow_origin), False
authorized, ws_protocol = is_request_authorized(request, configured_auth_token, normalize_auth_token)
if not authorized:
return build_unauthorized_response(allow_headers, allow_methods, allow_origin), False
accept_source = (websocket_key + websocket_guid).encode('utf-8')
accept_value = base64.b64encode(hashlib.sha1(accept_source).digest()).decode('ascii')
response_lines = [
'HTTP/1.1 101 Switching Protocols',
'Upgrade: websocket',
'Connection: Upgrade',
'Sec-WebSocket-Accept: %s' % accept_value,
]
if ws_protocol is not None:
response_lines.append('Sec-WebSocket-Protocol: %s' % ws_protocol)
response_lines.extend(['', ''])
return '\r\n'.join(response_lines).encode('utf-8'), True
def build_ws_frame(payload_bytes, opcode=1):
payload_length = len(payload_bytes)
header = bytearray()
header.append(0x80 | (opcode & 0x0F))
if payload_length < 126:
header.append(payload_length)
elif payload_length < 65536:
header.append(126)
header.extend([(payload_length >> 8) & 0xFF, payload_length & 0xFF])
else:
header.append(127)
for shift in [56, 48, 40, 32, 24, 16, 8, 0]:
header.append((payload_length >> shift) & 0xFF)
return bytes(header) + payload_bytes
def build_ws_json_frame(payload):
return build_ws_frame(json.dumps(payload, ensure_ascii=False, sort_keys=True).encode('utf-8'))
def build_ws_close_frame():
return build_ws_frame(b'', opcode=8)
def build_ws_pong_frame(payload_bytes):
return build_ws_frame(payload_bytes, opcode=10)
def queue_client_response(client, payload_bytes):
if client['response_bytes']:
return
client['response_bytes'] = payload_bytes
client['response_offset'] = 0