Click here to Skip to main content
Click here to Skip to main content

An introduction to Web Service Security using WSE - Part I

By , 1 Jun 2004
Rate this:
Please Sign up or sign in to vote.

TOC

Introduction

Web Services are thought of to be a means to provide easily accessible services over a network. They should be simply usable regardless of the underlying network structure or configuration, operating system, communication mechanism or implementing language.

While there are different possibilities to communicate with a Web Services, SOAP is regarded to be the de-facto standard. SOAP messages are being sent to service endpoints identified by URIs, commonly with the endpoint's service processing some action and sending a SOAP response containing results or error codes. This can simply be SOAP over HTTP or even a SOAP message packed in an e-mail, transferred by SMTP.

To be successful in business scenarios, Web Services have to be suitable for secure communication. Yet the original SOAP specification contains no solutions to solve the security problem. Other techniques as SSL or IPSec provide standard transport security. The problem: a SOAP connection from one endpoint to another can be seen as a logical connection, abstracting from the physical infrastructure beyond. Logically being an end-to-end connection, the physical layer can comprise of diverse intermediaries forwarding SOAP messages. So during this process of receiving and forwarding messages, security information defined on transport level, the way i.e. SSL works, can easily get lost. Thus any recipient had to rely on the security handling of his physical connection point predecessor, as well as its handling of the data integrity and confidentiality. A way out is to specify security information on message level.

Some big players as Microsoft and IBM built a group that dealed with the security problem, finally offering several specifications. The most important, and foundation of the others, is Web Service-Security (WS-Security or WSS).

WS-Security

WS-Security defines SOAP extensions to implement client authentication, message integrity and message confidentiality on the message level.

Thereby it's not the goal of WS-Security to invent new techniques, but to show how to use existing security solutions with SOAP and Web Service communication. It specifies rules for authentication, signatures and encryption mechanisms.
One benefit: WS-Security works in conjunction with other Web Service extensions.

Authentication

Authentication solves questions as "Who is the caller?" and "How does he prove his identity?". If these questions can be answered, it's the recipient's task to clarify the caller is to be trusted.
Authentication specifically prevents:

  • Masquerade attacks: Users must prove their identity, so it is more difficult to masquerade as another.
  • Replay attacks: When useing timestamps, it is difficult to reuse stolen authentication information.
  • Identity interception: When exchanges are additionally encrypted, intercepted identities are useless.

Note that authentication is only half the part of the security task. Once you know who the user is, you have to determine which resources the user is allowed to access. That's what authorization is for.

Integrity

Message integrity ensures the recipient that the data he receives has not been altered during transit. WS-Security tries to ensure integrity using the XML Signature specification, which defines a methodology for cryptographically signing XML. The signatures are defined using a <Signature> element and accompanying sub-elements as part of a security header.

The signature itself is computed based on the SOAP message content and a security token. The message receiver can check the validity of the message using an according decoding algorithm.

Confidentiality

Message confidentiality is to make the user sure that the data can't be read during transit, by means of message encryption. Here, the XML Encryption specification is the basis to encrypt portions of the SOAP messages. Any portions of SOAP messages, including headers, body blocks, and substructures, may be encrypted.

The encryption is realized using either symmetric keys shared by the sender and the receiver of the message or a key carried in the message in an encrypted form.

Since signatures and message encryption aren't within the scope of this article, please refer to the second part of this series. It will handle both topics in detail.

The <Security> Header

The entry-point to WS-Security is a SOAP header element, called <Security>. It contains the security-related data and information needed to implement mechanisms like security tokens, signatures or encryption. This element can be present multiple times to enable targeting different receivers (a so called SOAP role). The receiver can either be the ultimate message receiver or an intermediary. The target of a <Security> header is announced by use of the <role> element. To target security information for different recipients you must implement these information in different header blocks, each specifying a different<role> value. It's important that no two headers can have the same <role> value or omit the <role>. A header without a <role> value can be consumed by anyone.
Recognize the fact that no two security headers can use the same role. But an intermediary is not restricted to one security header - he can in fact consume multiple headers.

Below a SOAP message skeleton using the <Security> header:

<SOAP:Envelope xmlns:SOAP="...">
  <SOAP:Header>
    <wsse:Security SOAP:role="..." SOAP:mustUnderstand="...">
      <wsse:UsernameToken>
        ..
      </wsse:UsernameToken>
      ...
    </wsse:Security>
  </SOAP:Header>
  <SOAP:Body Id="MsgBody">
  <!–- SOAP Body data -->
  </SOAP:Body>
</SOAP:Envelope>

So as we can see only the header element of the SOAP message is altered to add WS-Security. All security elements are placed inside the <Security> element. The body remains as is.

Security Tokens

Identity and it's proof is the fist topic we are interested in. Without question most service providers see the importance to know who is talking to them, and whether to allow the message sender access to their services. And also the other way, the client to be sure of the service provider's authenticity, is of interest. But the latter is not within the scope of this article.
Authentication can be done using security tokens. WS-Security allows us to use any security token we like to use. Explicitly defined are three different options: username/password authentication in case of custom authentication and binary authentication tokens in the form of Kerberos tickets or X.509 certificates. In addition, custom binary security tokens can be applied.

The first option is to rely on custom authentication using username and password validation only. WSS defines an element called <UsernameToken> which provides support of this purpose.

<UsernameToken> among other things comprise the following sub-elements:

  • /Username
    username associated with this token
  • /Password
    password for the username associated with this token
  • /Password/@Type
    type of the password provided; 2 pre-defined types:
    • PasswordText
      clear text password
    • PasswordDigest
      digest of the password, base64-encoded SHA1 hash value of the UTF8-encoded password
  • /Nonce
    nonce for the token
  • /Created
    date and time of token creation

The sub-elements above are the elements important for the understanding of this article.

Username/Password Scenarios

There are different ways of using the <UsernameToken> element, dependent on the way the <Password> element is used.. The easiest way of identification would be just to convey a username and omit the password. A snippet of an according SOAP message can be seen below.

<UsernameToken>
  <Username>MyName</Username>
</UsernameToken>

It's clear that this mechanism alone would be quite insecure. Without any prove of identity, it's only useful in situations where some other authentication mechanism like SSL is used, with the user name only being a basic identification means.

But WS-Security gives us the means to implement "better" security. Conveying the <password> element as a part of the <UsernameToken> element, the first approach to (an admittedly weak) secure authentication would be done, since an identity could now be proven. A SOAP snippet would look at follows:

<UsernameToken>
  <Username>MyName</Username>
  <Password Type="PasswordText">MyPass</Password>
</UsernameToken>

The Type attribute indicates that the password is given as clear text. Thus, if someone intercepts the message he can easily figure out the password and authenticate himself. To prevent this, the password should ever be sent as a hashed value, making it impossible for an intermediary to see the real password. The hashing is done following the SHA-1 algorithm, then transmitting the hashed password base64-encoded.

<UsernameToken>
  <Username>MyName</Username>
  <Password Type="PasswordDigest">fm6SuM0RpIIhBQFgmESjdim/yj0=</Password>
</UsernameToken>

Now, the problem shifts from the danger of the password being clearly readable to the case that someone intercepts the message and uses the hashed password in his own message to authenticate himself. In this case it doesn't help that the malicious interceptor doesn't know the user's password itself - he just doesn't need to.
Even here WS-Security provides some additional means to make authentication safer: the password isn't transmitted as the hash value of the clear text, but the hash of a combination of the real text password, a Nonce and the creation time of the security token:

Password_Digest = Base64(SHA-1(Nonce + Created + Password))

A Nonce is a unique, random string that now identifies the password. In case it is correctly used and newly created for every SOAP message sent, no password hashes would be the same.

Taken this, the <UsernameToken> could look like this:

<UsernameToken>
  <Username>MyName</Username>
  <Password Type="PasswordDigest">fm6SuM0RpIIhBQFgmESjdim/yj0=</Password>
  <Nonce>Pj+EzE2y5ckMDx5ovEvzWw==</Nonce>
  <Created>2004-05-11T12:05:16Z</Created>
</UsernameToken>

In this scenario, the client creates the password and transmits the hashed value. The server uses the built-in mechanism to retrieve the password related to the given username, calculates the hash value and compares it to the received one. Access is granted if the hash values are identical.

But just handling the password like explained is not really secure. An evil user could take someone's whole UsernameToken and put it in his own request.

A first approach to prevent this could be to specify a timeout value for the token, thus a request with an expired timestamp wouldn't be accepted by the server. If the sender sets a timestamp of 60 seconds and the server receives the message later then 60 seconds after the given <Created> value, it simply rejects the whole request. This is easy to implement, but can also have some problems, like expired messages being accepted due to clock synchronisation issues on the server.

Regarding time synchronisation issues, WS-Security provides the <Timestamp> header. It can be used to express creation and expiration time of a message. This can be very useful for message creation, receipt and processing. As with the <Security> header, multiple <Timestamp> elements can be specified and targeted to different roles. The schema outline for the <Timestamp> element is as follows:

<Timestamp>
  <Created>...</Created>
  <Expires>...</Expires>
</Timestamp>

Note that the order of the sub-elements is fixed and thus has to be preserved by intermediaries. It's remarkable that while the sub-elements as <Created> are defined to be used with the <Timestamp> header, they can be used anywhere within the header or body when time-related markers are needed. Remember that we've already seen the <Created> element being used within the <UsernameToken> security token.

A next and probably better method to prevent UsernameToken replay is to keep a history of Nonce values from recently received requests on the server. Requests with a Nonce already used before would not be accepted. It's clear that the Nonce values have to be hold only for the time the request's timestamp propagates, then they can be deleted. In case several messages with similar Nonce values are received, all should be rejected since the interceptor could have delayed the original message and sent his own first.

Even with that many precautions made, one can not be sure to have a secure communication. Still a malicious person could stop a whole message from being delivered and use the UsernameToken to authenticate an own message. One needs to add a digital signature to his message to be secure against such situations, since this ensures the sender's authenticity.

Namespaces required

Notice that the xml snippets above don't have the namespace prefix included.
Important namespaces related to WS-Security and their associated prefixes are:

Prefix Namespace Some related elements
wsse http://schemas.xmlsoap.org/ws/2002/07/secext <Security>, <UsernameToken>, <Username>, <Password>, <Nonce>
wsu http://schemas.xmlsoap.org/ws/2002/07/utility <Timestamp>, <Created>, <Expires>
ds http://www.w3.org/2000/09/xmldsig# <Signature>, <SignedInfo>, <SignatureValue>, <KeyInfo>
xenc http://www.w3.org/2001/04/xmlenc# <EncryptedData>, <CipherData>

Setting up the WSE environment

The most important class WSE provides for our purposes is Microsoft.Web.Services.SoapContext. It gives us an interface to process the WS-Security header as well as other headers for incoming SOAP messages and to add WS-Security and other headers for outgoing SOAP messages. A wrapper class adds a SoapContext for the SOAP request and response. On the server side, a SOAP extension, Microsoft.Web.Services.Web ServicesExtension, validates incoming messages and provides SOAPContext for both request and response accessible from within our WebMethods.

First step is to set up the .NET application to use the WSE SOAP extension. This can be done machine-wide by adding an entry to machine.config, or with a scope restricted to the virtual directory of our service by adding the entry to its Web.config file. We choose the latter way since we don't want WSE support for all our applications. So just add a /configuration/system.web/webServices/soapExtensionTypes/Add element by typing the following lines:

<webServices>
  <soapExtensionTypes>
  <add type="Microsoft.Web.Services.Web ServicesExtension, 
    Microsoft.Web.Services, Version=1.0.0.0, Culture=neutral, 
    PublicKeyToken=31bf3856ad364e35" priority="1" group="0" 
  />
  </soapExtensionTypes>
</webServices>

Note that the type attribute has to be single-lined, and it's wrapped here for readability purposes.

Implementation

Now we start implementing a simple client authentication scenario. At the end of this article, we'll have a Web Service providing a simple method. This method is to be called by a client who adds WS-Security entries in form of the <security> header to his SOAP request. On the server side, the header is first gonna be evaluated and the client is granted access if authentication succeeded. Not until then the service' method will be called.

Password Provider

We know that the caller of a service has to add a UsernameToken to his SOAP request, and the recipient has to process the security token to validate the sender's user name and password. Thus given a user name, there has to be some mechanism to check a password against that user. The WSE provide a mechanism called a Password Provider to process this task.

To register a Password Provider, one needs to create a class implementing the interface Microsoft.Web.Services.Security.IPasswordProvider. The interface has one function called GetPassword, taking a Microsoft.Web.Services.Security.UsernameToken as an input argument. The task of GetPassword is to return the password related to the user name given in the UsernameToken. It's up to you what mechanism to use to retrieve the password. Most likely it would be a database-related solution.

My example Password Provider uses a very simple username/password association mechanism. Given a user name it searches a database for the associated password being returned by GetPassword. To have a (nearly) ready-to-go example, I used a simple text file accessed by ODBC as a data source. You'll probably need

to get the sample work. Having installed these packages the ODBC connection should work without problems.

Then create a simple .txt file (I named it pwd.txt) containing your username-password combinations similar to the following schema.

The first line is just the header and can be left out. The entries are separated by tabs, but it's up to you to choose another mechanism('|' for instance). Place the file in a folder accessible from your Password Provider - in my test scenario I simply placed it in the bin directory part of the IIS directory containing our Web Service.

Implementing GetPassword is all coding work to be done for a Password Provider. So create a new managed C++ project (ok, you can use C#, too) named PwdProvider. Call the namespace WS_Security and create a class MyPasswordProvider derived from IPasswordProvider. Add the function GetPassword as shown below.

String* GetPassword(UsernameToken __gc* token)
{
  if( token == 0 )
    throw new ArgumentNullException();
    
  // get odbc path from app settings and build connection string  
  Object *odbcPath = System::Configuration::
   ConfigurationSettings::AppSettings->get_Item("odbcPath");
  String *connString = String::Format(S"Driver={0};DBQ={1}", 
    S"{Microsoft Text Driver (*.txt; *.csv)}", odbcPath);
  
  // create and open a new odbc connection 
  OdbcConnection *conn = new OdbcConnection(connString);
  conn->Open();
  
  //get name of the datasource and build a command string
  String *odbcFile = System::Configuration::
   ConfigurationSettings::AppSettings->get_Item("odbcFile")->ToString();
  String *cmdString = String::Format(S"SELECT Username, 
    Password FROM {0} WHERE Username='{1}'", 
    odbcFile, token->Username);

  // create a command and execute it
  OdbcCommand *cmd = new OdbcCommand(cmdString, conn);
  OdbcDataReader *dr = cmd->ExecuteReader(CommandBehavior::CloseConnection);
 
  // read the results and return our password
  if( dr->Read() )
    return (String*)dr->get_Item(S"Password");
  else
    throw new ApplicationException("Unable to retrieve password");
}

The only interesting line is

return (String*)dr->get_Item(S"Password");

where the return of the password associated with the given user takes place. The other lines only suite for ODBC access actions to retrieve the password.

Check that the application configuration contains entries called odbcPath and odbcFile pointing to the location of your text file. A simple way is to add the values to the Web.config of our Web Service. We'll figure that out later.

A Web Service

Now that we've implemented the Password Provider let's build a simple Web Service.

First inform the WSE about the existence of your Password Provider. This is done by adding some entries to your Web Service application configuration file. The first entry specifies a WSE class that understands the configuration entry announcing the Password Provider. It's placed at /configuration in your Web.config file.

<configSections>
  <section name="microsoft.web.services"
    type="Microsoft.Web.Services.Configuration.Web ServicesConfiguration,
      Microsoft.Web.Services, Version=1.0.0.0, Culture=neutral, 
      PublicKeyToken=31bf3856ad364e35" />
</configSections>

Again, the type attribute has to appear at one line. Having introduced the microsoft.web.services element, the element that announces your Password Provider, /configuration/microsoft.web.services/security/passwordProvider, has to be added:

<microsoft.web.services>
  <security>
    <passwordProvider type="WS_Security.MyPasswordProvider, PwdProvider" />
  </security>
</microsoft.web.services>

The type attribute of the <passwordProvider> element tells WSE about your class implementing the Password Provider. In my case, the class is called MyPasswordProvider. It's defined in the WS_Security namespace and located in an Assembly called PwdProvider. Thus, the general form of this attribute is: NAMESPACE.CLASS, ASSEMBLY.

Since we currently deal with configuration issues, lets add a second element to .../microsoft.web.services: the diagnostics element. Especially for test and debugging purposes it's always helpful to have the SOAP messages received and sent by the Web Service logged. Now modify the security element as follows:

<microsoft.web.services>
  <security>
    <passwordProvider type="WS_Security.MyPasswordProvider, PwdProvider" />
  </security>
  <diagnostics>
       <trace enabled="true" input="inputTrace.config" 
  output="outputTrace.config" />
  </diagnostics>
</microsoft.web.services>

These lines enable tracing of the SOAP messages, where incoming messages are stored in inputTrace.config and outgoing messages in outputTrace.config, both in the folder your Web Service Dll is located in.

The Web Method

Now we add a web method to the service. It's the obligatory HelloWorld, simply returning a string when called.

String __gc* Class1::HelloWorld()
{
  // get access to the SOAP message's context
  SoapContext* sc = HttpSoapContext::RequestContext;
  if( sc == 0 )
    throw new ApplicationException(S"Only SOAP-requests allowed!");
    
  bool valid = false;
  SecurityToken *st = 0;
  
  // iterate through all security tokens
  IEnumerator *ie = sc->Security->Tokens->GetEnumerator();
  while( ie->MoveNext() ) {
    st = (SecurityToken *)ie->get_Current();

 
    // if the securtiy token is a UsernameToken stop iteration and go on
    if( st != 0 && st->GetType()->Equals(__typeof(UsernameToken)) )
    {
      valid = true;
      break;
    }
  }
  
  if( valid == false )
    throw new ApplicationException(S"Invalid or missing security token");

  // return the token's username
  UsernameToken *ut = (UsernameToken*)st;
  return ut->Username;
}

More important is what happens inside this method.

SoapContext* sc = HttpSoapContext::RequestContext
if( sc == 0 )
  throw new ApplicationException(S"Only SOAP-requests allowed!");

is used to retrieve the context for the SOAP request and to verify that a actually a SOAP message was received. Now we have access to the WS-Security features of the SOAP message.

Next step is to iterate through the security tokens part of the SOAP context. All security tokens associated with the SOAP message are contained in the Tokens collection that is a member of the Security class. This class represents the security header added to the SOAP message. It is accessed through the SoapContext we retrieved:

IEnumerator *ie = sc->Security->Tokens->GetEnumerator();

Our aim is to find a UsernameToken that contains the user name and password to validate. When found we simply return the sender's username.

Now there's one action to perform left. We still have to add the entries for our ODBC data source to a config file. For simplicity let's just take the Web Service' Web.config file. Insert a <appSettings> element and add a <add> element for odbcPath and odbcFile:

<configuration>
...
  <appSettings>
    <add key="odbcPath" value="YOUR_FOLDER_PATH" />
    <add key="odbcFile" value="YOUR_FILE" />
  </appSettings>
...
</configuration>

Now build the Web Service and let it add to the IIS web publishing folder. Also make PwdProvider.dll available to the service, just by adding is to the bin subfolder of the Web Service.

A Client Application

To finish the scenario let's build a client making use of the user validation implemented on the server-side in form of the Password Provider and our WebService.

In order to call our Web Service, we use the built-in capabilities of Visual Studio to create a proxy hiding the actual communication. This can be easy done by selecting Project->Add Web Reference, which calls wsdl.exe to create a source file for the Web Service proxy. Then wsdl.exe creates a .cs file containing the class that makes all Web Service calls. Here is the point where we have to hook into. Wsdl.exe derives the generated class from System.Web.Services.Protocols.SoapHttpClientProtocol by default. To be able to use the WSS methods to access the security headers, the proxy class has to inherit Microsoft.Web.Services.WebServicesClientProtocol. This gives us access to the RequestSoapContext and ResponseSoapContext, allowing us to process the WS-Security headers of the SOAP message.

Now create a method to call the Web Service. The main task herein is to create a new UsernameToken containing our username and password.

UsernameToken *ut = new UsernameToken("Otto",
  "Meier", PasswordOption::SendHashed);

These lines create a <UsernameToken> element as a part of the security header and add the <Username> and <Password> fields to it. WSE automatically appends the <Nonce> and <Created> elements and computes the password's hash value using the latter three elements.
Afterwards the token is added to the proxy's RequestSoapContext property.

ws->RequestSoapContext->Security->Tokens->Add(ut);

The example assumes that the WebService class is called SimpleWebService. Below you can see the method as a whole.

// create instance of the web service proxy
SimpleWebService *ws = new SimpleWebService();

// create a new UsernameToken and at it to the service' SOAP context
UsernameToken *ut = new UsernameToken("Otto", 
  "Meier", PasswordOption::SendHashed);
ws->RequestSoapContext->Security->Tokens->Add(ut);

Console::WriteLine(ws->HelloWorld());

Now remember what was said about the security issues (interceptors using the token and authenticating their own messages), and to use the <Created> element to limit the time a request is valid. As I said, this can be easily implemented - simply add the following line to above code:

ws->RequestSoapContext->Timestamp->Ttl = 60000;

Now if the server receives the message 60 seconds after it was created, the request is automatically rejected by the WSE.

Further Information

If you're interested in this topic and are looking for further information try these sites:

Installing the Examples

To test the examples included, you need an IIS running on your system. Then, build the Visual Studio projects. If configured properly, the Web Service will be automatically added to your IIS' web folder. PwdProvider.dll and Pwd.txt have to be in its bin folder to be found by the WSE. Note that the file WebService.dll needs to reside in the same folder as your WSClient.exe to be found by the Web Service Client. Then, simply start WSClient.exe, and if everything worked as expected "Otto" should be returned by the service.

Perspective

This article dealt with the basic authentication mechanism WS-Security provides, the username/password client authentication. The next part of this article series will go a little further and handle X.509 certificates and digital signing of SOAP messages, as a whole or in parts. Possibly, some other things will also play a role - that depends on my free time during the next days ... or weeks Smile | :)

License

This article has no explicit license attached to it but may contain usage terms in the article text or the download files themselves. If in doubt please contact the author via the discussion board below.

A list of licenses authors might use can be found here

About the Author

HENDRIK R
Web Developer
Germany Germany
Gained first computer game experiences on Atari and Commodore C64. Improved the gaming skills for about 10 years, then had my first Windows95 installed and wondered how to develop own applications.
 
Since that time playing around with C++, JAVA etc., from time to time trying to broaden my horizon with some new stuff. Currently I'm a student in computer science, spending more time in working as a freelancer than visiting any courses. Still confident of managing to finish my studies in 2005.

Comments and Discussions

 
GeneralMy vote of 4 Pinmembernguyen19088726-May-13 18:26 
Generalpls i need assistance Pinmemberflipsytipsy26-Nov-09 13:37 
GeneralREQUEST OD HELP ABOUT WEB SERVICES SECURITY Pinmemberxle291118-Nov-09 10:13 
GeneralWSE Version 3 of this article. Pinmembermadheadwork23-Apr-08 1:14 
GeneralRe: WSE Version 3 of this article. Pinmemberrexahs2-Nov-09 23:36 
GeneralSome question regarding IIS setting Pinmembernzhuda13-Dec-06 15:47 
GeneralA real novice question Pinmemberfred796-Sep-06 14:30 
QuestionHow about a VS 2005 Version? PinmemberMyPaq8-Apr-06 12:22 
GeneralThank you for the good article! Pinmemberanichin27-Jan-06 12:01 
GeneralRegarding Suggestion for how to webservice in my application PinmemberDeepak_DOTNET12-Jan-06 1:09 
QuestionWhat if my database only store pw Hash? Pinmembernorm25-Nov-05 23:20 
QuestionCan any one help in writing a C# webService Pinmembersudepally24-Jan-05 12:14 
GeneralWell done PinmemberTadejMali6-Nov-04 12:47 
GeneralI'm battling a bit :-( Pinmemberjou_ma_se_epos19-Aug-04 5:45 
GeneralRe: I'm battling a bit :-( PinmemberHENDRIK R28-Sep-04 4:35 
GeneralThanks for good artical Pinmembercoolvcguy10-Jun-04 6:44 
GeneralGreat Article. PinmemberDean Bathke28-May-04 3:03 
GeneralRe: Great Article. PinmemberHENDRIK R28-May-04 5:02 
GeneralExcellent PinmemberKugan Kandasamy21-May-04 9:19 
GeneralRe: Excellent PinmemberHENDRIK R21-May-04 22:25 
GeneralExcellent! PinmemberA. Riazi17-May-04 1:47 
GeneralRe: Excellent! PinmemberHENDRIK R17-May-04 3:27 
GeneralUpdate coming .... PinmemberHENDRIK R16-May-04 22:06 
GeneralRe: Update coming .... Pinmembersurendar_ab25-Oct-06 22:12 

General General    News News    Suggestion Suggestion    Question Question    Bug Bug    Answer Answer    Joke Joke    Rant Rant    Admin Admin   

Use Ctrl+Left/Right to switch messages, Ctrl+Up/Down to switch threads, Ctrl+Shift+Left/Right to switch pages.

| Advertise | Privacy | Mobile
Web03 | 2.8.140415.2 | Last Updated 2 Jun 2004
Article Copyright 2004 by HENDRIK R
Everything else Copyright © CodeProject, 1999-2014
Terms of Use
Layout: fixed | fluid